mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-10-05 11:18:24 +00:00
Merge remote-tracking branch 'origin/main' into claude/vigilant-ptolemy-ggxtwn
This commit is contained in:
+27
-1
@@ -44,6 +44,7 @@ import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.Immutable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.produceState
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
@@ -94,6 +95,7 @@ import com.vitorpamplona.amethyst.commons.ui.note.elements.NoteActionHandlers
|
||||
import com.vitorpamplona.amethyst.commons.ui.note.elements.ShareOptionsBottomSheet
|
||||
import com.vitorpamplona.amethyst.commons.ui.note.elements.noteActionSections
|
||||
import com.vitorpamplona.amethyst.commons.ui.note.elements.observeBookmarksFollowsAndAccount
|
||||
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.publicChannels.concord.ConcordExpiringPinDialog
|
||||
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.report.ReportNoteDialog
|
||||
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.wallet.OnchainZapSendDialog
|
||||
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.wallet.navigateToReloadMint
|
||||
@@ -110,12 +112,15 @@ import com.vitorpamplona.amethyst.ui.note.ZapAmountChoiceGrid
|
||||
import com.vitorpamplona.amethyst.ui.note.observeZapRailCapability
|
||||
import com.vitorpamplona.amethyst.ui.note.payViaIntentOrManualSplit
|
||||
import com.vitorpamplona.quartz.buzz.stream.StreamMessageV2Event
|
||||
import com.vitorpamplona.quartz.concord.cord03Channels.ConcordDisappearing
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip22Comments.CommentEvent
|
||||
import com.vitorpamplona.quartz.nipC7Chats.ChatEvent
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
import kotlinx.collections.immutable.toImmutableSet
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlin.uuid.ExperimentalUuidApi
|
||||
|
||||
// null amount = open the on-chain dialog with no prefill.
|
||||
@@ -393,17 +398,38 @@ fun ChatMessageActionSheet(
|
||||
|
||||
// Concord (CORD-04 §7): pin/unpin into the channel's Pin List. Only offered to a
|
||||
// PIN_MESSAGES holder who can write the Control Plane (null otherwise).
|
||||
val concordPinned = remember(note) { accountViewModel.account.concord.concordPinState(note) }
|
||||
// Read off the main thread: it verifies the channel's whole Pin List.
|
||||
val concordPinState by produceState<Boolean?>(null, note) {
|
||||
value = withContext(Dispatchers.Default) { accountViewModel.account.concord.concordPinState(note) }
|
||||
}
|
||||
val concordPinned = concordPinState
|
||||
if (concordPinned != null && !note.isDraft()) {
|
||||
var confirmExpiringPin by remember(note) { mutableStateOf(false) }
|
||||
SectionDivider()
|
||||
TileRow {
|
||||
val label = if (concordPinned) Res.string.relay_group_unpin_message else Res.string.relay_group_pin_message
|
||||
ActionTile(MaterialSymbols.PushPin, stringRes(label)) {
|
||||
// Pinning a disappearing message (CORD-08) keeps its words past the timer: ask first.
|
||||
val expires = note.event?.let { ConcordDisappearing.expirationOf(it) } != null
|
||||
if (!concordPinned && expires) {
|
||||
confirmExpiringPin = true
|
||||
} else {
|
||||
accountViewModel.toggleConcordPin(note)
|
||||
onDismiss()
|
||||
}
|
||||
}
|
||||
}
|
||||
if (confirmExpiringPin) {
|
||||
ConcordExpiringPinDialog(
|
||||
onConfirm = {
|
||||
confirmExpiringPin = false
|
||||
accountViewModel.toggleConcordPin(note)
|
||||
onDismiss()
|
||||
},
|
||||
onDismiss = { confirmExpiringPin = false },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+9
-6
@@ -88,7 +88,6 @@ import com.vitorpamplona.amethyst.commons.ui.insets.imePaddingSafe
|
||||
import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav
|
||||
import com.vitorpamplona.amethyst.commons.ui.note.creators.userSuggestions.ShowUserSuggestionList
|
||||
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.feed.types.concordTimerText
|
||||
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.publicChannels.concord.ConcordPinDuties
|
||||
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.publicChannels.concord.ConcordPinnedButton
|
||||
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.publicChannels.concord.ConcordPinnedMessagesSheet
|
||||
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.publicChannels.concord.datasource.ConcordChannelSubscription
|
||||
@@ -186,10 +185,9 @@ fun ConcordChannelScreen(
|
||||
newMessageModel.init(accountViewModel)
|
||||
newMessageModel.load(communityId, channelId)
|
||||
|
||||
// CORD-04 §7 Pins: the header's entry point, the sheet it opens, the jump it requests, and the
|
||||
// delayed duty writes (deletion omission / Edit refresh) a PIN_MESSAGES holder owes.
|
||||
// CORD-04 §7 Pins: the header's entry point, the sheet it opens and the jump it requests. The
|
||||
// delayed duty writes a PIN_MESSAGES holder owes run from the account (scheduleConcordPinDuties).
|
||||
val pins by rememberConcordChannelPins(communityId, channelId, accountViewModel)
|
||||
ConcordPinDuties(communityId, channelId, pins, accountViewModel)
|
||||
var showPins by remember { mutableStateOf(false) }
|
||||
val jumpToNoteId = remember { mutableStateOf<String?>(null) }
|
||||
pins?.let { current ->
|
||||
@@ -199,6 +197,7 @@ fun ConcordChannelScreen(
|
||||
channelId = channelId,
|
||||
pins = current,
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav,
|
||||
onJumpToMessage = { jumpToNoteId.value = it },
|
||||
onDismiss = { showPins = false },
|
||||
)
|
||||
@@ -208,7 +207,7 @@ fun ConcordChannelScreen(
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
actions = { ConcordPinnedButton(pins) { showPins = true } },
|
||||
actions = { ConcordPinnedButton(communityId, pins, accountViewModel) { showPins = true } },
|
||||
title = {
|
||||
Column {
|
||||
Text(channel.toBestDisplayName(), maxLines = 1)
|
||||
@@ -332,7 +331,11 @@ private fun ConcordTimerIndicator(
|
||||
communityId: String,
|
||||
accountViewModel: AccountViewModel,
|
||||
) {
|
||||
val session = remember(communityId) { accountViewModel.account.concordSessions.sessionFor(communityId) } ?: return
|
||||
// Re-resolved on every session-set change: the session may not exist yet at first composition, and
|
||||
// a Refounding replaces it (a captured one would keep reading the dead epoch's fold).
|
||||
val sessions = accountViewModel.account.concordSessions
|
||||
val revision by sessions.revision.collectAsStateWithLifecycle()
|
||||
val session = remember(communityId, revision) { sessions.sessionFor(communityId) } ?: return
|
||||
val state by session.state.collectAsStateWithLifecycle()
|
||||
val secs = state?.metadata?.messageExpirationSecs() ?: return
|
||||
Text(
|
||||
|
||||
+10
-5
@@ -677,6 +677,10 @@ also carried on-relay as the encrypted, fragmented kind:33302 Community List
|
||||
| `amy concord list` | List joined Concord communities. |
|
||||
| `amy concord import` | Fetch + decrypt this account's Community List — the kind:33302 fragments plus the retired kind:13302 (carries heldRoots, CORD-06). |
|
||||
| `amy concord channels COMMUNITY` | List a community's channels; `readable` is false for a private channel whose key this account does not hold (CORD-03 §1). |
|
||||
| `amy concord channel create COMMUNITY NAME [--private [--role NAME]]` | Create a channel (MANAGE_CHANNELS). `--private` gives it its own independent key at channel epoch 0 (stored before anything publishes) plus a bit-less access Role scoped to it (CORD-04 §2, default name = the channel's); nobody holds that Role yet — `concord grant` it to let members read. |
|
||||
| `amy concord channel privatize COMMUNITY CHANNEL [--role NAME]` | Convert a Public channel to Private (CORD-03 §2): a fresh key at the next channel epoch — floored at the highest channel rotation found on the wire, refused (`inconclusive`) past 32 — plus an access Role, then the flag. Protects the future only. |
|
||||
| `amy concord channel publicize COMMUNITY CHANNEL` | Convert a Private channel back to Public (flag only); the held key stays so the private era keeps reading. |
|
||||
| `amy concord channel rekey COMMUNITY CHANNEL` | Rotate a Private channel's key (CORD-06 §1-2) to exactly the members its Roles entitle today plus us: 72-byte scope-bound blobs at the channel-rekey address, `vac` on every chunk. Needs MANAGE_CHANNELS and outranking every cut role holder; the key is reserved in `concord.json` so a re-run re-delivers the same one. |
|
||||
| `amy concord send COMMUNITY CHANNEL TEXT` | Post a message (CHANNEL = `general`\|name\|id). A private channel posts on its own key's plane; without a held key it fails with `no_channel_key` instead of falling back to the community-wide plane. |
|
||||
| `amy concord read COMMUNITY CHANNEL [--limit N] [--epoch N] [--root HEX]` | Read a channel's messages (default 50); `--epoch`/`--root` read a prior epoch's plane (public channels; a private channel reads its held key's plane). Banned members' messages are left out and counted in `hidden_banned`. |
|
||||
| `amy concord invite COMMUNITY [--base URL]` | Mint + publish a shareable invite link (at most 3 bootstrap relays ride in the fragment, CORD-05 §3; the bundle names this account as creator), then publish this account's Invite Registry (`vsk 8`, CORD-05 §5) listing its live link signers — expired links pruned. Output adds `registry_published`, `public` and `live_invite_links`. |
|
||||
@@ -686,15 +690,16 @@ also carried on-relay as the encrypted, fragmented kind:33302 Community List
|
||||
| `amy concord decline WRAP-ID` | Discard a Direct Invite; its wrap id is remembered in `concord-invites.json` so it never resurfaces. |
|
||||
| `amy concord revoke COMMUNITY TOKEN\|URL` | Retire a link you minted: publishes a `vsk=9` tombstone at its coordinate, records it in your Invite List, then republishes your Invite Registry without it. When it was the community's last live link the output carries `privatized: true` / `refound_required: true`: the community is Private now, and `concord refound COMMUNITY --privatize` rotates its keys (CORD-05 §2). |
|
||||
| `amy concord join URL` | Redeem an invite link, save the community, and publish a Guestbook Join echoing the link's attribution (CORD-05 §1/§6). |
|
||||
| `amy concord rekey [COMMUNITY]` | Follow a Refounding we were re-keyed for. Honors only a BAN-holding rotator whose `vac` cites a Grant our fold has synced (the owner cites none); racing rotations converge on the lowest root (CORD-06 §3). |
|
||||
| `amy concord rekey [COMMUNITY]` | Follow a Refounding we were re-keyed for. Honors only a BAN-holding rotator whose `vac` cites a Grant our fold has synced (the owner cites none); racing rotations converge on the lowest root (CORD-06 §3). Then follows every held private channel's own rotations (`channel_rekeys`): a complete, honored rotation off the key we hold is adopted; one from a rotator who outranks us that leaves us out drops the key and records the cut, so no older key comes back. |
|
||||
| `amy concord recover [COMMUNITY] [--rejoin]` | Report whether a Refounding left us behind (our joined-through link resolves to a higher epoch). A bundle never moves the base on its own (CORD-06 §2); `--rejoin` explicitly re-accepts the link. Ban-gated, fails closed. |
|
||||
| `amy concord refound COMMUNITY --remove U[,U…]` / `--privatize` | CORD-06 Refounding. Aborts unless the whole Control Plane folds; publishes the rekey chunks first (each confirmed), the compacted plane after; reserves its keys so a re-run resumes with the same root; refused for a dissolved community. `--privatize` removes nobody: it converts a Public community to Private (owed once its last live invite link is revoked). |
|
||||
| `amy concord refound COMMUNITY --remove U[,U…]` / `--privatize` | CORD-06 Refounding. Aborts unless the whole Control Plane folds; publishes the rekey chunks first (each confirmed), the compacted plane after, then rotates every held private channel to its entitled kept set, sealed under the prior root (`channels_rotated`); reserves its keys so a re-run resumes with the same root; refused for a dissolved community. `--privatize` removes nobody: it converts a Public community to Private (owed once its last live invite link is revoked). |
|
||||
| `amy concord roles COMMUNITY` | List live roles + the current banlist (CORD-04), plus the community's mode from the folded Invite Registries (CORD-05 §5): `public` (true while any live invite link exists), `live_invite_links`, and `invite_registries` (links per creator). |
|
||||
| `amy concord role COMMUNITY NAME POSITION PERM…` | Define a role (perms by name, e.g. `BAN KICK`; also `MANAGE_ROLES`, `MANAGE_CHANNELS`, `MANAGE_METADATA`, `MANAGE_MESSAGES`, `CREATE_INVITE`, `VIEW_AUDIT_LOG`, `MENTION_EVERYONE`, `PIN_MESSAGES`). |
|
||||
| `amy concord grant COMMUNITY USER ROLE-ID` | Grant a role to a member. |
|
||||
| `amy concord grant COMMUNITY USER ROLE-ID` | Grant a role to a member. Private-channel keys follow the Grant (CORD-03/06): every channel it opens to a member is vended to them by Direct Invite carrying only those channels (`channel_keys_vended`); every channel it closes is rotated (`channels_rotated`, or `channels_not_rotated` with the reason). Only keys this account holds can move (`channels_not_held`). |
|
||||
| `amy concord ban COMMUNITY USER` / `unban COMMUNITY USER` | Ban / unban a member. A ban reports `public` and `refound_required`: a Public ban is the Banlist alone, while a ban from a Private community owes a Refounding (`concord refound COMMUNITY --remove USER`, CORD-06 §3). |
|
||||
| `amy concord pins COMMUNITY CHANNEL` | The channel's Pin List (CORD-04 §7), every entry verified from its proof bundle; entries the author deleted are listed under `deleted`, `edited`/`stale_edit` flag revisions, and `sealed_unavailable` means the list is sealed under a key this account never held (unreadable, not empty). |
|
||||
| `amy concord pin COMMUNITY CHANNEL RUMOR_ID` / `unpin COMMUNITY CHANNEL RUMOR_ID` | Pin / unpin a message (PIN_MESSAGES or owner, plus the control write key). Pinning reopens the message's wrap to prove it with its original seal; a private channel's list is sealed under its current key. Refused (`list_unavailable`, `too_many_pins`, `too_large`, …) rather than published when the list is unreadable or a cap would break. |
|
||||
| `amy concord kick COMMUNITY USER` | Cooperative Kick (CORD-04 §6): strips the member's roles first (when you may — MANAGE_ROLES + outrank), then publishes the Guestbook KICK (kind 3309) citing your Grant. Needs KICK and a strict outrank. Reports `roles_stripped`; changes no key — the member can rejoin, and a compliant client leaves on its own. |
|
||||
| `amy concord pins COMMUNITY CHANNEL` | The channel's Pin List (CORD-04 §7), every entry verified from its proof bundle; entries the author deleted are listed under `deleted`, `edited`/`stale_edit` flag revisions, and `sealed_unavailable` means the list is sealed under a key this account never held (unreadable, not empty). Expired (CORD-08) pinned messages are hidden like deleted ones. |
|
||||
| `amy concord pin COMMUNITY CHANNEL RUMOR_ID [--force]` / `unpin COMMUNITY CHANNEL RUMOR_ID` | Pin / unpin a message (PIN_MESSAGES or owner, plus the control write key). A disappearing message (one carrying a CORD-08 `expiration`) is refused with `expiring_message` unless `--force`: the pin would keep its words past the timer. Pinning reopens the message's wrap to prove it with its original seal; a private channel's list is sealed under its current key. Refused (`list_unavailable`, `too_many_pins`, `too_large`, …) rather than published when the list is unreadable or a cap would break. |
|
||||
| `amy concord dissolve COMMUNITY --yes` | Owner only, irreversible: publish the `eid`-bound dissolution tombstone that seals the community read-only (CORD-02 §9). |
|
||||
| `amy concord timer COMMUNITY [off\|SECONDS\|1d\|1w\|30d\|90d\|1y]` | CORD-08 disappearing messages. No value: print the folded timer (`0` = off). With one: publish the metadata edition (MANAGE_METADATA) and a kind-1740 notice into every channel whose key we hold. While a timer is set, `send` signs a NIP-40 `expiration` into the rumor and repeats it on the wrap; `read` drops expired messages. |
|
||||
|
||||
|
||||
@@ -44,6 +44,7 @@ import com.vitorpamplona.quartz.concord.cord02Community.ConcordDissolution
|
||||
import com.vitorpamplona.quartz.concord.cord02Community.ConcordListFragmentSet
|
||||
import com.vitorpamplona.quartz.concord.cord02Community.HeldRoot
|
||||
import com.vitorpamplona.quartz.concord.cord02Community.PrivateChannelKey
|
||||
import com.vitorpamplona.quartz.concord.cord03Channels.ConcordChannelKeyring
|
||||
import com.vitorpamplona.quartz.concord.cord04Roles.AuthorityResolver
|
||||
import com.vitorpamplona.quartz.concord.cord04Roles.ConcordLimits
|
||||
import com.vitorpamplona.quartz.concord.cord04Roles.ControlEdition
|
||||
@@ -53,6 +54,7 @@ import com.vitorpamplona.quartz.concord.cord05Invites.ConcordInviteListDocument
|
||||
import com.vitorpamplona.quartz.concord.cord05Invites.ConcordInviteListEntry
|
||||
import com.vitorpamplona.quartz.concord.cord05Invites.ConcordInviteListEvent
|
||||
import com.vitorpamplona.quartz.concord.cord05Invites.ConcordInviteListTombstone
|
||||
import com.vitorpamplona.quartz.concord.cord05Invites.ConcordInviteVend
|
||||
import com.vitorpamplona.quartz.concord.cord05Invites.InviteBundleStatus
|
||||
import com.vitorpamplona.quartz.concord.cord06Rekey.ReceivedRefounding
|
||||
import com.vitorpamplona.quartz.concord.crypto.ControlPlaneKeys
|
||||
@@ -79,6 +81,15 @@ object ConcordCommands {
|
||||
| concord import fetch + decrypt this account's kind:33302
|
||||
| community list (carries heldRoots, CORD-06)
|
||||
| concord channels COMMUNITY list a community's channels
|
||||
| concord channel create COMMUNITY NAME create a channel (MANAGE_CHANNELS); --private
|
||||
| [--private [--role NAME]] gives it its own key at channel epoch 0 plus a
|
||||
| bit-less access Role (default: the channel name);
|
||||
| grant that Role to let members read it
|
||||
| concord channel privatize COMMUNITY CHANNEL convert a Public channel to Private: a fresh key
|
||||
| [--role NAME] at the next channel epoch + an access Role
|
||||
| concord channel publicize COMMUNITY CHANNEL convert a Private channel back to Public (flag only)
|
||||
| concord channel rekey COMMUNITY CHANNEL rotate a Private channel's key to exactly the
|
||||
| members its Roles entitle today (CORD-06)
|
||||
| concord send COMMUNITY CHANNEL TEXT post a message (CHANNEL = general|name|id)
|
||||
| concord read COMMUNITY CHANNEL [--limit N] read a channel's messages (default 50);
|
||||
| [--epoch N] [--root HEX] --epoch/--root read a prior epoch's plane
|
||||
@@ -96,7 +107,9 @@ object ConcordCommands {
|
||||
| it in your invite list so it stays retired
|
||||
| concord join URL redeem an invite link and save the community
|
||||
| concord rekey [COMMUNITY] follow a Refounding we were re-keyed for:
|
||||
| open our blob and adopt the new epoch
|
||||
| open our blob and adopt the new epoch; then
|
||||
| follow each held private channel's rotations
|
||||
| (adopt the new key, or drop it when cut)
|
||||
| concord recover [COMMUNITY] [--rejoin] re-resolve the joined-through invite link and
|
||||
| report whether a Refounding left us behind;
|
||||
| --rejoin re-accepts that link (a bundle never
|
||||
@@ -106,8 +119,12 @@ object ConcordCommands {
|
||||
| and public: true/false + live invite links
|
||||
| from the folded registries (CORD-05 §5)
|
||||
| concord role COMMUNITY NAME POSITION PERM… define a role (perms by name, e.g. BAN KICK)
|
||||
| concord grant COMMUNITY USER ROLE-ID grant a role to a member
|
||||
| concord grant COMMUNITY USER ROLE-ID grant a role to a member; the private channels it
|
||||
| opens are vended by Direct Invite, the ones it
|
||||
| closes are rotated (CORD-03/06)
|
||||
| concord ban COMMUNITY USER ban a member
|
||||
| concord kick COMMUNITY USER cooperative kick (CORD-04 §6): strip the member's
|
||||
| roles, then the Guestbook KICK; re-joinable
|
||||
| concord pins COMMUNITY CHANNEL the channel's verified Pin List (CORD-04 §7)
|
||||
| concord pin COMMUNITY CHANNEL RUMOR_ID pin a message (PIN_MESSAGES); proves it with
|
||||
| its original seal, capped at 25 / 32 KiB
|
||||
@@ -133,7 +150,7 @@ object ConcordCommands {
|
||||
route(
|
||||
"concord",
|
||||
tail,
|
||||
"concord <create|list|import|channels|send|read|invite|invites|accept|decline|revoke|join|recover|rekey|roles|role|grant|ban|unban|pins|pin|unpin|refound|dissolve|timer>",
|
||||
"concord <create|list|import|channels|channel|send|read|invite|invites|accept|decline|revoke|join|recover|rekey|roles|role|grant|ban|kick|unban|pins|pin|unpin|refound|dissolve|timer>",
|
||||
help = USAGE,
|
||||
routes =
|
||||
mapOf(
|
||||
@@ -141,6 +158,7 @@ object ConcordCommands {
|
||||
"list" to { rest -> list(dataDir, rest) },
|
||||
"import" to { rest -> import(dataDir, rest) },
|
||||
"channels" to { rest -> ConcordChannelCommands.channels(dataDir, rest) },
|
||||
"channel" to { rest -> ConcordPrivateChannelCommands.channel(dataDir, rest) },
|
||||
"send" to { rest -> ConcordChannelCommands.send(dataDir, rest) },
|
||||
"read" to { rest -> ConcordChannelCommands.read(dataDir, rest) },
|
||||
"invite" to { rest -> invite(dataDir, rest) },
|
||||
@@ -155,6 +173,7 @@ object ConcordCommands {
|
||||
"role" to { rest -> ConcordModCommands.defineRole(dataDir, rest) },
|
||||
"grant" to { rest -> ConcordModCommands.grant(dataDir, rest) },
|
||||
"ban" to { rest -> ConcordModCommands.ban(dataDir, rest) },
|
||||
"kick" to { rest -> ConcordModCommands.kick(dataDir, rest) },
|
||||
"unban" to { rest -> ConcordModCommands.unban(dataDir, rest) },
|
||||
"pins" to { rest -> ConcordPinCommands.pins(dataDir, rest) },
|
||||
"pin" to { rest -> ConcordPinCommands.pin(dataDir, rest) },
|
||||
@@ -729,9 +748,12 @@ object ConcordCommands {
|
||||
0
|
||||
}
|
||||
is DirectInviteAcceptPlan.CatchUp -> {
|
||||
val held = heldSc!!
|
||||
store.upsert(storedFrom(held, plan.entry))
|
||||
val added = plan.entry.privateChannels.filter { pc -> held.privateChannels.none { it.channelId.equals(pc.channelId, ignoreCase = true) && it.epoch == pc.epoch } }
|
||||
// Re-applied to the record as stored NOW (the fold above took a while), never the
|
||||
// snapshot the plan was computed from.
|
||||
val held = store.load().firstOrNull { it.communityId == heldSc!!.communityId } ?: heldSc!!
|
||||
val adopted = ConcordInviteVend.adoptCatchUp(entryFor(held), opened.invite, plan.channelIds) ?: plan.entry
|
||||
store.upsert(storedFrom(held, adopted))
|
||||
val added = adopted.privateChannels.filter { pc -> held.privateChannels.none { it.channelId.equals(pc.channelId, ignoreCase = true) && it.epoch == pc.epoch } }
|
||||
Output.emit(done(mapOf("joined" to true, "catch_up" to true, "channels" to added.map { mapOf("id" to it.channelId, "name" to it.name, "epoch" to it.epoch) })))
|
||||
0
|
||||
}
|
||||
@@ -840,7 +862,9 @@ object ConcordCommands {
|
||||
}
|
||||
|
||||
/** The quartz list entry a [StoredCommunity] describes — the shape every commons helper takes. */
|
||||
fun entryFor(sc: StoredCommunity) =
|
||||
fun entryFor(sc: StoredCommunity) = sc.channelCuts.entries.fold(entryShape(sc)) { entry, (id, epoch) -> ConcordChannelKeyring.withCut(entry, id, epoch) }
|
||||
|
||||
private fun entryShape(sc: StoredCommunity) =
|
||||
ConcordCommunityListEntry(
|
||||
id = sc.communityId,
|
||||
owner = sc.owner,
|
||||
@@ -870,6 +894,7 @@ object ConcordCommands {
|
||||
name = entry.name.ifBlank { sc.name },
|
||||
inviteRef = entry.inviteRef ?: sc.inviteRef,
|
||||
privateChannels = entry.privateChannels.filter { it.key.isNotBlank() }.map { StoredPrivateChannel(it.channelId, it.key, it.epoch, it.name) },
|
||||
channelCuts = ConcordChannelKeyring.cutsOf(entry),
|
||||
)
|
||||
|
||||
/**
|
||||
@@ -1048,7 +1073,15 @@ object ConcordCommands {
|
||||
store.upsert(storedFrom(sc, adopted).copy(pendingRefounding = null))
|
||||
results += mapOf("community_id" to sc.communityId, "name" to sc.name, "rekeyed" to true, "from_epoch" to sc.rootEpoch, "root_epoch" to received.newEpoch, "rotator" to received.rotator)
|
||||
}
|
||||
Output.emit(mapOf("communities" to results))
|
||||
// Then every held Private Channel's own rotations (CORD-06 §2), off the records as stored
|
||||
// now — a base adoption above may have moved the root they are sealed under.
|
||||
val channelResults = mutableListOf<Map<String, Any?>>()
|
||||
for (id in targets.map { it.communityId }) {
|
||||
val fresh = store.load().firstOrNull { it.communityId == id } ?: continue
|
||||
if (fresh.privateChannels.isEmpty() || isDissolved(ctx, fresh)) continue
|
||||
channelResults += ConcordPrivateChannelCommands.drainChannelRekeys(ctx, store, fresh)
|
||||
}
|
||||
Output.emit(mapOf("communities" to results, "channel_rekeys" to channelResults))
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,10 +29,13 @@ import com.vitorpamplona.amethyst.cli.stores.StoredCommunity
|
||||
import com.vitorpamplona.amethyst.cli.stores.StoredPendingRefounding
|
||||
import com.vitorpamplona.amethyst.commons.actions.ConcordActions
|
||||
import com.vitorpamplona.amethyst.commons.actions.ConcordModeration
|
||||
import com.vitorpamplona.amethyst.commons.actions.ConcordPrivateChannels
|
||||
import com.vitorpamplona.amethyst.commons.actions.ConcordReceive
|
||||
import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityState
|
||||
import com.vitorpamplona.quartz.concord.cord02Community.ConcordDissolution
|
||||
import com.vitorpamplona.quartz.concord.cord03Channels.ConcordChannelKeyring
|
||||
import com.vitorpamplona.quartz.concord.cord03Channels.ConcordDisappearing
|
||||
import com.vitorpamplona.quartz.concord.cord04Roles.AuthorityResolver
|
||||
import com.vitorpamplona.quartz.concord.cord04Roles.ConcordLimits
|
||||
import com.vitorpamplona.quartz.concord.cord04Roles.ConcordPermissions
|
||||
import com.vitorpamplona.quartz.concord.cord04Roles.ControlEdition
|
||||
@@ -41,6 +44,7 @@ import com.vitorpamplona.quartz.concord.cord04Roles.RoleEntity
|
||||
import com.vitorpamplona.quartz.concord.cord05Invites.ConcordInviteListDocument
|
||||
import com.vitorpamplona.quartz.concord.cord05Invites.ConcordInviteRegistry
|
||||
import com.vitorpamplona.quartz.concord.cord05Invites.InviteBundleStatus
|
||||
import com.vitorpamplona.quartz.concord.cord06Rekey.ConcordChannelRekey
|
||||
import com.vitorpamplona.quartz.concord.cord06Rekey.ConcordRefounding
|
||||
import com.vitorpamplona.quartz.concord.cord06Rekey.IncompleteControlPlaneException
|
||||
import com.vitorpamplona.quartz.concord.cord06Rekey.PendingRefounding
|
||||
@@ -204,7 +208,11 @@ object ConcordModCommands {
|
||||
)
|
||||
val ack = ctx.publish(wrap, ConcordCommands.relaysFor(ctx, sc))
|
||||
RawEventSupport.publishGuard(ack, wrap.id)?.let { return it }
|
||||
Output.emit(mapOf("member" to member, "roles" to listOf(roleId)) + RawEventSupport.ackFields(ack))
|
||||
// Role-gated channel keys follow the Grant (CORD-03/06): vend what it opened, rotate what it closed.
|
||||
val before = AuthorityResolver.resolve(editions, sc.communityId.hexToByteArray(), sc.owner)
|
||||
val after = editions + ConcordActions.controlEditions(listOf(wrap), cp)
|
||||
val access = ConcordPrivateChannelCommands.reconcileAccess(ctx, ConcordStore(dataDir.concordFile), loaded.community, before, after)
|
||||
Output.emit(mapOf("member" to member, "roles" to listOf(roleId)) + access + RawEventSupport.ackFields(ack))
|
||||
return 0
|
||||
}
|
||||
}
|
||||
@@ -318,6 +326,61 @@ object ConcordModCommands {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Kicks a member: `kick <community> <user>` (CORD-04 §6, the Cooperative Kick). Role Removal
|
||||
* first — an empty Grant when the target holds roles and we may strip them (MANAGE_ROLES +
|
||||
* outrank, and the control_root to publish it; best-effort) — then the Guestbook directive
|
||||
* (kind 3309) citing our Grant. Needs KICK and a strict outrank of the target.
|
||||
*/
|
||||
suspend fun kick(
|
||||
dataDir: DataDir,
|
||||
rest: Array<String>,
|
||||
): Int {
|
||||
val args = Args(rest)
|
||||
val handle = args.positional(0, "community")
|
||||
val userRef = args.positional(1, "user")
|
||||
args.rejectUnknown()
|
||||
val store = ConcordStore(dataDir.concordFile)
|
||||
val sc = store.find(handle) ?: return ConcordCommands.notFound(handle)
|
||||
|
||||
Context.open(dataDir).use { ctx ->
|
||||
ctx.prepare()
|
||||
val member = ctx.requireUserHex(userRef)
|
||||
val loaded = load(ctx, sc, dataDir)
|
||||
val (cp, editions) = loaded
|
||||
val cid = sc.communityId.hexToByteArray()
|
||||
val me = ctx.signer.pubKey
|
||||
val authority = AuthorityResolver.resolve(editions, cid, sc.owner)
|
||||
if (!authority.canActOn(me, member, ConcordPermissions.KICK)) {
|
||||
return Output.error("forbidden", "kicking $member takes KICK and a strict outrank in '$handle' (CORD-04 §6)")
|
||||
}
|
||||
val relays = ConcordCommands.relaysFor(ctx, sc)
|
||||
|
||||
// Strip first, so the target's rank is gone before the departure lands.
|
||||
var chain = editions
|
||||
var access: Map<String, Any?> = emptyMap()
|
||||
val strip = authority.rolesOf(member).isNotEmpty() && cp.canWrite && authority.canActOn(me, member, ConcordPermissions.MANAGE_ROLES)
|
||||
if (strip) {
|
||||
val stripWrap = ConcordModeration.grant(ctx.signer, cp, cid, member, emptyList(), editions, TimeUtils.now(), owner = sc.owner)
|
||||
if (ctx.publish(stripWrap, relays).values.any { it.accepted }) {
|
||||
chain = editions + ConcordActions.controlEditions(listOf(stripWrap), cp)
|
||||
access = ConcordPrivateChannelCommands.reconcileAccess(ctx, store, loaded.community, authority, chain)
|
||||
} else {
|
||||
System.err.println("[concord] the role strip for $member was not accepted by any relay; kicking anyway")
|
||||
}
|
||||
}
|
||||
|
||||
val gb = ConcordActions.guestbookPlane(sc.root.hexToByteArray(), cid, sc.rootEpoch)
|
||||
ctx.registerConcordStreamKeys(relays, listOf(gb.secretKey))
|
||||
val citation = AuthorityResolver.resolve(chain, cid, sc.owner).citationFor(me)
|
||||
val wrap = ConcordActions.buildGuestbookKick(ctx.signer, gb, member, citation, TimeUtils.now())
|
||||
val ack = ctx.publish(wrap, relays)
|
||||
RawEventSupport.publishGuard(ack, wrap.id)?.let { return it }
|
||||
Output.emit(mapOf("member" to member, "kicked" to true, "roles_stripped" to (chain !== editions)) + access + RawEventSupport.ackFields(ack))
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
/** Unbans a member: `unban <community> <user>`. */
|
||||
suspend fun unban(
|
||||
dataDir: DataDir,
|
||||
@@ -544,11 +607,33 @@ object ConcordModCommands {
|
||||
}
|
||||
val compactionFailures = build.controlWraps.count { wrap -> ctx.publish(wrap, relays).values.none { it.accepted } }
|
||||
|
||||
// 4b. Rotate every held Private Channel (CORD-06 §3), each to its OWN entitled set among
|
||||
// the kept members, sealed under the PRIOR root so a base-fork loser can still open
|
||||
// it. One that no relay takes keeps its key and is reported: resumable, not atomic.
|
||||
val afterBans = ConcordCommunityState.fold(chain, sc.communityId.hexToByteArray(), sc.owner)
|
||||
val kept = recipients.mapTo(HashSet()) { it.lowercase() }
|
||||
var withChannels = ConcordCommands.entryFor(loaded.community)
|
||||
val channelsRotated = mutableListOf<String>()
|
||||
val channelsNotRotated = mutableListOf<String>()
|
||||
for (held in withChannels.privateChannels) {
|
||||
val id = held.channelId.lowercase()
|
||||
if (id !in afterBans.privateChannelIds || ConcordChannelKeyring.heldKey(withChannels, id) == null) continue
|
||||
val keep = ConcordPrivateChannels.keepSet(afterBans.authority, id, me).filterTo(HashSet()) { it in kept || it == me.lowercase() }
|
||||
val newKey = ConcordChannelRekey.mintKey()
|
||||
val wraps = ConcordPrivateChannels.buildRotation(ctx.signer, priorRoot, held, newKey, keep, TimeUtils.now(), citation)
|
||||
if (wraps.all { wrap -> ctx.publish(wrap, relays).values.any { it.accepted } }) {
|
||||
withChannels = ConcordChannelKeyring.withRotatedKey(withChannels, id, newKey.toHexKey(), held.epoch + 1) ?: withChannels
|
||||
channelsRotated += id
|
||||
} else {
|
||||
channelsNotRotated += id
|
||||
}
|
||||
}
|
||||
|
||||
// 5. Adopt the new epoch ourselves — the same pure rewrite Amethyst uses, banking the
|
||||
// epoch we are leaving for the anti-rollback floor — and drop the reservation.
|
||||
val adopted =
|
||||
ConcordReceive.withAdoptedRoot(
|
||||
ConcordCommands.entryFor(loaded.community),
|
||||
withChannels,
|
||||
keys.newRoot,
|
||||
build.newEpoch,
|
||||
build.newControlKeys.address.hexToByteArray(),
|
||||
@@ -606,6 +691,8 @@ object ConcordModCommands {
|
||||
"rekey_wraps" to build.rekeyWraps.size,
|
||||
"compaction_failures" to compactionFailures,
|
||||
"invites_refreshed" to refreshed,
|
||||
"channels_rotated" to channelsRotated,
|
||||
"channels_not_rotated" to channelsNotRotated,
|
||||
),
|
||||
)
|
||||
return 0
|
||||
|
||||
@@ -35,10 +35,12 @@ import com.vitorpamplona.amethyst.commons.actions.ConcordPinOutcome
|
||||
import com.vitorpamplona.amethyst.commons.actions.ConcordPinWrite
|
||||
import com.vitorpamplona.amethyst.commons.actions.ConcordPinning
|
||||
import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityState
|
||||
import com.vitorpamplona.quartz.concord.cord03Channels.ConcordDisappearing
|
||||
import com.vitorpamplona.quartz.concord.cord04Roles.ConcordPermissions
|
||||
import com.vitorpamplona.quartz.concord.cord04Roles.ControlEdition
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray
|
||||
import com.vitorpamplona.quartz.nip40Expiration.isExpirationBefore
|
||||
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||
|
||||
/**
|
||||
@@ -91,7 +93,8 @@ object ConcordPinCommands {
|
||||
?.key
|
||||
?.conversationKey
|
||||
},
|
||||
isKilled = view.evidence::isKilled,
|
||||
// CORD-08 §3: an expired message never shows, pinned or not (the proof stays valid, the rumor says it is gone).
|
||||
isKilled = { view.evidence.isKilled(it) || it.tags.isExpirationBefore(TimeUtils.now()) },
|
||||
newestEdit = view.evidence::newestEdit,
|
||||
)
|
||||
}
|
||||
@@ -168,6 +171,7 @@ object ConcordPinCommands {
|
||||
val handle = args.positional(0, "community")
|
||||
val channelRef = args.positional(1, "channel")
|
||||
val rumorId = args.positional(2, "rumor_id").lowercase()
|
||||
val force = pin && args.bool("force")
|
||||
args.rejectUnknown()
|
||||
if (!HEX64.matches(rumorId)) return Output.error("bad_args", "RUMOR_ID must be a 64-char hex rumor id")
|
||||
val stored = ConcordStore(dataDir.concordFile).find(handle) ?: return ConcordCommands.notFound(handle)
|
||||
@@ -202,9 +206,14 @@ object ConcordPinCommands {
|
||||
if (pin) {
|
||||
val refused = ConcordPinning.refusal(pinCtx)
|
||||
val source = if (refused == null) ConcordPinning.sourceFrom(view.wraps, view.planes, rumorId) else null
|
||||
val expiresAt = source?.let { ConcordDisappearing.expirationOf(it.opened.rumor) }
|
||||
when {
|
||||
refused != null -> ConcordPinWrite(refused)
|
||||
source == null -> ConcordPinWrite(ConcordPinOutcome.MESSAGE_UNAVAILABLE)
|
||||
// A pin carries the message's words in its proof: pinning a disappearing message
|
||||
// makes it outlive its timer (CORD-08), so that takes an explicit --force.
|
||||
expiresAt != null && !force ->
|
||||
return Output.error("expiring_message", "that message disappears at $expiresAt (CORD-08) and a pin would keep its words past the timer; pass --force to pin it anyway")
|
||||
else -> ConcordPinning.pin(pinCtx, source, TimeUtils.now())
|
||||
}
|
||||
} else {
|
||||
|
||||
+409
@@ -0,0 +1,409 @@
|
||||
/*
|
||||
* 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.cli.commands
|
||||
|
||||
import com.vitorpamplona.amethyst.cli.Args
|
||||
import com.vitorpamplona.amethyst.cli.Context
|
||||
import com.vitorpamplona.amethyst.cli.DataDir
|
||||
import com.vitorpamplona.amethyst.cli.Output
|
||||
import com.vitorpamplona.amethyst.cli.stores.ConcordStore
|
||||
import com.vitorpamplona.amethyst.cli.stores.StoredCommunity
|
||||
import com.vitorpamplona.amethyst.commons.actions.ConcordActions
|
||||
import com.vitorpamplona.amethyst.commons.actions.ConcordModeration
|
||||
import com.vitorpamplona.amethyst.commons.actions.ConcordPrivateChannels
|
||||
import com.vitorpamplona.amethyst.commons.actions.ConcordReceive
|
||||
import com.vitorpamplona.amethyst.commons.model.ConcordDirectInviteDraft
|
||||
import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityState
|
||||
import com.vitorpamplona.quartz.concord.cord02Community.PrivateChannelKey
|
||||
import com.vitorpamplona.quartz.concord.cord03Channels.ConcordChannelKeyring
|
||||
import com.vitorpamplona.quartz.concord.cord04Roles.AuthorityResolver
|
||||
import com.vitorpamplona.quartz.concord.cord04Roles.ChannelEntity
|
||||
import com.vitorpamplona.quartz.concord.cord04Roles.ConcordPermissions
|
||||
import com.vitorpamplona.quartz.concord.cord04Roles.ControlEdition
|
||||
import com.vitorpamplona.quartz.concord.cord05Invites.ConcordInviteVend
|
||||
import com.vitorpamplona.quartz.concord.cord06Rekey.ChannelRekeyOutcome
|
||||
import com.vitorpamplona.quartz.concord.cord06Rekey.ConcordChannelRekey
|
||||
import com.vitorpamplona.quartz.marmot.RecipientRelayFetcher
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray
|
||||
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
|
||||
import com.vitorpamplona.quartz.utils.RandomInstance
|
||||
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||
|
||||
/**
|
||||
* `amy concord channel create|privatize|publicize|rekey` — Private Channels (CORD-03 §1-2,
|
||||
* CORD-06 §1-2). Thin assembly over [ConcordPrivateChannels] (commons); the decisions — the key
|
||||
* epoch, the access Role, who a rotation keeps, whether a received rotation is honored — are all
|
||||
* shared with Amethyst. Like every amy verb that holds secrets, keys live in the local store only
|
||||
* (amy does not republish the Community List).
|
||||
*/
|
||||
object ConcordPrivateChannelCommands {
|
||||
/** How many channel epochs `privatize` probes for earlier rotations (Armada MAX_PROBED_CHANNEL_EPOCH). */
|
||||
private const val MAX_PROBED_CHANNEL_EPOCH = 32L
|
||||
|
||||
suspend fun channel(
|
||||
dataDir: DataDir,
|
||||
tail: Array<String>,
|
||||
): Int =
|
||||
route(
|
||||
"concord channel",
|
||||
tail,
|
||||
"concord channel <create|privatize|publicize|rekey>",
|
||||
routes =
|
||||
mapOf(
|
||||
"create" to { rest -> create(dataDir, rest) },
|
||||
"privatize" to { rest -> privatize(dataDir, rest) },
|
||||
"publicize" to { rest -> publicize(dataDir, rest) },
|
||||
"rekey" to { rest -> rekey(dataDir, rest) },
|
||||
),
|
||||
)
|
||||
|
||||
/** Publishes [wraps] in order to [sc]'s relays; the first one no relay takes stops the run. */
|
||||
private suspend fun publishAll(
|
||||
ctx: Context,
|
||||
sc: StoredCommunity,
|
||||
wraps: List<Event>,
|
||||
): Int? {
|
||||
val relays = ConcordCommands.relaysFor(ctx, sc)
|
||||
for (wrap in wraps) {
|
||||
val ack = ctx.publish(wrap, relays)
|
||||
RawEventSupport.publishGuard(ack, wrap.id)?.let { return it }
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
private fun canManageChannels(
|
||||
authority: AuthorityResolver,
|
||||
me: HexKey,
|
||||
): Boolean = authority.isOwner(me) || authority.hasPermission(me, ConcordPermissions.MANAGE_CHANNELS)
|
||||
|
||||
private fun forbidden(): Int = Output.error("forbidden", "this needs the Manage-channels permission (CORD-03 §2); readers would drop the edition")
|
||||
|
||||
/** Stores [sc] with the Private Channel [key] (refused when it would not move the channel forward). */
|
||||
private fun storeKey(
|
||||
store: ConcordStore,
|
||||
sc: StoredCommunity,
|
||||
key: PrivateChannelKey,
|
||||
): Boolean {
|
||||
val fresh = store.load().firstOrNull { it.communityId == sc.communityId } ?: sc
|
||||
val next = ConcordChannelKeyring.withChannelKey(ConcordCommands.entryFor(fresh), key) ?: return false
|
||||
store.upsert(ConcordCommands.storedFrom(fresh, next))
|
||||
return true
|
||||
}
|
||||
|
||||
/** `concord channel create COMMUNITY NAME [--private [--role NAME]]`. */
|
||||
private suspend fun create(
|
||||
dataDir: DataDir,
|
||||
rest: Array<String>,
|
||||
): Int {
|
||||
val args = Args(rest)
|
||||
val handle = args.positional(0, "community")
|
||||
val name = args.positional(1, "name")
|
||||
val private = args.bool("private")
|
||||
val roleName = args.flag("role")
|
||||
args.rejectUnknown()
|
||||
if (!ChannelEntity(name = name.trim()).hasValidName()) return Output.error("bad_args", "a channel name must be 1..${ChannelEntity.NAME_MAX_BYTES} UTF-8 bytes").let { 2 }
|
||||
if (roleName != null && !private) return Output.error("bad_args", "--role names a Private channel's access Role; add --private").let { 2 }
|
||||
val store = ConcordStore(dataDir.concordFile)
|
||||
val sc = store.find(handle) ?: return ConcordCommands.notFound(handle)
|
||||
|
||||
Context.open(dataDir).use { ctx ->
|
||||
ctx.prepare()
|
||||
val loaded = ConcordModCommands.load(ctx, sc, dataDir)
|
||||
val (cp, editions) = loaded
|
||||
ConcordModCommands.writeGuard(cp)?.let { return it }
|
||||
val authority = AuthorityResolver.resolve(editions, sc.communityId.hexToByteArray(), sc.owner)
|
||||
if (!canManageChannels(authority, ctx.signer.pubKey)) return forbidden()
|
||||
|
||||
if (!private) {
|
||||
val channelId = RandomInstance.bytes(32)
|
||||
val wrap = ConcordModeration.defineChannel(ctx.signer, cp, sc.communityId.hexToByteArray(), channelId, ChannelEntity(name = name.trim()), editions, TimeUtils.now(), owner = sc.owner)
|
||||
publishAll(ctx, sc, listOf(wrap))?.let { return it }
|
||||
Output.emit(mapOf("channel_id" to channelId.toHexKey(), "name" to name.trim(), "private" to false))
|
||||
return 0
|
||||
}
|
||||
|
||||
val build =
|
||||
ConcordPrivateChannels.create(ctx.signer, cp, sc.communityId.hexToByteArray(), name, roleName, editions, authority, sc.owner, TimeUtils.now())
|
||||
?: return Output.error("forbidden", "no rank to mint this channel's access Role from")
|
||||
// The key goes into the store BEFORE the editions publish: otherwise a crash orphans the only copy.
|
||||
if (!storeKey(store, loaded.community, build.key)) return Output.error("conflict", "could not store the new channel key")
|
||||
publishAll(ctx, sc, build.wraps)?.let { return it }
|
||||
Output.emit(
|
||||
mapOf(
|
||||
"channel_id" to build.channelIdHex,
|
||||
"name" to name.trim(),
|
||||
"private" to true,
|
||||
"channel_epoch" to build.key.epoch,
|
||||
"access_role_id" to build.roleIdHex,
|
||||
),
|
||||
)
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
/** `concord channel privatize COMMUNITY CHANNEL [--role NAME]`. */
|
||||
private suspend fun privatize(
|
||||
dataDir: DataDir,
|
||||
rest: Array<String>,
|
||||
): Int {
|
||||
val args = Args(rest)
|
||||
val handle = args.positional(0, "community")
|
||||
val channelRef = args.positional(1, "channel")
|
||||
val roleName = args.flag("role")
|
||||
args.rejectUnknown()
|
||||
val store = ConcordStore(dataDir.concordFile)
|
||||
val sc = store.find(handle) ?: return ConcordCommands.notFound(handle)
|
||||
|
||||
Context.open(dataDir).use { ctx ->
|
||||
ctx.prepare()
|
||||
val channelId = ConcordChannelCommands.resolve(ctx, sc, channelRef) ?: return Output.error("not_found", "no channel '$channelRef'")
|
||||
val loaded = ConcordModCommands.load(ctx, sc, dataDir)
|
||||
val (cp, editions) = loaded
|
||||
ConcordModCommands.writeGuard(cp)?.let { return it }
|
||||
val state = ConcordCommunityState.fold(editions, sc.communityId.hexToByteArray(), sc.owner)
|
||||
if (!canManageChannels(state.authority, ctx.signer.pubKey)) return forbidden()
|
||||
val standing = state.channels[channelId]?.definition ?: return Output.error("not_found", "channel '$channelRef' is not in the folded Control Plane")
|
||||
if (standing.private) return Output.error("already_private", "channel '$channelRef' is already private")
|
||||
|
||||
// The next channel epoch must climb past every generation ever used — including ones this
|
||||
// account never held — so probe the rekey addresses the roots derive (CORD-03 §2).
|
||||
val entry = ConcordCommands.entryFor(loaded.community)
|
||||
val window = HashMap<HexKey, Long>()
|
||||
for (root in (listOf(entry.root) + entry.heldRoots.map { it.key }).distinct()) {
|
||||
for (epoch in 1L..MAX_PROBED_CHANNEL_EPOCH) window[ConcordChannelRekey.address(root.hexToByteArray(), channelId.hexToByteArray(), epoch).publicKeyHex] = epoch
|
||||
}
|
||||
val relays = ConcordCommands.relaysFor(ctx, sc)
|
||||
val seen = ctx.drain(relays.associateWith { listOf(ConcordActions.planeFilterFor(window.keys.toList())) }).map { it.second }
|
||||
val floor = seen.mapNotNull { window[it.pubKey] }.maxOrNull() ?: 0
|
||||
if (floor >= MAX_PROBED_CHANNEL_EPOCH) return Output.error("inconclusive", "this channel has rotated at least $MAX_PROBED_CHANNEL_EPOCH times; its next epoch can't be established safely")
|
||||
|
||||
val build =
|
||||
ConcordPrivateChannels.privatize(ctx.signer, cp, entry, channelId, standing, roleName, editions, state.authority, TimeUtils.now(), floor)
|
||||
?: return Output.error("forbidden", "no rank to mint this channel's access Role from")
|
||||
if (!storeKey(store, loaded.community, build.key)) return Output.error("conflict", "could not store the new channel key")
|
||||
publishAll(ctx, sc, build.wraps)?.let { return it }
|
||||
Output.emit(mapOf("channel_id" to channelId, "private" to true, "channel_epoch" to build.key.epoch, "access_role_id" to build.roleIdHex))
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
/** `concord channel publicize COMMUNITY CHANNEL`. */
|
||||
private suspend fun publicize(
|
||||
dataDir: DataDir,
|
||||
rest: Array<String>,
|
||||
): Int {
|
||||
val args = Args(rest)
|
||||
val handle = args.positional(0, "community")
|
||||
val channelRef = args.positional(1, "channel")
|
||||
args.rejectUnknown()
|
||||
val sc = ConcordStore(dataDir.concordFile).find(handle) ?: return ConcordCommands.notFound(handle)
|
||||
|
||||
Context.open(dataDir).use { ctx ->
|
||||
ctx.prepare()
|
||||
val channelId = ConcordChannelCommands.resolve(ctx, sc, channelRef) ?: return Output.error("not_found", "no channel '$channelRef'")
|
||||
val (cp, editions) = ConcordModCommands.load(ctx, sc, dataDir)
|
||||
ConcordModCommands.writeGuard(cp)?.let { return it }
|
||||
val state = ConcordCommunityState.fold(editions, sc.communityId.hexToByteArray(), sc.owner)
|
||||
if (!canManageChannels(state.authority, ctx.signer.pubKey)) return forbidden()
|
||||
val standing = state.channels[channelId]?.definition ?: return Output.error("not_found", "channel '$channelRef' is not in the folded Control Plane")
|
||||
val wrap =
|
||||
ConcordPrivateChannels.publicize(ctx.signer, cp, sc.communityId.hexToByteArray(), channelId, standing, editions, sc.owner, TimeUtils.now())
|
||||
?: return Output.error("already_public", "channel '$channelRef' is not private")
|
||||
publishAll(ctx, sc, listOf(wrap))?.let { return it }
|
||||
Output.emit(mapOf("channel_id" to channelId, "private" to false))
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
/** `concord channel rekey COMMUNITY CHANNEL` — rotate to exactly the members entitled today. */
|
||||
private suspend fun rekey(
|
||||
dataDir: DataDir,
|
||||
rest: Array<String>,
|
||||
): Int {
|
||||
val args = Args(rest)
|
||||
val handle = args.positional(0, "community")
|
||||
val channelRef = args.positional(1, "channel")
|
||||
args.rejectUnknown()
|
||||
val store = ConcordStore(dataDir.concordFile)
|
||||
val sc = store.find(handle) ?: return ConcordCommands.notFound(handle)
|
||||
|
||||
Context.open(dataDir).use { ctx ->
|
||||
ctx.prepare()
|
||||
if (ConcordCommands.isDissolved(ctx, sc)) return Output.error("dissolved", "community '$handle' has been dissolved (CORD-02 §9)")
|
||||
val channelId = ConcordChannelCommands.resolve(ctx, sc, channelRef) ?: return Output.error("not_found", "no channel '$channelRef'")
|
||||
val loaded = ConcordModCommands.load(ctx, sc, dataDir)
|
||||
val authority = AuthorityResolver.resolve(loaded.editions, sc.communityId.hexToByteArray(), sc.owner)
|
||||
// The known role holders a rotation to the entitled set leaves out; a roleless member ranks
|
||||
// last, so any MANAGE_CHANNELS holder outranks them and they need no check.
|
||||
val keep = ConcordPrivateChannels.keepSet(authority, channelId, ctx.signer.pubKey)
|
||||
val cut = (authority.roleHolders() + authority.owner()).filterTo(HashSet()) { it !in keep }
|
||||
return rotate(ctx, store, loaded.community, channelId, authority, loaded.editions, cut)
|
||||
}
|
||||
}
|
||||
|
||||
/** A rotation's result: [error] (code to message) when refused or unpublished, else what landed. */
|
||||
internal class Rotation(
|
||||
val error: Pair<String, String>? = null,
|
||||
val newEpoch: Long = 0,
|
||||
val kept: Int = 0,
|
||||
val chunks: Int = 0,
|
||||
)
|
||||
|
||||
/** [rotateSilently] with its outcome emitted as the command's JSON line. */
|
||||
internal suspend fun rotate(
|
||||
ctx: Context,
|
||||
store: ConcordStore,
|
||||
sc: StoredCommunity,
|
||||
channelId: HexKey,
|
||||
authority: AuthorityResolver,
|
||||
editions: List<ControlEdition>,
|
||||
cut: Set<HexKey>,
|
||||
): Int {
|
||||
val r = rotateSilently(ctx, store, sc, channelId, authority, editions, cut)
|
||||
r.error?.let { (code, message) -> return Output.error(code, message) }
|
||||
Output.emit(mapOf("channel_id" to channelId, "rekeyed" to true, "channel_epoch" to r.newEpoch, "kept" to r.kept, "chunks" to r.chunks))
|
||||
return 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Rotates [channelId] to its entitled set (CORD-06 §1-2), cutting [cut]: authority checked, the
|
||||
* key reserved in the store before anything publishes (a retry re-delivers the same key), every
|
||||
* chunk accepted by a relay before the new key is adopted locally. Prints nothing.
|
||||
*/
|
||||
internal suspend fun rotateSilently(
|
||||
ctx: Context,
|
||||
store: ConcordStore,
|
||||
sc: StoredCommunity,
|
||||
channelId: HexKey,
|
||||
authority: AuthorityResolver,
|
||||
editions: List<ControlEdition>,
|
||||
cut: Set<HexKey>,
|
||||
): Rotation {
|
||||
val me = ctx.signer.pubKey
|
||||
val entry = ConcordCommands.entryFor(sc)
|
||||
val held = ConcordChannelKeyring.heldKey(entry, channelId) ?: return Rotation("no_channel_key" to "this account holds no key for channel $channelId, so it cannot rotate it")
|
||||
if (!ConcordPrivateChannels.canRotate(authority, me, cut)) {
|
||||
return Rotation("forbidden" to "rotating needs the Manage-channels permission and outranking every member it cuts (CORD-06 §3)")
|
||||
}
|
||||
val citation = ConcordReceive.rotationCitation(entry, editions, me)
|
||||
if (citation == null && !authority.isOwner(me)) return Rotation("forbidden" to "no Grant of ours to cite; nobody would honor this rotation (CORD-06 §3)")
|
||||
|
||||
val newEpoch = held.epoch + 1
|
||||
val reservation = "${held.channelId.lowercase()}:$newEpoch:${ConcordChannelRekey.prevCommit(held.epoch, held.key.hexToByteArray())}"
|
||||
val newKeyHex = sc.pendingChannelRotations[reservation] ?: ConcordChannelRekey.mintKey().toHexKey()
|
||||
store.upsert(sc.copy(pendingChannelRotations = sc.pendingChannelRotations + (reservation to newKeyHex)))
|
||||
|
||||
val keep = ConcordPrivateChannels.keepSet(authority, channelId, me)
|
||||
val wraps = ConcordPrivateChannels.buildRotation(ctx.signer, sc.root.hexToByteArray(), held, newKeyHex.hexToByteArray(), keep, TimeUtils.now(), citation)
|
||||
val relays = ConcordCommands.relaysFor(ctx, sc)
|
||||
for (wrap in wraps) {
|
||||
if (ctx.publish(wrap, relays).values.none { it.accepted }) {
|
||||
return Rotation("rejected" to "no relay accepted chunk ${wrap.id} of the rotation; re-running re-delivers the same key")
|
||||
}
|
||||
}
|
||||
|
||||
// Adopt at once: the rotator must never keep writing under the severed key.
|
||||
val fresh = store.load().firstOrNull { it.communityId == sc.communityId } ?: sc
|
||||
val next = ConcordChannelKeyring.withRotatedKey(ConcordCommands.entryFor(fresh), channelId, newKeyHex, newEpoch)
|
||||
if (next != null) store.upsert(ConcordCommands.storedFrom(fresh, next).copy(pendingChannelRotations = fresh.pendingChannelRotations - reservation))
|
||||
return Rotation(newEpoch = newEpoch, kept = keep.size, chunks = wraps.size)
|
||||
}
|
||||
|
||||
/**
|
||||
* Follows every held Private Channel's rotations for [sc] (CORD-06 §2): drains the watched
|
||||
* channel-rekey addresses, adopts a key carried off the one we hold, or drops the channel (and
|
||||
* records the cut) when a rotation from someone who outranks us left us out. Returns one result
|
||||
* per channel acted on.
|
||||
*/
|
||||
internal suspend fun drainChannelRekeys(
|
||||
ctx: Context,
|
||||
store: ConcordStore,
|
||||
sc: StoredCommunity,
|
||||
): List<Map<String, Any?>> {
|
||||
val entry = ConcordCommands.entryFor(sc)
|
||||
val keys = ConcordPrivateChannels.watchKeys(entry)
|
||||
if (keys.isEmpty()) return emptyList()
|
||||
val relays = ConcordCommands.relaysFor(ctx, sc)
|
||||
ctx.registerConcordStreamKeys(relays, keys.values.map { it.secretKey })
|
||||
val wraps = ctx.drain(relays.associateWith { listOf(ConcordActions.planeFilterFor(keys.keys.toList())) }, pendingOnAuthRequired = true).map { it.second }
|
||||
if (wraps.isEmpty()) return emptyList()
|
||||
val loaded = ConcordModCommands.load(ctx, sc)
|
||||
val authority = AuthorityResolver.resolve(loaded.editions, sc.communityId.hexToByteArray(), sc.owner)
|
||||
val outcomes = ConcordPrivateChannels.receive(entry, wraps, loaded.editions, authority, ctx.signer)
|
||||
if (outcomes.isEmpty()) return emptyList()
|
||||
val fresh = store.load().firstOrNull { it.communityId == sc.communityId } ?: sc
|
||||
val next = ConcordPrivateChannels.applyOutcome(ConcordCommands.entryFor(fresh), outcomes, entry.privateChannels.associate { it.channelId.lowercase() to it.epoch })
|
||||
if (next != null) store.upsert(ConcordCommands.storedFrom(fresh, next))
|
||||
return outcomes.map { (id, outcome) ->
|
||||
when (outcome) {
|
||||
is ChannelRekeyOutcome.Adopted -> mapOf("community_id" to sc.communityId, "channel_id" to id, "adopted" to true, "channel_epoch" to outcome.epoch)
|
||||
is ChannelRekeyOutcome.Removed -> mapOf("community_id" to sc.communityId, "channel_id" to id, "removed" to true, "channel_epoch" to outcome.epoch)
|
||||
ChannelRekeyOutcome.None -> mapOf("community_id" to sc.communityId, "channel_id" to id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* After a Grant: vends every Private Channel it opened to its member by Direct Invite (only those
|
||||
* channels), and rotates every one it closed (CORD-03/06; Armada `handleToggleRole`). Returns what
|
||||
* it did, for the grant command's output.
|
||||
*/
|
||||
internal suspend fun reconcileAccess(
|
||||
ctx: Context,
|
||||
store: ConcordStore,
|
||||
sc: StoredCommunity,
|
||||
before: AuthorityResolver,
|
||||
afterEditions: List<ControlEdition>,
|
||||
): Map<String, Any?> {
|
||||
val state = ConcordCommunityState.fold(afterEditions, sc.communityId.hexToByteArray(), sc.owner)
|
||||
val me = ctx.signer.pubKey.lowercase()
|
||||
val entry = ConcordCommands.entryFor(sc)
|
||||
val changes = ConcordInviteVend.accessChanges(before, state.authority, state.privateChannelIds)
|
||||
val vended = mutableListOf<Map<String, Any?>>()
|
||||
val rotated = mutableListOf<String>()
|
||||
val unrotated = mutableListOf<Map<String, String>>()
|
||||
val unheld = mutableListOf<String>()
|
||||
val byMember = HashMap<HexKey, MutableSet<HexKey>>()
|
||||
for (change in changes) {
|
||||
if (ConcordChannelKeyring.heldKey(entry, change.channelIdHex) == null) {
|
||||
unheld += change.channelIdHex
|
||||
continue
|
||||
}
|
||||
for (m in change.gained - me) byMember.getOrPut(m) { HashSet() }.add(change.channelIdHex)
|
||||
val cut = change.lost - me
|
||||
if (cut.isNotEmpty()) {
|
||||
val fresh = store.load().firstOrNull { it.communityId == sc.communityId } ?: sc
|
||||
val r = rotateSilently(ctx, store, fresh, change.channelIdHex, state.authority, afterEditions, cut)
|
||||
if (r.error == null) rotated += change.channelIdHex else unrotated += mapOf("channel_id" to change.channelIdHex, "reason" to r.error.second)
|
||||
}
|
||||
}
|
||||
for ((member, channels) in byMember) {
|
||||
val draft = ConcordActions.draftDirectInvite(entry, state, me, member, onlyChannelIds = channels) as? ConcordDirectInviteDraft.Ready ?: continue
|
||||
val wrap = ConcordActions.buildDirectInvite(ctx.signer, member, draft.invite)
|
||||
val lists = ctx.cachedRelayListsOf(member) ?: RecipientRelayFetcher.fetchRelayLists(ctx.client, member, ctx.bootstrapRelays())
|
||||
val ack = ctx.publish(wrap, ConcordActions.directInviteDeliveryRelays(lists))
|
||||
vended += mapOf("member" to member, "channels" to draft.invite.channels.map { it.id }, "delivered" to ack.values.any { it.accepted })
|
||||
}
|
||||
return mapOf("channel_keys_vended" to vended, "channels_rotated" to rotated, "channels_not_rotated" to unrotated, "channels_not_held" to unheld)
|
||||
}
|
||||
}
|
||||
+4
-1
@@ -23,6 +23,7 @@ package com.vitorpamplona.amethyst.cli.stores
|
||||
import com.fasterxml.jackson.module.kotlin.readValue
|
||||
import com.vitorpamplona.amethyst.cli.Output
|
||||
import com.vitorpamplona.amethyst.cli.SecureFileIO
|
||||
import com.vitorpamplona.amethyst.commons.model.concord.ConcordDirectInviteInbox
|
||||
import java.io.File
|
||||
|
||||
/** amy's bookkeeping for Concord Direct Invites (CORD-05 §6): the wrap ids the user declined. */
|
||||
@@ -50,6 +51,8 @@ class ConcordInviteInboxStore(
|
||||
fun decline(wrapId: String) {
|
||||
val current = load()
|
||||
if (wrapId in current.declined) return
|
||||
SecureFileIO.writeTextAtomic(file, Output.mapper.writeValueAsString(current.copy(declined = current.declined + wrapId)))
|
||||
// Bounded like the app's store: the newest declines are kept, a long-expired one is not worth a line.
|
||||
val next = (current.declined + wrapId).takeLast(ConcordDirectInviteInbox.DECLINED_CAP)
|
||||
SecureFileIO.writeTextAtomic(file, Output.mapper.writeValueAsString(current.copy(declined = next)))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -57,6 +57,13 @@ data class StoredCommunity(
|
||||
// A private channel is read and written ONLY on the plane its own key derives; without one it
|
||||
// is unreadable and `send` refuses rather than fall back to the root-derived plane.
|
||||
val privateChannels: List<StoredPrivateChannel> = emptyList(),
|
||||
// Per Private Channel, the channel epoch whose rotation cut this account out (CORD-06 §2; the
|
||||
// reference client's `channel_cuts`): a key below it is never adopted again from a bundle.
|
||||
val channelCuts: Map<String, Long> = emptyMap(),
|
||||
// Channel keys reserved for a Private Channel rotation this account started but has not yet
|
||||
// adopted, keyed "channelId:newEpoch:prevcommit" (CORD-06): a retried `channel rekey` must
|
||||
// re-deliver the SAME key, never a sibling that splits the members at one epoch.
|
||||
val pendingChannelRotations: Map<String, String> = emptyMap(),
|
||||
// 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,
|
||||
|
||||
+77
-26
@@ -33,8 +33,8 @@ import com.vitorpamplona.quartz.concord.cord02Community.ImagePointer
|
||||
import com.vitorpamplona.quartz.concord.cord02Community.NewConcordCommunity
|
||||
import com.vitorpamplona.quartz.concord.cord02Community.PrivateChannelKey
|
||||
import com.vitorpamplona.quartz.concord.cord03Channels.ChannelChat
|
||||
import com.vitorpamplona.quartz.concord.cord03Channels.ConcordChannelKeyring
|
||||
import com.vitorpamplona.quartz.concord.cord03Channels.ConcordChannelKeys
|
||||
import com.vitorpamplona.quartz.concord.cord03Channels.ConcordChatEditEvent
|
||||
import com.vitorpamplona.quartz.concord.cord03Channels.ConcordDisappearing
|
||||
import com.vitorpamplona.quartz.concord.cord04Roles.AuthorityCitation
|
||||
import com.vitorpamplona.quartz.concord.cord04Roles.AuthorityResolver
|
||||
@@ -212,24 +212,30 @@ object ConcordActions {
|
||||
|
||||
/**
|
||||
* The older Chat Planes of a channel this account can still read, beside [currentChannelPlane]:
|
||||
* - Public: its plane under every held prior root ([historicalChannelPlanes]), plus the
|
||||
* private-era plane when a channel key is held (a channel that was Private before);
|
||||
* - Private: none. Only the channel-key planes are its own; the root-derived plane is readable
|
||||
* by every member, so showing it would present public content as private (Armada
|
||||
* `channelsView`). With no priors kept per channel key, that leaves nothing.
|
||||
* - Public: its plane under every held prior root ([historicalChannelPlanes]), plus every
|
||||
* private-era plane a channel key is held for (a channel that was Private before);
|
||||
* - Private: the planes of the older channel keys the entry still carries (its `seed` and a
|
||||
* peer's `priors`, [ConcordChannelKeyring.historicalKeys]) — history across a channel rekey.
|
||||
* Never the root-derived plane: every member reads that one, so showing it would present
|
||||
* public content as private (Armada `channelsView`).
|
||||
*/
|
||||
fun historicalChannelPlanes(
|
||||
entry: ConcordCommunityListEntry,
|
||||
channelIdHex: HexKey,
|
||||
isPrivate: Boolean,
|
||||
): List<ChannelPlane> {
|
||||
if (isPrivate) return emptyList()
|
||||
val channelId = channelIdHex.hexToByteArray()
|
||||
val olderKeys =
|
||||
ConcordChannelKeyring.historicalKeys(entry, channelIdHex).map { old ->
|
||||
ChannelPlane(channelIdHex, old.epoch, ConcordChannelKeys.privateChannel(old.key.hexToByteArray(), channelId, old.epoch))
|
||||
}
|
||||
if (isPrivate) return olderKeys
|
||||
val rootEras = historicalChannelPlanes(entry.heldRoots, listOf(channelIdHex))
|
||||
val privateEra =
|
||||
heldPrivateChannelKey(entry, channelIdHex)?.let { held ->
|
||||
ChannelPlane(channelIdHex, held.epoch, ConcordChannelKeys.privateChannel(held.key.hexToByteArray(), channelIdHex.hexToByteArray(), held.epoch))
|
||||
ChannelPlane(channelIdHex, held.epoch, ConcordChannelKeys.privateChannel(held.key.hexToByteArray(), channelId, held.epoch))
|
||||
}
|
||||
return rootEras + listOfNotNull(privateEra)
|
||||
return rootEras + listOfNotNull(privateEra) + olderKeys
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -500,6 +506,11 @@ object ConcordActions {
|
||||
* Builds an encrypted-seal **edit** wrap (kind-3302 [ChannelChat.edit] of [target]) on the
|
||||
* [channel] plane. [newText] replaces [target]'s content on receivers that apply the edit overlay;
|
||||
* only the original author's edits take effect, so restrict callers to their own messages.
|
||||
*
|
||||
* The Edit carries [expiration] verbatim — by default [target]'s own NIP-40 deadline, and none
|
||||
* when [target] has none — never `now + timer`: an Edit stamped with a fresh deadline would
|
||||
* outlive (or cut short) the message it revises, so the revised words could survive the message
|
||||
* the timer already erased (CORD-08 §2; the reference client keeps `expirationOf(original)`).
|
||||
*/
|
||||
suspend fun buildChannelEdit(
|
||||
authorSigner: NostrSigner,
|
||||
@@ -510,9 +521,10 @@ object ConcordActions {
|
||||
newText: String,
|
||||
createdAt: Long,
|
||||
extraTags: Array<Array<String>> = emptyArray(),
|
||||
timerSecs: Long? = null,
|
||||
expiration: Long? = ConcordDisappearing.expirationOf(target),
|
||||
): Event {
|
||||
val rumor = ChannelChat.edit(authorSigner.pubKey, channelId, epoch, target.id, newText, createdAt, withTimer(extraTags, ConcordChatEditEvent.KIND, createdAt, timerSecs))
|
||||
val tags = ConcordDisappearing.withExpiration(extraTags.filterNot { it.isNotEmpty() && it[0] == "expiration" }.toTypedArray(), expiration)
|
||||
val rumor = ChannelChat.edit(authorSigner.pubKey, channelId, epoch, target.id, newText, createdAt, tags)
|
||||
return wrapChat(rumor, channel, authorSigner)
|
||||
}
|
||||
|
||||
@@ -616,14 +628,16 @@ object ConcordActions {
|
||||
/**
|
||||
* [openChannelRumor] without the CORD-08 expiry refusal, for a caller that must tell an expired
|
||||
* rumor apart from garbage — the session, which purges an expired rumor's wrap instead of merely
|
||||
* skipping it. Such a caller owns the refusal.
|
||||
* skipping it. Such a caller owns the refusal. [kinds] widens the gate to
|
||||
* [ChannelChat.PLANE_KINDS] for a caller that routes the WebXDC signal apart from chat rows.
|
||||
*/
|
||||
fun openChannelRumorAnyExpiry(
|
||||
wrap: Event,
|
||||
channel: GroupKey,
|
||||
channelId: HexKey,
|
||||
epoch: Long,
|
||||
): Event? = ConcordStreamEnvelope.openOrNull(wrap, channel)?.let { ChannelChat.acceptOpened(it, channelId, epoch) }
|
||||
kinds: Set<Int> = ChannelChat.CHAT_KINDS,
|
||||
): Event? = ConcordStreamEnvelope.openOrNull(wrap, channel)?.let { ChannelChat.acceptOpened(it, channelId, epoch, kinds) }
|
||||
|
||||
// ---- invites --------------------------------------------------------------
|
||||
|
||||
@@ -676,6 +690,7 @@ object ConcordActions {
|
||||
expiresAtMs: Long? = null,
|
||||
name: String = entry.name,
|
||||
icon: ImagePointer? = null,
|
||||
onlyChannelIds: Set<HexKey>? = null,
|
||||
): CommunityInvite =
|
||||
CommunityInvite(
|
||||
communityId = entry.id,
|
||||
@@ -684,7 +699,12 @@ object ConcordActions {
|
||||
communityRoot = entry.root,
|
||||
rootEpoch = entry.rootEpoch,
|
||||
controlPk = entry.controlPk,
|
||||
channels = ConcordInviteVend.toInviteChannels(ConcordInviteVend.vendableChannels(entry.privateChannels, authority, recipient)),
|
||||
channels =
|
||||
ConcordInviteVend.toInviteChannels(
|
||||
ConcordInviteVend
|
||||
.vendableChannels(entry.privateChannels, authority, recipient)
|
||||
.filter { onlyChannelIds == null || it.channelId.lowercase() in onlyChannelIds },
|
||||
),
|
||||
relays = entry.relays.take(ConcordInviteBundle.MAX_COMMUNITY_RELAYS),
|
||||
name = name.ifBlank { entry.name },
|
||||
icon = icon,
|
||||
@@ -705,6 +725,7 @@ object ConcordActions {
|
||||
sender: HexKey,
|
||||
recipient: HexKey,
|
||||
expiresAtMs: Long? = null,
|
||||
onlyChannelIds: Set<HexKey>? = null,
|
||||
): ConcordDirectInviteDraft {
|
||||
val to = recipient.lowercase()
|
||||
if (!HEX64.matches(to)) return ConcordDirectInviteDraft.Refused(ConcordDirectInviteSendResult.INVALID_RECIPIENT)
|
||||
@@ -719,6 +740,7 @@ object ConcordActions {
|
||||
expiresAtMs = expiresAtMs,
|
||||
name = state.metadata?.name ?: entry.name,
|
||||
icon = state.metadata?.icon,
|
||||
onlyChannelIds = onlyChannelIds?.mapTo(HashSet()) { it.lowercase() },
|
||||
),
|
||||
)
|
||||
}
|
||||
@@ -879,11 +901,31 @@ object ConcordActions {
|
||||
return ConcordStreamEnvelope.wrap(rumor, guestbook, memberSigner, encrypted = true, createdAt = createdAt)
|
||||
}
|
||||
|
||||
/** Opens the guestbook [wraps] into their live membership set (joins minus later leaves). */
|
||||
/**
|
||||
* Builds an authorized Guestbook KICK (kind 3309) wrap naming [target], citing [citation] — the
|
||||
* actor's own Grant head (`vac`, CORD-04 §5), null only for the owner. A Kick is the *second*
|
||||
* layer of a removal: the caller strips the target's roles first (CORD-04 §6).
|
||||
*/
|
||||
suspend fun buildGuestbookKick(
|
||||
actorSigner: NostrSigner,
|
||||
guestbook: GroupKey,
|
||||
target: HexKey,
|
||||
citation: AuthorityCitation?,
|
||||
createdAt: Long,
|
||||
): Event {
|
||||
val rumor = Guestbook.kick(actorSigner.pubKey, target.lowercase(), createdAt, citation = citation)
|
||||
return ConcordStreamEnvelope.wrap(rumor, guestbook, actorSigner, encrypted = true, createdAt = createdAt)
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens the guestbook [wraps] into their live membership set: joins minus later leaves and later
|
||||
* Kicks honored against [authority] (none is honored without it).
|
||||
*/
|
||||
fun guestbookMembers(
|
||||
wraps: List<Event>,
|
||||
guestbook: GroupKey,
|
||||
): Set<HexKey> = projectGuestbook(wraps.mapNotNull { guestbookEntry(it, guestbook) })
|
||||
authority: AuthorityResolver? = null,
|
||||
): Set<HexKey> = projectGuestbook(wraps.mapNotNull { guestbookEntry(it, guestbook) }, authority)
|
||||
|
||||
/**
|
||||
* Opens a single guestbook [wrap] into its entry, or null when it doesn't belong to
|
||||
@@ -898,17 +940,26 @@ object ConcordActions {
|
||||
fun guestbookEntry(
|
||||
wrap: Event,
|
||||
guestbook: GroupKey,
|
||||
): GuestbookEntry? = ConcordStreamEnvelope.openOrNull(wrap, guestbook)?.rumor?.let { Guestbook.parse(it) }
|
||||
): GuestbookEntry? =
|
||||
ConcordStreamEnvelope
|
||||
.openOrNull(wrap, guestbook)
|
||||
// The Guestbook's seals MUST be encrypted (CORD-02 §5); a plaintext one is Control-only.
|
||||
?.takeIf { it.sealKind == ConcordStreamEnvelope.KIND_SEAL_ENCRYPTED }
|
||||
?.rumor
|
||||
?.let { Guestbook.parse(it) }
|
||||
|
||||
/** Last-writer-wins projection of already-opened [entries] down to the JOINed member set. */
|
||||
fun projectGuestbook(entries: Collection<GuestbookEntry>): Set<HexKey> {
|
||||
val latest = HashMap<HexKey, GuestbookEntry>()
|
||||
for (entry in entries) {
|
||||
val prev = latest[entry.member.lowercase()]
|
||||
if (prev == null || entry.createdAt > prev.createdAt) latest[entry.member.lowercase()] = entry
|
||||
}
|
||||
return latest.values.filter { it.action == GuestbookAction.JOIN }.mapTo(HashSet()) { it.member.lowercase() }
|
||||
}
|
||||
/**
|
||||
* The CORD-02 §5 coalesce of already-opened [entries] (latest motion per npub, Kicks honored
|
||||
* against [authority]) down to the JOINed member set.
|
||||
*/
|
||||
fun projectGuestbook(
|
||||
entries: Collection<GuestbookEntry>,
|
||||
authority: AuthorityResolver? = null,
|
||||
nowMs: Long = TimeUtils.nowMillis(),
|
||||
): Set<HexKey> = joinedMembers(Guestbook.coalesce(entries, nowMs, authority))
|
||||
|
||||
/** The npubs whose coalesced Guestbook state ([Guestbook.coalesce]) is a Join. */
|
||||
fun joinedMembers(coalesced: Map<HexKey, GuestbookEntry>): Set<HexKey> = coalesced.filterValues { it.action == GuestbookAction.JOIN }.keys
|
||||
|
||||
// ---- refounding / rekey (CORD-06) ----------------------------------------
|
||||
|
||||
|
||||
+13
-5
@@ -32,6 +32,7 @@ import com.vitorpamplona.quartz.concord.cord04Roles.ControlEdition
|
||||
import com.vitorpamplona.quartz.concord.cord04Roles.ControlEditionBuilder
|
||||
import com.vitorpamplona.quartz.concord.cord04Roles.ControlEntityKind
|
||||
import com.vitorpamplona.quartz.concord.cord04Roles.ControlRootWrap
|
||||
import com.vitorpamplona.quartz.concord.cord04Roles.EntityFloor
|
||||
import com.vitorpamplona.quartz.concord.cord04Roles.GrantEntity
|
||||
import com.vitorpamplona.quartz.concord.cord04Roles.MetadataEntity
|
||||
import com.vitorpamplona.quartz.concord.cord04Roles.RoleEntity
|
||||
@@ -98,7 +99,8 @@ object ConcordModeration {
|
||||
communityId: ByteArray,
|
||||
entityId: ByteArray,
|
||||
owner: HexKey,
|
||||
): ControlEdition? = ConcordCommunityState.authorizedHeads(current, communityId, owner)[entityId.toHexKey()]?.known
|
||||
floors: Map<String, EntityFloor> = emptyMap(),
|
||||
): ControlEdition? = ConcordCommunityState.authorizedHeads(current, communityId, owner, floors)[entityId.toHexKey()]?.known
|
||||
|
||||
/** version/prevHash to chain onto the current head of [entityId], or a genesis at version 1 (CORD-04 §1). */
|
||||
private fun versioning(head: ControlEdition?): Pair<Long, ByteArray?> = if (head != null) (head.version + 1) to head.hash else 1L to null
|
||||
@@ -140,8 +142,9 @@ object ConcordModeration {
|
||||
createdAt: Long,
|
||||
citation: AuthorityCitation?,
|
||||
owner: HexKey,
|
||||
floors: Map<String, EntityFloor> = emptyMap(),
|
||||
): Event {
|
||||
val head = headOf(current, communityId, entityId, owner)
|
||||
val head = headOf(current, communityId, entityId, owner, floors)
|
||||
val content = ConcordJson.encodePreserving(serializer, value, head?.content)
|
||||
return wrap(actor, controlPlane, communityId, kind, entityId, head, content, current, createdAt, citation, owner)
|
||||
}
|
||||
@@ -207,7 +210,8 @@ object ConcordModeration {
|
||||
createdAt: Long,
|
||||
citation: AuthorityCitation? = null,
|
||||
owner: HexKey,
|
||||
): Event = editMetadata(actor, controlPlane, communityId, standing.withMessageExpiration(secs), current, createdAt, citation, owner)
|
||||
floors: Map<String, EntityFloor> = emptyMap(),
|
||||
): Event = editMetadata(actor, controlPlane, communityId, standing.withMessageExpiration(secs), current, createdAt, citation, owner, floors)
|
||||
|
||||
/**
|
||||
* Replaces the community metadata (name / icon / description / relays). The
|
||||
@@ -224,10 +228,11 @@ object ConcordModeration {
|
||||
createdAt: Long,
|
||||
citation: AuthorityCitation? = null,
|
||||
owner: HexKey,
|
||||
floors: Map<String, EntityFloor> = emptyMap(),
|
||||
): Event {
|
||||
require(ConcordLimits.nameFits(metadata.name)) { "community name exceeds ${ConcordLimits.NAME_MAX_BYTES} bytes" }
|
||||
require(ConcordLimits.descriptionFits(metadata.description)) { "description exceeds ${ConcordLimits.DESCRIPTION_MAX_BYTES} bytes" }
|
||||
return edit(actor, controlPlane, communityId, ControlEntityKind.METADATA, communityId, MetadataEntity.serializer(), metadata, current, createdAt, citation, owner)
|
||||
return edit(actor, controlPlane, communityId, ControlEntityKind.METADATA, communityId, MetadataEntity.serializer(), metadata, current, createdAt, citation, owner, floors)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -390,9 +395,12 @@ object ConcordModeration {
|
||||
createdAt: Long,
|
||||
citation: AuthorityCitation? = null,
|
||||
owner: HexKey,
|
||||
floors: Map<String, EntityFloor> = emptyMap(),
|
||||
): Event {
|
||||
val entityId = ConcordInviteRegistry.coordinate(communityId, actor.pubKey)
|
||||
val head = headOf(current, communityId, entityId, owner)
|
||||
// Floor-aware: after a Refounding the honored head may be the prior epoch's (the anti-rollback
|
||||
// floor), and chaining onto the current epoch's editions alone would fork below it.
|
||||
val head = headOf(current, communityId, entityId, owner, floors)
|
||||
return wrap(actor, controlPlane, communityId, ControlEntityKind.INVITE_REGISTRY, entityId, head, ConcordInviteRegistry.encode(linkSigners), current, createdAt, citation, owner)
|
||||
}
|
||||
|
||||
|
||||
+32
@@ -0,0 +1,32 @@
|
||||
/*
|
||||
* 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.actions
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
|
||||
/**
|
||||
* This pin as the rumor the chat feed would render (CORD-04 §7): the recomputed id, author, kind and
|
||||
* the original's tags — so its NIP-92 `imeta` attachments, encrypted ones included, come along — with
|
||||
* the newest words this client can show ([ConcordPinnedMessage.content]). Unsigned: a rumor never is.
|
||||
* A pin proves its message without this account ever having held it, so this is the only source of
|
||||
* the attachment keys for such a pin.
|
||||
*/
|
||||
fun ConcordPinnedMessage.toRumor(): Event = Event(pin.rumorId, pin.author, pin.createdAt, pin.kind, pin.tags, content, "")
|
||||
+73
-4
@@ -37,6 +37,7 @@ import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray
|
||||
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
|
||||
import com.vitorpamplona.quartz.nip40Expiration.ExpirationTag
|
||||
import com.vitorpamplona.quartz.utils.sha256.sha256
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlin.random.Random
|
||||
@@ -92,6 +93,11 @@ class ConcordChannelPins(
|
||||
val sealedForm: Boolean,
|
||||
/** Entries that failed verification and were dropped alone. */
|
||||
val invalidEntries: Int,
|
||||
/**
|
||||
* False while the Control Plane has not been swept whole yet: [head] is whatever the partial fold
|
||||
* holds, so an empty list may only mean "not served yet". No edition may be built from it (§7).
|
||||
*/
|
||||
val complete: Boolean = true,
|
||||
) {
|
||||
val count: Int get() = pins.size
|
||||
|
||||
@@ -100,8 +106,20 @@ class ConcordChannelPins(
|
||||
/** True when the head owes keyless readers a republish: an erased entry, or a newer Edit to attach. */
|
||||
val owesRepublish: Boolean get() = killed.isNotEmpty() || pins.any { it.newerEdit != null }
|
||||
|
||||
/** Every rumor id the list carries, shown or erased — what a delete or an Edit must name to change this read. */
|
||||
val rumorIds: Set<HexKey> by lazy { (alive + killed).mapTo(HashSet()) { it.rumorId } }
|
||||
|
||||
/**
|
||||
* The soonest NIP-40 deadline (unix seconds) after [now] among the shown pins, or null: when this
|
||||
* read goes stale, since an expired message leaves the list (CORD-08 §3).
|
||||
*/
|
||||
fun nextExpiry(now: Long): Long? = alive.mapNotNull { pin -> pin.tags.firstNotNullOfOrNull(ExpirationTag::parse) }.filter { it > now }.minOrNull()
|
||||
|
||||
companion object {
|
||||
fun none(channelIdHex: HexKey) = ConcordChannelPins(channelIdHex, null, emptyList(), emptyList(), emptyList(), sealedUnavailable = false, violating = false, sealedForm = false, invalidEntries = 0)
|
||||
fun none(
|
||||
channelIdHex: HexKey,
|
||||
complete: Boolean = true,
|
||||
) = ConcordChannelPins(channelIdHex, null, emptyList(), emptyList(), emptyList(), sealedUnavailable = false, violating = false, sealedForm = false, invalidEntries = 0, complete = complete)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -136,6 +154,38 @@ class ConcordPinVerifier(
|
||||
}
|
||||
return verdict
|
||||
}
|
||||
|
||||
private val lists = LinkedHashMap<HexKey, ConcordPins.PinListRead>()
|
||||
|
||||
/** List parses (and sealed-form decrypts) actually performed (cache misses) — for tests. */
|
||||
var listReads: Int = 0
|
||||
private set
|
||||
|
||||
/**
|
||||
* [head]'s content read as a Pin List, memoized by the head's rumor id: an edition's bytes never
|
||||
* change, so re-reading the pins on every trigger (a fold, a delete landing, an expiry) parses and
|
||||
* decrypts the list once. A read that found the list sealed under a key not held is not cached,
|
||||
* so the key arriving later (a Private Channel key delivered on grant) opens it.
|
||||
*/
|
||||
fun readList(
|
||||
head: ControlEdition,
|
||||
unsealKey: (epoch: Long) -> ByteArray?,
|
||||
): ConcordPins.PinListRead {
|
||||
lock.withLock { lists[head.rumorId] }?.let { return it }
|
||||
val read = ConcordPins.read(head.content, unsealKey)
|
||||
lock.withLock {
|
||||
listReads++
|
||||
if (!read.sealedUnavailable) {
|
||||
lists[head.rumorId] = read
|
||||
while (lists.size > MAX_LISTS) lists.remove(lists.keys.first())
|
||||
}
|
||||
}
|
||||
return read
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val MAX_LISTS = 64
|
||||
}
|
||||
}
|
||||
|
||||
/** The proof material for pinning one opened message: its original seal and the plane key of its epoch. */
|
||||
@@ -214,6 +264,9 @@ enum class ConcordPinOutcome {
|
||||
UNVERIFIABLE,
|
||||
TOO_MANY_PINS,
|
||||
TOO_LARGE,
|
||||
|
||||
/** The edition was built but no relay acknowledged it, so it may not have landed. */
|
||||
NOT_CONFIRMED,
|
||||
}
|
||||
|
||||
class ConcordPinWrite(
|
||||
@@ -283,9 +336,10 @@ object ConcordPinning {
|
||||
verifier: ConcordPinVerifier = ConcordPinVerifier(),
|
||||
isKilled: (VerifiedPin) -> Boolean = { false },
|
||||
newestEdit: (VerifiedPin) -> ConcordLocalEdit? = { null },
|
||||
complete: Boolean = true,
|
||||
): ConcordChannelPins {
|
||||
if (head == null) return ConcordChannelPins.none(channelIdHex)
|
||||
val read = ConcordPins.read(head.content, unsealKey)
|
||||
if (head == null) return ConcordChannelPins.none(channelIdHex, complete)
|
||||
val read = verifier.readList(head, unsealKey)
|
||||
val alive = ArrayList<VerifiedPin>()
|
||||
val killed = ArrayList<VerifiedPin>()
|
||||
var invalid = 0
|
||||
@@ -314,11 +368,23 @@ object ConcordPinning {
|
||||
pins = shown,
|
||||
sealedUnavailable = read.sealedUnavailable,
|
||||
violating = read.violating,
|
||||
sealedForm = ConcordPins.isSealedForm(head.content),
|
||||
sealedForm = read.sealedForm,
|
||||
invalidEntries = invalid,
|
||||
complete = complete,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* [pins]' shown entries a reader displays: an entry by a [isBanned] author (the community declines
|
||||
* to show a banned member's posts, CORD-04 §4) or by someone the reader [isHidden]s (mutes or
|
||||
* blocks) is left out. Display only — the list itself, and every write built from it, is untouched.
|
||||
*/
|
||||
fun visible(
|
||||
pins: ConcordChannelPins,
|
||||
isBanned: (HexKey) -> Boolean,
|
||||
isHidden: (HexKey) -> Boolean,
|
||||
): List<ConcordPinnedMessage> = pins.pins.filterNot { isBanned(it.author) || isHidden(it.author) }
|
||||
|
||||
/** True when [edit] is newer than whatever Edit [pin]'s proof already carries. */
|
||||
private fun isNewer(
|
||||
edit: ConcordLocalEdit,
|
||||
@@ -363,6 +429,9 @@ object ConcordPinning {
|
||||
when {
|
||||
!ctx.authorized -> ConcordPinOutcome.NOT_AUTHORIZED
|
||||
!ctx.controlPlane.canWrite -> ConcordPinOutcome.NO_WRITE_KEY
|
||||
// §7: never write from a list the fold was not served — a partial fold's head (or its
|
||||
// absence) is not the list a replace-entire edition would overwrite.
|
||||
!ctx.pins.complete -> ConcordPinOutcome.NOT_FOLDED
|
||||
// §7: a writer MUST NOT build an edition from a list it could not read.
|
||||
ctx.pins.sealedUnavailable -> ConcordPinOutcome.LIST_UNAVAILABLE
|
||||
ctx.channelIsPrivate && ctx.currentPlane == null -> ConcordPinOutcome.NO_CHANNEL_KEY
|
||||
|
||||
+387
@@ -0,0 +1,387 @@
|
||||
/*
|
||||
* 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.actions
|
||||
|
||||
import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEntry
|
||||
import com.vitorpamplona.quartz.concord.cord02Community.PrivateChannelKey
|
||||
import com.vitorpamplona.quartz.concord.cord03Channels.ConcordChannelKeyring
|
||||
import com.vitorpamplona.quartz.concord.cord04Roles.AuthorityCitation
|
||||
import com.vitorpamplona.quartz.concord.cord04Roles.AuthorityResolver
|
||||
import com.vitorpamplona.quartz.concord.cord04Roles.ChannelEntity
|
||||
import com.vitorpamplona.quartz.concord.cord04Roles.ConcordLimits
|
||||
import com.vitorpamplona.quartz.concord.cord04Roles.ConcordPermissions
|
||||
import com.vitorpamplona.quartz.concord.cord04Roles.ControlEdition
|
||||
import com.vitorpamplona.quartz.concord.cord04Roles.RoleEntity
|
||||
import com.vitorpamplona.quartz.concord.cord04Roles.RoleScope
|
||||
import com.vitorpamplona.quartz.concord.cord05Invites.ConcordInviteVend
|
||||
import com.vitorpamplona.quartz.concord.cord06Rekey.ChannelRekeyOutcome
|
||||
import com.vitorpamplona.quartz.concord.cord06Rekey.ChannelRotation
|
||||
import com.vitorpamplona.quartz.concord.cord06Rekey.ConcordChannelRekey
|
||||
import com.vitorpamplona.quartz.concord.cord06Rekey.ConcordRefounding
|
||||
import com.vitorpamplona.quartz.concord.cord06Rekey.ConcordRotationAuthority
|
||||
import com.vitorpamplona.quartz.concord.crypto.ControlPlaneKeys
|
||||
import com.vitorpamplona.quartz.concord.crypto.GroupKey
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray
|
||||
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
|
||||
import com.vitorpamplona.quartz.utils.RandomInstance
|
||||
|
||||
/**
|
||||
* A Private Channel just minted or privatised (CORD-03 §2): its [channelIdHex], the independent
|
||||
* [key] to store in the Community List **before** [wraps] publish (a lost List write would orphan
|
||||
* the only copy), the access Role minted beside it ([roleIdHex]), and the Control editions to
|
||||
* publish in order — the Role first (an orphan Role is inert), then the channel edition.
|
||||
*/
|
||||
class PrivateChannelBuild(
|
||||
val channelIdHex: HexKey,
|
||||
val key: PrivateChannelKey,
|
||||
val roleIdHex: HexKey,
|
||||
val wraps: List<Event>,
|
||||
)
|
||||
|
||||
/**
|
||||
* Private Channels end to end (CORD-03 §1-2, CORD-04 §2, CORD-05 §6, CORD-06 §1-3) as pure
|
||||
* builders and decisions, shared by the app and `amy`. The network and the Community List write
|
||||
* stay with the caller.
|
||||
*
|
||||
* Who may read a Private Channel is its Roles: a Role scoped `{kind:"channel", channel_id}` IS its
|
||||
* access list ([ConcordInviteVend.isEntitled]). Read access is enforced by key possession alone, so
|
||||
* this decides who a key is delivered TO (a Direct Invite on grant) and who a rotation keeps (a
|
||||
* channel rekey on revoke). Pinned to Armada's `channelAccess.ts`, `useCommunityActions`
|
||||
* (`createChannel`, `privatiseChannel`, `publiciseChannel`) and `useRekey` (`useChannelRekey`,
|
||||
* `useChannelRekeyWatch`).
|
||||
*/
|
||||
object ConcordPrivateChannels {
|
||||
/**
|
||||
* The position a new access Role takes (Armada `accessRolePosition`): the bottom of the roster,
|
||||
* never above what [actor] may mint (the owner mints from 1, anyone else strictly below their
|
||||
* own rank), so a grantee is never promoted by being let into a channel. Null when [actor] holds
|
||||
* no rank to mint from.
|
||||
*/
|
||||
fun accessRolePosition(
|
||||
authority: AuthorityResolver?,
|
||||
actor: HexKey,
|
||||
owner: HexKey,
|
||||
): Long? {
|
||||
val ceiling =
|
||||
if (actor.equals(owner, ignoreCase = true)) {
|
||||
1L
|
||||
} else {
|
||||
(authority?.rank(actor) ?: return null) + 1
|
||||
}
|
||||
val lowest =
|
||||
authority
|
||||
?.roles()
|
||||
?.values
|
||||
?.filterNot { it.deleted }
|
||||
?.maxOfOrNull { it.position } ?: 0L
|
||||
return maxOf(ceiling, lowest + 1)
|
||||
}
|
||||
|
||||
/** [name] cut to the protocol's 64-byte cap on a character boundary (Armada slices the same way). */
|
||||
private fun fitName(name: String): String {
|
||||
var out = name
|
||||
while (!ConcordLimits.nameFits(out)) out = out.dropLast(1)
|
||||
return out
|
||||
}
|
||||
|
||||
/** The bit-less access Role scoped to [channelIdHex] (CORD-04 §2): read access is the key, never a bit. */
|
||||
fun accessRole(
|
||||
name: String,
|
||||
channelIdHex: HexKey,
|
||||
position: Long,
|
||||
): RoleEntity =
|
||||
RoleEntity(
|
||||
name = fitName(name),
|
||||
position = position,
|
||||
permissions = "0",
|
||||
scope = RoleScope(kind = "channel", channelId = channelIdHex.lowercase()),
|
||||
)
|
||||
|
||||
/**
|
||||
* A new channel (CORD-03 §2): a random `channel_id`, and for a Private one an independent key
|
||||
* at channel epoch 0 plus its access Role (Armada `createChannel`: a born-private channel is
|
||||
* epoch 0; the first *privatisation* of a public channel is epoch 1). Returns null when the
|
||||
* name is invalid or the actor has no rank to mint the Role from.
|
||||
*/
|
||||
suspend fun create(
|
||||
actor: NostrSigner,
|
||||
cp: ControlPlaneKeys,
|
||||
communityId: ByteArray,
|
||||
name: String,
|
||||
accessRoleName: String?,
|
||||
current: List<ControlEdition>,
|
||||
authority: AuthorityResolver?,
|
||||
owner: HexKey,
|
||||
createdAt: Long,
|
||||
): PrivateChannelBuild? {
|
||||
val channel = ChannelEntity(name = name.trim(), private = true)
|
||||
if (!channel.hasValidName()) return null
|
||||
val position = accessRolePosition(authority, actor.pubKey, owner) ?: return null
|
||||
val channelId = RandomInstance.bytes(32)
|
||||
val channelIdHex = channelId.toHexKey()
|
||||
val roleId = RandomInstance.bytes(32)
|
||||
val role = accessRole(accessRoleName?.trim()?.ifBlank { null } ?: channel.name, channelIdHex, position)
|
||||
val roleWrap = ConcordModeration.defineRole(actor, cp, communityId, roleId, role, current, createdAt, owner = owner)
|
||||
val channelWrap = ConcordModeration.defineChannel(actor, cp, communityId, channelId, channel, current, createdAt, owner = owner)
|
||||
return PrivateChannelBuild(
|
||||
channelIdHex = channelIdHex,
|
||||
key = PrivateChannelKey(channelIdHex, ConcordChannelRekey.mintKey().toHexKey(), 0, channel.name),
|
||||
roleIdHex = roleId.toHexKey(),
|
||||
wraps = listOf(roleWrap, channelWrap),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts the Public channel [channelIdHex] to Private (CORD-03 §2): a fresh independent key at
|
||||
* the NEXT channel epoch ([ConcordChannelKeyring.nextChannelEpoch], floored at [observedFloor],
|
||||
* the highest channel rotation seen on the wire), a new access Role, and the channel edition
|
||||
* flipping `private` on — every other field of [standing] carried through. Protects the future
|
||||
* only: the public era stays readable to every member. Null when the actor can't mint the Role.
|
||||
*/
|
||||
suspend fun privatize(
|
||||
actor: NostrSigner,
|
||||
cp: ControlPlaneKeys,
|
||||
entry: ConcordCommunityListEntry,
|
||||
channelIdHex: HexKey,
|
||||
standing: ChannelEntity,
|
||||
accessRoleName: String?,
|
||||
current: List<ControlEdition>,
|
||||
authority: AuthorityResolver?,
|
||||
createdAt: Long,
|
||||
observedFloor: Long = 0,
|
||||
): PrivateChannelBuild? {
|
||||
if (standing.private || standing.deleted) return null
|
||||
val position = accessRolePosition(authority, actor.pubKey, entry.owner) ?: return null
|
||||
val communityId = entry.id.hexToByteArray()
|
||||
val roleId = RandomInstance.bytes(32)
|
||||
val role = accessRole(accessRoleName?.trim()?.ifBlank { null } ?: standing.name, channelIdHex, position)
|
||||
val roleWrap = ConcordModeration.defineRole(actor, cp, communityId, roleId, role, current, createdAt, owner = entry.owner)
|
||||
val channelWrap = ConcordModeration.defineChannel(actor, cp, communityId, channelIdHex.hexToByteArray(), standing.copy(private = true), current, createdAt, owner = entry.owner)
|
||||
val epoch = ConcordChannelKeyring.nextChannelEpoch(entry, channelIdHex, observedFloor)
|
||||
return PrivateChannelBuild(
|
||||
channelIdHex = channelIdHex.lowercase(),
|
||||
key = PrivateChannelKey(channelIdHex.lowercase(), ConcordChannelRekey.mintKey().toHexKey(), epoch, standing.name),
|
||||
roleIdHex = roleId.toHexKey(),
|
||||
wraps = listOf(roleWrap, channelWrap),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts the Private channel [channelIdHex] back to Public (CORD-03 §2): the flag only. The
|
||||
* channel derives from the `community_root` from here on; the held key stays in the List so its
|
||||
* holders keep reading the private era, which a later joiner never can. Null when it is not
|
||||
* private.
|
||||
*/
|
||||
suspend fun publicize(
|
||||
actor: NostrSigner,
|
||||
cp: ControlPlaneKeys,
|
||||
communityId: ByteArray,
|
||||
channelIdHex: HexKey,
|
||||
standing: ChannelEntity,
|
||||
current: List<ControlEdition>,
|
||||
owner: HexKey,
|
||||
createdAt: Long,
|
||||
): Event? {
|
||||
if (!standing.private || standing.deleted) return null
|
||||
return ConcordModeration.defineChannel(actor, cp, communityId, channelIdHex.hexToByteArray(), standing.copy(private = false), current, createdAt, owner = owner)
|
||||
}
|
||||
|
||||
// ---- channel rotations (CORD-06 §1-2) -------------------------------------
|
||||
|
||||
/**
|
||||
* Whether [actor] may launch a single-channel Rekey cutting [removed] (CORD-06 §3 Authority):
|
||||
* `MANAGE_CHANNELS` (or `BAN`, a Refounding's) and strictly outranking every removed target.
|
||||
* The owner may always; the owner is never a valid target.
|
||||
*/
|
||||
fun canRotate(
|
||||
authority: AuthorityResolver,
|
||||
actor: HexKey,
|
||||
removed: Collection<HexKey>,
|
||||
bit: Int = ConcordPermissions.MANAGE_CHANNELS,
|
||||
): Boolean {
|
||||
if (authority.isOwner(actor)) return true
|
||||
if (!authority.hasPermission(actor, bit)) return false
|
||||
return removed.all { it.equals(actor, ignoreCase = true) || authority.canActOn(actor, it, bit) }
|
||||
}
|
||||
|
||||
/**
|
||||
* The members a rotation of [channelIdHex] keeps (Armada `handleRotateChannelKey`): exactly
|
||||
* those entitled today ([ConcordInviteVend.entitledMembers]) plus the [rotator], who must keep
|
||||
* every key or nobody could rotate the channel next time.
|
||||
*/
|
||||
fun keepSet(
|
||||
authority: AuthorityResolver,
|
||||
channelIdHex: HexKey,
|
||||
rotator: HexKey,
|
||||
): Set<HexKey> = ConcordInviteVend.entitledMembers(authority, channelIdHex) + rotator.lowercase()
|
||||
|
||||
/**
|
||||
* The wraps of one rotation of [held] to [newKey] for [keep], sealed under [sealingRoot] (the
|
||||
* current root for a single-channel Rekey, the PRIOR root inside a Refounding — CORD-06 §3), and
|
||||
* citing [authority] on every chunk.
|
||||
*/
|
||||
suspend fun buildRotation(
|
||||
rotator: NostrSigner,
|
||||
sealingRoot: ByteArray,
|
||||
held: PrivateChannelKey,
|
||||
newKey: ByteArray,
|
||||
keep: Collection<HexKey>,
|
||||
createdAt: Long,
|
||||
authority: AuthorityCitation?,
|
||||
): List<Event> =
|
||||
ConcordChannelRekey.build(
|
||||
rotatorSigner = rotator,
|
||||
sealingRoot = sealingRoot,
|
||||
channelId = held.channelId.hexToByteArray(),
|
||||
heldKey = held.key.hexToByteArray(),
|
||||
heldEpoch = held.epoch,
|
||||
newKey = newKey,
|
||||
recipients = keep + rotator.pubKey,
|
||||
createdAt = createdAt,
|
||||
authority = authority,
|
||||
)
|
||||
|
||||
/** The roots a member watches channel rekeys under: the current one and the canonical prior one (CORD-06 §3). */
|
||||
fun watchRoots(entry: ConcordCommunityListEntry): List<ByteArray> {
|
||||
val prior = ConcordRefounding.canonicalHeldRoots(entry.heldRoots).filter { it.epoch == entry.rootEpoch - 1 }
|
||||
return (listOf(entry.root) + prior.map { it.key }).distinct().map { it.hexToByteArray() }
|
||||
}
|
||||
|
||||
/**
|
||||
* Every channel-rekey address this entry should watch (CORD-06 §2): per held Private Channel,
|
||||
* the next [ConcordChannelRekey.LOOKAHEAD] channel epochs past the held one, under each of
|
||||
* [watchRoots]. Address hex → key.
|
||||
*/
|
||||
fun watchKeys(entry: ConcordCommunityListEntry): Map<HexKey, GroupKey> {
|
||||
val out = LinkedHashMap<HexKey, GroupKey>()
|
||||
val roots = watchRoots(entry)
|
||||
for (held in entry.privateChannels) {
|
||||
if (ConcordChannelKeyring.heldKey(entry, held.channelId) == null) continue
|
||||
val channelId = held.channelId.hexToByteArray()
|
||||
for (root in roots) {
|
||||
for (ahead in 1..ConcordChannelRekey.LOOKAHEAD) {
|
||||
val key = ConcordChannelRekey.address(root, channelId, held.epoch + ahead)
|
||||
out[key.publicKeyHex] = key
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a received channel rotation's Rotator may be honored (CORD-06 §3 Authority): the owner,
|
||||
* or a non-banned holder of `MANAGE_CHANNELS` (a single-channel Rekey) or `BAN` (a Refounding's
|
||||
* channel rekeys), whose `vac` cites a Grant our fold has synced. Key possession is never
|
||||
* authority.
|
||||
*/
|
||||
fun isHonoredRotation(
|
||||
entry: ConcordCommunityListEntry,
|
||||
editions: Collection<ControlEdition>,
|
||||
authority: AuthorityResolver,
|
||||
rotation: ChannelRotation,
|
||||
): Boolean {
|
||||
val rotator = rotation.rotator
|
||||
if (!authority.isOwner(rotator)) {
|
||||
if (authority.isBanned(rotator)) return false
|
||||
if (!authority.hasPermission(rotator, ConcordPermissions.MANAGE_CHANNELS) && !authority.hasPermission(rotator, ConcordPermissions.BAN)) return false
|
||||
}
|
||||
val heads = ConcordRotationAuthority.headsOf(editions, entry.id, entry.owner)
|
||||
return ConcordRotationAuthority.citationSatisfied(entry.id, rotator, entry.owner, rotation.authority, heads)
|
||||
}
|
||||
|
||||
/** Whether [rotator] strictly outranks [me] — only such a Rotator's omission is a cut (CORD-06 §3). */
|
||||
fun outranks(
|
||||
authority: AuthorityResolver,
|
||||
rotator: HexKey,
|
||||
me: HexKey,
|
||||
): Boolean {
|
||||
if (authority.isOwner(me)) return false
|
||||
val theirs = authority.rank(rotator) ?: return false
|
||||
val mine = authority.rank(me) ?: Long.MAX_VALUE
|
||||
return theirs < mine
|
||||
}
|
||||
|
||||
/**
|
||||
* What the buffered channel-rekey [wraps] mean for each Private Channel [entry] holds a key for
|
||||
* (CORD-06 §2), per channel id: an adoption moves that channel's key forward, a cut drops it and
|
||||
* records `channel_cuts`. Channels with nothing to do are omitted. The caller applies the result
|
||||
* inside its List write ([applyOutcome]).
|
||||
*/
|
||||
suspend fun receive(
|
||||
entry: ConcordCommunityListEntry,
|
||||
wraps: Collection<Event>,
|
||||
editions: Collection<ControlEdition>,
|
||||
authority: AuthorityResolver,
|
||||
recipient: NostrSigner,
|
||||
): Map<HexKey, ChannelRekeyOutcome> {
|
||||
if (wraps.isEmpty()) return emptyMap()
|
||||
val keys = watchKeys(entry)
|
||||
val me = recipient.pubKey
|
||||
val joinedAtSecs = entry.addedAt / 1000
|
||||
val out = LinkedHashMap<HexKey, ChannelRekeyOutcome>()
|
||||
for (held in entry.privateChannels) {
|
||||
if (ConcordChannelKeyring.heldKey(entry, held.channelId) == null) continue
|
||||
val id = held.channelId.lowercase()
|
||||
val rotations = ConcordChannelRekey.rotations(wraps, keys, id)
|
||||
if (rotations.isEmpty()) continue
|
||||
val outcome =
|
||||
ConcordChannelRekey.walk(
|
||||
rotations = rotations,
|
||||
channelIdHex = id,
|
||||
heldKey = held.key.hexToByteArray(),
|
||||
heldEpoch = held.epoch,
|
||||
recipientSigner = recipient,
|
||||
joinedAtSecs = joinedAtSecs,
|
||||
honored = { isHonoredRotation(entry, editions, authority, it) },
|
||||
outranksMe = { outranks(authority, it, me) },
|
||||
)
|
||||
if (outcome !is ChannelRekeyOutcome.None) out[id] = outcome
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/**
|
||||
* [current] with [outcomes] applied — only where the channel is still at the epoch the outcome
|
||||
* was computed from ([fromEpochs]), so a write racing another adoption never rolls a key back —
|
||||
* or null when nothing changed.
|
||||
*/
|
||||
fun applyOutcome(
|
||||
current: ConcordCommunityListEntry,
|
||||
outcomes: Map<HexKey, ChannelRekeyOutcome>,
|
||||
fromEpochs: Map<HexKey, Long>,
|
||||
): ConcordCommunityListEntry? {
|
||||
var next = current
|
||||
for ((id, outcome) in outcomes) {
|
||||
val held = ConcordChannelKeyring.heldKey(next, id) ?: continue
|
||||
if (held.epoch != fromEpochs[id]) continue
|
||||
next =
|
||||
when (outcome) {
|
||||
is ChannelRekeyOutcome.Adopted -> ConcordChannelKeyring.withRotatedKey(next, id, outcome.key.toHexKey(), outcome.epoch) ?: next
|
||||
is ChannelRekeyOutcome.Removed -> ConcordChannelKeyring.withoutChannel(next, id, outcome.epoch)
|
||||
ChannelRekeyOutcome.None -> next
|
||||
}
|
||||
}
|
||||
return if (next === current) null else next
|
||||
}
|
||||
}
|
||||
+6
-2
@@ -96,7 +96,8 @@ object ConcordSubscriptionPlanner {
|
||||
* The off-channel planes every joined community subscribes to upfront (known
|
||||
* from the entry alone): the Guestbook Plane (membership motions) and the
|
||||
* next-epoch base-rekey address (so an inbound Refounding is received live,
|
||||
* CORD-06), and the dissolution tombstone address (CORD-02 §9). All are kind-1059
|
||||
* CORD-06), the dissolution tombstone address (CORD-02 §9), and every held Private Channel's
|
||||
* next channel-rekey addresses (CORD-06 §2). All are kind-1059
|
||||
* wraps authored by their derived stream address.
|
||||
*/
|
||||
fun auxiliaryPlaneSubs(entries: List<ConcordCommunityListEntry>): List<ConcordPlaneSub> =
|
||||
@@ -114,7 +115,10 @@ object ConcordSubscriptionPlanner {
|
||||
ConcordPlaneSub(channelId = null, pubKeyHex = dissolved.publicKeyHex, relays = relays),
|
||||
// The current epoch's own rekey address, so a racing sibling can heal us (CORD-06 §3).
|
||||
sibling?.let { ConcordPlaneSub(channelId = null, pubKeyHex = it.publicKeyHex, relays = relays) },
|
||||
)
|
||||
) +
|
||||
// Each held Private Channel's next channel-rekey addresses (CORD-06 §2), so a rotation
|
||||
// that moves the key forward — or cuts us — is received live.
|
||||
ConcordPrivateChannels.watchKeys(e).keys.map { ConcordPlaneSub(channelId = null, pubKeyHex = it, relays = relays) }
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -189,6 +189,7 @@ import com.vitorpamplona.quartz.buzz.threading.buzzThread
|
||||
import com.vitorpamplona.quartz.buzz.threading.buzzThreadRootForReplyTo
|
||||
import com.vitorpamplona.quartz.concord.cord03Channels.ChannelChat
|
||||
import com.vitorpamplona.quartz.concord.cord03Channels.ConcordChannelId
|
||||
import com.vitorpamplona.quartz.concord.cord03Channels.ConcordChatEditEvent
|
||||
import com.vitorpamplona.quartz.concord.cord03Channels.ConcordDisappearing
|
||||
import com.vitorpamplona.quartz.concord.cord03Channels.ConcordTimerNoticeEvent
|
||||
import com.vitorpamplona.quartz.experimental.bounties.BountyAddValueEvent
|
||||
@@ -359,6 +360,7 @@ import com.vitorpamplona.quartz.utils.containsAny
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.DelicateCoroutinesApi
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.FlowPreview
|
||||
import kotlinx.coroutines.IO
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
@@ -366,7 +368,10 @@ import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import kotlinx.coroutines.flow.debounce
|
||||
import kotlinx.coroutines.flow.filter
|
||||
import kotlinx.coroutines.flow.flowOn
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.merge
|
||||
import kotlinx.coroutines.flow.sample
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
@@ -377,6 +382,9 @@ import kotlin.coroutines.cancellation.CancellationException
|
||||
import com.vitorpamplona.quartz.experimental.nip95.header.thumbhash as nip95thumbhash
|
||||
import com.vitorpamplona.quartz.experimental.profileGallery.thumbhash as galleryThumbhash
|
||||
|
||||
/** How long past a disappearing message's deadline the sweep waits, to purge nearby deadlines in one pass (CORD-08). */
|
||||
private const val CONCORD_EXPIRY_COALESCE_MS = 2_000L
|
||||
|
||||
@OptIn(DelicateCoroutinesApi::class)
|
||||
@Stable
|
||||
class Account(
|
||||
@@ -759,6 +767,8 @@ class Account(
|
||||
val expired = concordSessions.sweepExpired(now)
|
||||
for (rumors in expired.values) {
|
||||
for (gone in rumors) {
|
||||
// Its attachments' decryption keys go with it: a cached key would keep the blob readable.
|
||||
gone.attachmentUrls.forEach { encryptionKeyCache.remove(it) }
|
||||
cache.getNoteIfExists(gone.rumorId)?.let { note ->
|
||||
note.detachFromChildren()
|
||||
cache.pruner.unlinkAndRemove(note)
|
||||
@@ -799,7 +809,7 @@ class Account(
|
||||
* decrypts the blob transparently on fetch (keyed by URL) — the same path NIP-17 encrypted media
|
||||
* uses. Runs for both inbound wraps and our own local echo, so a sent image renders immediately.
|
||||
*/
|
||||
private fun registerConcordEncryptedImages(rumor: Event) {
|
||||
internal fun registerConcordEncryptedImages(rumor: Event) {
|
||||
val images = ChannelChat.encryptedImagesOf(rumor)
|
||||
if (images.isEmpty()) return
|
||||
images.forEach { img ->
|
||||
@@ -4184,19 +4194,44 @@ class Account(
|
||||
// A promotion to staff delivers the Control Plane write key inside the Grant
|
||||
// itself (CORD-04 §3), so the fold that seats the role is also when it arrives.
|
||||
runCatching { concord.drainConcordStaffGrants() }.onFailure { Log.w("Concord", "staff grant drain failed", it) }
|
||||
// A Private Channel rotation lands on its channel-rekey address (CORD-06 §2): adopt the new
|
||||
// key, or drop the channel when it cut us.
|
||||
runCatching { concord.drainConcordChannelRekeys() }.onFailure { Log.w("Concord", "channel rekey drain failed", it) }
|
||||
// A Grant folding late turns a parked catch-up invite into one we adopt without a click.
|
||||
runCatching { concord.drainConcordCatchUps() }.onFailure { Log.w("Concord", "catch-up drain failed", it) }
|
||||
// An honored Kick naming us (CORD-04 §6): leave the community locally and say so.
|
||||
runCatching { concord.drainConcordKicks() }.onFailure { Log.w("Concord", "kick drain failed", it) }
|
||||
// A rotation we were *excluded* from produces no rekey to drain, so it can only be
|
||||
// found by re-resolving the invite link we joined through. Rate-limited internally.
|
||||
runCatching { concord.recoverStrandedConcordCommunities() }.onFailure { Log.w("Concord", "stranded recovery failed", it) }
|
||||
}
|
||||
}
|
||||
|
||||
// CORD-04 §7: a PIN_MESSAGES holder owes keyless readers the deletion omission and the Edit
|
||||
// refresh whether or not the channel is open, so the delayed pin duties run from the account —
|
||||
// on every structural tick (a new Pin List head, a fold) and whenever a delete or an Edit lands.
|
||||
// The scheduler itself waits 3–15 s, keeps one duty per channel and one attempt per debt.
|
||||
scope.launch {
|
||||
@OptIn(FlowPreview::class)
|
||||
merge(
|
||||
concordSessions.revision.map { },
|
||||
cache.live.newEventBundles
|
||||
.filter { notes -> notes.any { it.event is DeletionRequestEvent || it.event is ConcordChatEditEvent } }
|
||||
.map { },
|
||||
).sample(1000).collect {
|
||||
runCatching { concord.scheduleConcordPinDuties(scope) }.onFailure { Log.w("Concord", "pin duty scheduling failed", it) }
|
||||
}
|
||||
}
|
||||
|
||||
// CORD-08 §3: purge disappearing Concord messages when they expire. Sleeps until the earliest
|
||||
// deadline any joined community holds and never wakes while nothing carries one, so a
|
||||
// community without a timer costs nothing. A new earlier deadline restarts the wait.
|
||||
scope.launch(Dispatchers.IO) {
|
||||
concordSessions.nextExpiry.collectLatest { at ->
|
||||
if (at == null) return@collectLatest
|
||||
val waitMs = (at - TimeUtils.now()) * 1000
|
||||
// Coalesced: sleep a little past the deadline and sweep everything due by then, so a
|
||||
// burst of messages sent seconds apart expires in one pass instead of one sweep each.
|
||||
val waitMs = (at - TimeUtils.now()) * 1000 + CONCORD_EXPIRY_COALESCE_MS
|
||||
if (waitMs > 0) delay(waitMs)
|
||||
runCatching { sweepExpiredConcordMessages() }.onFailure { Log.w("Concord", "expired-message sweep failed", it) }
|
||||
}
|
||||
|
||||
+651
-30
@@ -27,9 +27,12 @@ import com.vitorpamplona.amethyst.commons.actions.ConcordModeration
|
||||
import com.vitorpamplona.amethyst.commons.actions.ConcordPinContext
|
||||
import com.vitorpamplona.amethyst.commons.actions.ConcordPinOutcome
|
||||
import com.vitorpamplona.amethyst.commons.actions.ConcordPinWrite
|
||||
import com.vitorpamplona.amethyst.commons.actions.ConcordPinnedMessage
|
||||
import com.vitorpamplona.amethyst.commons.actions.ConcordPinning
|
||||
import com.vitorpamplona.amethyst.commons.actions.ConcordPrivateChannels
|
||||
import com.vitorpamplona.amethyst.commons.actions.ConcordReceive
|
||||
import com.vitorpamplona.amethyst.commons.actions.ConcordSubscriptionPlanner
|
||||
import com.vitorpamplona.amethyst.commons.actions.toRumor
|
||||
import com.vitorpamplona.amethyst.commons.defaults.DefaultDmIndexerRelays
|
||||
import com.vitorpamplona.amethyst.commons.model.ConcordInviteResult
|
||||
import com.vitorpamplona.amethyst.commons.model.Note
|
||||
@@ -39,6 +42,8 @@ import com.vitorpamplona.amethyst.commons.model.concord.ConcordChannelListState
|
||||
import com.vitorpamplona.amethyst.commons.model.concord.ConcordCommunitySession
|
||||
import com.vitorpamplona.amethyst.commons.model.concord.ConcordDirectInviteInbox
|
||||
import com.vitorpamplona.amethyst.commons.model.concord.ConcordDirectInviteView
|
||||
import com.vitorpamplona.amethyst.commons.model.concord.ConcordKickNotice
|
||||
import com.vitorpamplona.amethyst.commons.model.concord.ConcordPinDutyScheduler
|
||||
import com.vitorpamplona.amethyst.commons.model.concord.DirectInviteAcceptPlan
|
||||
import com.vitorpamplona.amethyst.commons.model.concordChannelLastReadRoute
|
||||
import com.vitorpamplona.amethyst.commons.util.ConcurrentSet
|
||||
@@ -53,12 +58,17 @@ import com.vitorpamplona.quartz.concord.cord02Community.ConcordListTooLargeExcep
|
||||
import com.vitorpamplona.quartz.concord.cord02Community.HeldRoot
|
||||
import com.vitorpamplona.quartz.concord.cord02Community.ImagePointer
|
||||
import com.vitorpamplona.quartz.concord.cord03Channels.ChannelChat
|
||||
import com.vitorpamplona.quartz.concord.cord03Channels.ConcordChannelKeyring
|
||||
import com.vitorpamplona.quartz.concord.cord03Channels.ConcordChatEditEvent
|
||||
import com.vitorpamplona.quartz.concord.cord03Channels.ConcordDisappearing
|
||||
import com.vitorpamplona.quartz.concord.cord03Channels.concordEpoch
|
||||
import com.vitorpamplona.quartz.concord.cord04Roles.AuthorityCitation
|
||||
import com.vitorpamplona.quartz.concord.cord04Roles.AuthorityCitations
|
||||
import com.vitorpamplona.quartz.concord.cord04Roles.AuthorityResolver
|
||||
import com.vitorpamplona.quartz.concord.cord04Roles.ChannelEntity
|
||||
import com.vitorpamplona.quartz.concord.cord04Roles.ConcordLimits
|
||||
import com.vitorpamplona.quartz.concord.cord04Roles.ConcordPermissions
|
||||
import com.vitorpamplona.quartz.concord.cord04Roles.ControlEdition
|
||||
import com.vitorpamplona.quartz.concord.cord04Roles.MetadataEntity
|
||||
import com.vitorpamplona.quartz.concord.cord04Roles.RoleEntity
|
||||
import com.vitorpamplona.quartz.concord.cord05Invites.CommunityInvite
|
||||
@@ -68,8 +78,11 @@ import com.vitorpamplona.quartz.concord.cord05Invites.ConcordInviteListEntry
|
||||
import com.vitorpamplona.quartz.concord.cord05Invites.ConcordInviteListEvent
|
||||
import com.vitorpamplona.quartz.concord.cord05Invites.ConcordInviteListTombstone
|
||||
import com.vitorpamplona.quartz.concord.cord05Invites.ConcordInviteRegistry
|
||||
import com.vitorpamplona.quartz.concord.cord05Invites.ConcordInviteVend
|
||||
import com.vitorpamplona.quartz.concord.cord05Invites.InviteBundleStatus
|
||||
import com.vitorpamplona.quartz.concord.cord05Invites.InviteRelayDictionary
|
||||
import com.vitorpamplona.quartz.concord.cord06Rekey.ChannelRekeyOutcome
|
||||
import com.vitorpamplona.quartz.concord.cord06Rekey.ConcordChannelRekey
|
||||
import com.vitorpamplona.quartz.concord.cord06Rekey.ConcordRefounding
|
||||
import com.vitorpamplona.quartz.concord.cord06Rekey.IncompleteControlPlaneException
|
||||
import com.vitorpamplona.quartz.concord.cord06Rekey.PendingRefounding
|
||||
@@ -99,19 +112,26 @@ import com.vitorpamplona.quartz.utils.Log
|
||||
import com.vitorpamplona.quartz.utils.RandomInstance
|
||||
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||
import com.vitorpamplona.quartz.utils.concurrent.ConcurrentMap
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.awaitAll
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharedFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asSharedFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.Semaphore
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.coroutines.sync.withPermit
|
||||
import kotlinx.coroutines.withTimeoutOrNull
|
||||
|
||||
/** Name of the default Concord community Admin role minted by "Make admin". */
|
||||
@@ -128,6 +148,9 @@ private const val SESSION_WAIT_MS = 10_000L
|
||||
*/
|
||||
private const val RECOVERY_CHECK_INTERVAL_MS = 15 * 60 * 1000L
|
||||
|
||||
/** How many times a Pin List write re-applies itself on top of a concurrent edition that won the fold. */
|
||||
private const val PIN_REHEAL_RETRIES = 2
|
||||
|
||||
/**
|
||||
* How many recipients one Refounding will re-key. See `AccountConcordActions.boundRecipients`.
|
||||
*
|
||||
@@ -137,6 +160,9 @@ private const val RECOVERY_CHECK_INTERVAL_MS = 15 * 60 * 1000L
|
||||
*/
|
||||
private const val MAX_REFOUNDING_RECIPIENTS = 5_000
|
||||
|
||||
// How many channel epochs a privatisation probes for earlier rotations (Armada MAX_PROBED_CHANNEL_EPOCH).
|
||||
private const val MAX_PROBED_CHANNEL_EPOCH = 32L
|
||||
|
||||
/** A lowercase 32-byte hex key (the Guestbook `invite` tag's creator). */
|
||||
private val HEX64 = Regex("^[0-9a-f]{64}$")
|
||||
|
||||
@@ -234,6 +260,21 @@ class AccountConcordActions(
|
||||
/** Adds or replaces [entry] in the Community List; false when it could not be written. */
|
||||
private suspend fun persistConcordEntry(entry: ConcordCommunityListEntry): Boolean = writeConcordList { it.follow(entry) }
|
||||
|
||||
/**
|
||||
* Rewrites the held entry for [communityId] through [transform], applied to the entry **as the
|
||||
* List holds it inside the write** ([ConcordChannelListState.update]) — never to a snapshot read
|
||||
* before a suspension, which a concurrent rekey or import could have moved on. True only when
|
||||
* [transform] produced a change and it was written.
|
||||
*/
|
||||
private suspend fun updateConcordEntry(
|
||||
communityId: String,
|
||||
transform: (ConcordCommunityListEntry) -> ConcordCommunityListEntry?,
|
||||
): Boolean {
|
||||
var changed = false
|
||||
val ok = writeConcordList { list -> list.update(communityId) { cur -> transform(cur)?.also { changed = true } } }
|
||||
return ok && changed
|
||||
}
|
||||
|
||||
/** Publishes a Guestbook JOIN (kind 3306) for [entry] to its community relays. */
|
||||
private suspend fun announceConcordGuestbookJoin(
|
||||
entry: ConcordCommunityListEntry,
|
||||
@@ -399,24 +440,30 @@ class AccountConcordActions(
|
||||
retired: List<HexKey> = emptyList(),
|
||||
): Boolean {
|
||||
val session = account.concordSessions.sessionFor(entry.id) ?: return false
|
||||
// Only from a drained fold: `published` is read off our honored head, and a partial fold
|
||||
// would read "no registry" and chain a fresh v1 over the real one (dropped by every reader).
|
||||
val state = session.foldForWrite() ?: return false
|
||||
// A dissolved community has no future (CORD-02 §9): no registry edit can change anything.
|
||||
if (state.dissolved) return false
|
||||
if (!isAuthorizedFor(session, ConcordPermissions.CREATE_INVITE)) return false
|
||||
val cp = controlKeysForWrite(session) ?: return false
|
||||
val me = account.signer.pubKey
|
||||
val state = session.state.value
|
||||
val published = state?.registryOf(me).orEmpty()
|
||||
val published = state.registryOf(me)
|
||||
val next = ConcordInviteRegistry.nextLinks(published, list, entry.id, TimeUtils.now(), minted, retired)
|
||||
val hasHead = state?.inviteRegistries?.containsKey(me.lowercase()) == true
|
||||
val hasHead = state.inviteRegistries.containsKey(me.lowercase())
|
||||
if (next == published.sorted() && (hasHead || next.isEmpty())) return false
|
||||
// The writer chains off the same authorized head the fold honors (ConcordModeration.headOf).
|
||||
// The writer chains off the same authorized, floor-aware head the fold honors.
|
||||
val wrap =
|
||||
try {
|
||||
ConcordModeration.setInviteRegistry(account.signer, cp, entry.id.hexToByteArray(), next, session.controlEditions(), TimeUtils.now(), owner = entry.owner)
|
||||
ConcordModeration.setInviteRegistry(account.signer, cp, entry.id.hexToByteArray(), next, session.controlEditions(), TimeUtils.now(), owner = entry.owner, floors = session.controlFloors())
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
Log.w("Concord", "invite registry build failed for ${entry.id}", e)
|
||||
return false
|
||||
}
|
||||
publishConcordWrap(entry, wrap)
|
||||
return true
|
||||
// Confirmed: the registry is the community's Public/Private source of truth (CORD-05 §5).
|
||||
return publishConcordWrapConfirmed(entry, wrap)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -844,15 +891,41 @@ class AccountConcordActions(
|
||||
* The Direct Invite inbox: wraps from the dedicated sweep ([refreshConcordDirectInvites]) and
|
||||
* from the NIP-17 giftwrap pipeline land here, parked until the user accepts or declines.
|
||||
*/
|
||||
val directInviteInbox = ConcordDirectInviteInbox(account.signer)
|
||||
val directInviteInbox =
|
||||
ConcordDirectInviteInbox(
|
||||
account.signer,
|
||||
// Muted/blocked senders never park; followed ones outrank strangers when the inbox is full.
|
||||
isHidden = { account.isHidden(it) },
|
||||
isFollowed = { it in account.followingKeySet() },
|
||||
)
|
||||
|
||||
/**
|
||||
* The parked Direct Invites a UI should show, newest first: invites for communities we don't
|
||||
* hold, plus catch-ups for ones we do ([ConcordDirectInviteInbox.visible]).
|
||||
* The parked Direct Invites a UI should show, followed senders first, then newest: invites for
|
||||
* communities we don't hold (nor left after they were sent), plus catch-ups for ones we do
|
||||
* ([ConcordDirectInviteInbox.visible]).
|
||||
*/
|
||||
val pendingConcordDirectInvites: StateFlow<List<ConcordDirectInviteView>> =
|
||||
combine(directInviteInbox.pending, account.concordChannelList.liveCommunities) { pending, joined ->
|
||||
ConcordDirectInviteInbox.visible(pending.values, joined)
|
||||
combine(
|
||||
directInviteInbox.pending,
|
||||
account.concordChannelList.liveCommunities,
|
||||
account.concordChannelList.removedAt,
|
||||
account.hiddenUsers.flow,
|
||||
// A fold landing can make a parked catch-up admissible or not (the sender's standing).
|
||||
combine(account.kind3FollowList.flow, account.concordSessions.revision) { follows, _ -> follows },
|
||||
) { pending, joined, removedAt, _, follows ->
|
||||
ConcordDirectInviteInbox.visible(
|
||||
pending.values,
|
||||
joined,
|
||||
removedAt = removedAt,
|
||||
isFollowed = { it in follows.authors },
|
||||
isHidden = { account.isHidden(it) },
|
||||
heldStateOf = { id ->
|
||||
account.concordSessions
|
||||
.sessionFor(id)
|
||||
?.state
|
||||
?.value
|
||||
},
|
||||
)
|
||||
}.stateIn(account.scope, SharingStarted.WhileSubscribed(5_000), emptyList())
|
||||
|
||||
/**
|
||||
@@ -878,9 +951,42 @@ class AccountConcordActions(
|
||||
val filter = ConcordActions.directInvitesFilter(account.signer.pubKey, directInviteInbox.since())
|
||||
val wraps = account.client.fetchAll(filters = relays.associateWith { listOf(filter) })
|
||||
wraps.distinctBy { it.id }.forEach { directInviteInbox.offer(it) }
|
||||
drainConcordCatchUps()
|
||||
return (directInviteInbox.pending.value.keys - before).size
|
||||
}
|
||||
|
||||
/**
|
||||
* Adopts, without a click, every parked catch-up the held fold says is exactly the delivery a
|
||||
* Grant prescribes (Armada `judgeCatchUp`, [ConcordInviteVend.judgeCatchUp]): a staff sender, a
|
||||
* recipient who isn't banned, and only live Private Channels our Roles entitle us to. Consent
|
||||
* came from the Grant. Anything else waits for a manual Accept. Runs after an inbox sweep and on
|
||||
* the revision tick (a Grant folding late turns a waiting catch-up adoptable).
|
||||
*/
|
||||
internal suspend fun drainConcordCatchUps() {
|
||||
if (!account.isWriteable()) return
|
||||
val me = account.signer.pubKey
|
||||
val now = TimeUtils.nowMillis()
|
||||
for (opened in directInviteInbox.pending.value.values) {
|
||||
if (opened.isExpired(now)) continue
|
||||
val held =
|
||||
account.concordChannelList.liveCommunities.value
|
||||
.firstOrNull { it.id.equals(opened.invite.communityId, ignoreCase = true) } ?: continue
|
||||
val state =
|
||||
account.concordSessions
|
||||
.sessionFor(held.id)
|
||||
?.state
|
||||
?.value ?: continue
|
||||
if (state.dissolved) continue
|
||||
val verdict = ConcordInviteVend.judgeCatchUp(state.authority, state.privateChannelIds, me, opened.sender, opened.invite, held)
|
||||
if (verdict != ConcordInviteVend.CatchUpVerdict.ADOPT) continue
|
||||
val ids = ConcordInviteVend.catchUpChannelIds(held, opened.invite)
|
||||
if (updateConcordEntry(held.id) { cur -> ConcordInviteVend.adoptCatchUp(cur, opened.invite, ids) }) {
|
||||
Log.i("Concord") { "Adopted a granted Private Channel key for ${held.id} from ${opened.sender}" }
|
||||
directInviteInbox.resolve(opened.wrapId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The recipient's giftwrap inbox (CORD-05 §6): their kind-10050 DM relays, else NIP-65 read
|
||||
* relays — from the cache when we have their lists, fetched otherwise — else the stock set.
|
||||
@@ -916,6 +1022,7 @@ class AccountConcordActions(
|
||||
communityId: String,
|
||||
recipientPubKey: HexKey,
|
||||
expiresAtMs: Long? = null,
|
||||
onlyChannelIds: Set<HexKey>? = null,
|
||||
): ConcordDirectInviteSendResult {
|
||||
if (!account.isWriteable()) return ConcordDirectInviteSendResult.NOT_WRITEABLE
|
||||
val recipient = recipientPubKey.lowercase()
|
||||
@@ -929,7 +1036,7 @@ class AccountConcordActions(
|
||||
?.state
|
||||
?.value ?: return ConcordDirectInviteSendResult.ROSTER_NOT_LOADED
|
||||
val invite =
|
||||
when (val draft = ConcordActions.draftDirectInvite(entry, state, account.signer.pubKey, recipient, expiresAtMs)) {
|
||||
when (val draft = ConcordActions.draftDirectInvite(entry, state, account.signer.pubKey, recipient, expiresAtMs, onlyChannelIds)) {
|
||||
is ConcordDirectInviteDraft.Refused -> return draft.reason
|
||||
is ConcordDirectInviteDraft.Ready -> draft.invite
|
||||
}
|
||||
@@ -971,9 +1078,12 @@ class AccountConcordActions(
|
||||
// No folded roster yet: whether it bans us is unknown, so the invite waits.
|
||||
DirectInviteAcceptPlan.RosterNotLoaded -> ConcordInviteResult.NotReachable
|
||||
DirectInviteAcceptPlan.NothingNew -> ConcordInviteResult.Joined(bundle.communityId)
|
||||
// Keys only, on the held base: no second Guestbook Join.
|
||||
is DirectInviteAcceptPlan.CatchUp ->
|
||||
if (persistConcordEntry(plan.entry)) ConcordInviteResult.Joined(bundle.communityId) else ConcordInviteResult.NotReachable
|
||||
// Keys only, on the held base: no second Guestbook Join. Re-applied to the entry the
|
||||
// List holds at write time, so a root imported meanwhile is never written back over.
|
||||
is DirectInviteAcceptPlan.CatchUp -> {
|
||||
val ok = writeConcordList { list -> list.update(plan.entry.id) { cur -> ConcordInviteVend.adoptCatchUp(cur, bundle, plan.channelIds) } }
|
||||
if (ok) ConcordInviteResult.Joined(bundle.communityId) else ConcordInviteResult.NotReachable
|
||||
}
|
||||
DirectInviteAcceptPlan.Join ->
|
||||
joinValidatedConcordInvite(
|
||||
bundle = bundle,
|
||||
@@ -1140,7 +1250,8 @@ class AccountConcordActions(
|
||||
.findEmojiTags(newText)
|
||||
.map { it.toTagArray() }
|
||||
.toTypedArray()
|
||||
val wrap = ConcordActions.buildChannelEdit(account.signer, channelKey, channelIdHex, plane.epoch, target, newText, TimeUtils.now(), emojiTags, session.messageExpirationSecs())
|
||||
// CORD-08 §2: the Edit keeps the ORIGINAL message's deadline (none if it has none), never now + timer.
|
||||
val wrap = ConcordActions.buildChannelEdit(account.signer, channelKey, channelIdHex, plane.epoch, target, newText, TimeUtils.now(), emojiTags, expiration = ConcordDisappearing.expirationOf(target))
|
||||
publishConcordWrap(entry, wrap)
|
||||
return true
|
||||
}
|
||||
@@ -1246,6 +1357,31 @@ class AccountConcordActions(
|
||||
if (relays.isNotEmpty()) account.client.publish(wrap, relays)
|
||||
}
|
||||
|
||||
/**
|
||||
* [publishConcordWrap] for a Control Plane edition whose caller must know it landed: waits for a
|
||||
* relay OK (`publish` only queues and never reports acceptance) and folds the wrap into the
|
||||
* session only then, so a refused write never shows as done locally. False when no relay of the
|
||||
* community accepted it.
|
||||
*/
|
||||
private suspend fun publishConcordWrapConfirmed(
|
||||
entry: ConcordCommunityListEntry,
|
||||
wrap: Event,
|
||||
): Boolean {
|
||||
val relays = entry.relays.mapNotNullTo(mutableSetOf()) { RelayUrlNormalizer.normalizeOrNull(it) }
|
||||
if (relays.isEmpty()) return false
|
||||
val landed =
|
||||
try {
|
||||
account.client.publishAndConfirm(wrap, relays)
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
Log.w("Concord", "Control edition publish failed for ${entry.id}", e)
|
||||
false
|
||||
}
|
||||
if (landed) account.concordSessions.ingest(wrap)
|
||||
return landed
|
||||
}
|
||||
|
||||
/**
|
||||
* Echo an own Concord channel message into its feed and publish it, driving
|
||||
* the bubble's send state as it goes.
|
||||
@@ -1371,6 +1507,7 @@ class AccountConcordActions(
|
||||
val session = account.concordSessions.sessionFor(communityId) ?: return false
|
||||
if (!account.isWriteable()) return false
|
||||
val cp = controlKeysForAction(session, ConcordPermissions.MANAGE_ROLES, member) ?: return false
|
||||
val before = session.state.value?.authority
|
||||
// A Grant that first makes its member staff must deliver the control_root in the same
|
||||
// edition (CORD-04 §3) — grantWithStaffDelivery attaches the pairwise wrap when the
|
||||
// roles carry a Control-writing bit and we hold the secret to hand over.
|
||||
@@ -1388,6 +1525,8 @@ class AccountConcordActions(
|
||||
epoch = session.entry.rootEpoch,
|
||||
)
|
||||
publishConcordWrap(session.entry, wrap)
|
||||
// Role-gated channel keys follow the Grant: vend what it opened, rotate what it closed.
|
||||
reconcileConcordChannelAccess(communityId, before)
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -1500,8 +1639,10 @@ class AccountConcordActions(
|
||||
val session = account.concordSessions.sessionFor(communityId) ?: return false
|
||||
if (!account.isWriteable()) return false
|
||||
val cp = controlKeysForAction(session, ConcordPermissions.MANAGE_ROLES, member) ?: return false
|
||||
val before = session.state.value?.authority
|
||||
val grantWrap = ConcordModeration.grant(account.signer, cp, communityId.hexToByteArray(), member, emptyList(), session.controlEditions(), TimeUtils.now(), owner = session.entry.owner)
|
||||
publishConcordWrap(session.entry, grantWrap)
|
||||
reconcileConcordChannelAccess(communityId, before)
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -1558,14 +1699,20 @@ class AccountConcordActions(
|
||||
val session = account.concordSessions.sessionFor(communityId) ?: return false
|
||||
if (!account.isWriteable()) return false
|
||||
val cp = controlKeysForAction(session, ConcordPermissions.BAN, member) ?: return false
|
||||
val before = session.state.value?.authority
|
||||
val wrap = ConcordModeration.ban(account.signer, cp, communityId.hexToByteArray(), member, session.controlEditions(), TimeUtils.now(), owner = session.entry.owner)
|
||||
publishConcordWrap(session.entry, wrap)
|
||||
// Judged on the fold that now carries the ban (publishConcordWrap ingests it first).
|
||||
val state = session.state.value
|
||||
if (state != null && state.banRequiresRefounding(listOf(member))) {
|
||||
// The Refounding rotates every held Private Channel to its entitled set (CORD-06 §3).
|
||||
if (!refoundConcordCommunity(communityId, setOf(member))) {
|
||||
Log.w("Concord") { "Banned $member from the Private community $communityId, but its Refounding did not complete" }
|
||||
}
|
||||
} else {
|
||||
// A Public ban keeps the base, so the Private Channels the target could read are cut by
|
||||
// their own rekeys (CORD-04 §6: "a Private-Channel rekey for a channel-scoped cut").
|
||||
reconcileConcordChannelAccess(communityId, before)
|
||||
}
|
||||
return true
|
||||
}
|
||||
@@ -1583,6 +1730,80 @@ class AccountConcordActions(
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Kick [member] (CORD-04 §6, the Cooperative Kick): Role Removal first — an empty Grant when they
|
||||
* hold roles and this account may strip them (MANAGE_ROLES + outrank; best-effort, as the
|
||||
* reference client) — so their rank is gone before the departure lands, *then* the Guestbook
|
||||
* directive (kind 3309) citing our Grant (`vac`). Needs KICK and a strict outrank of the target;
|
||||
* the directive rides the Guestbook, so no Control write key is needed for it. A kicked member
|
||||
* may re-join or be re-invited. Refused on a dissolved community (CORD-02 §9).
|
||||
*/
|
||||
suspend fun kickConcordMember(
|
||||
communityId: String,
|
||||
member: HexKey,
|
||||
): Boolean {
|
||||
val session = account.concordSessions.sessionFor(communityId) ?: return false
|
||||
if (!account.isWriteable()) return false
|
||||
if (!isAuthorizedFor(session, ConcordPermissions.KICK, member)) return false
|
||||
if (session.state.value?.dissolved == true) return false
|
||||
val me = account.signer.pubKey
|
||||
val authority = session.state.value?.authority
|
||||
if (authority != null && authority.rolesOf(member).isNotEmpty() &&
|
||||
(authority.isOwner(me) || authority.canActOn(me, member, ConcordPermissions.MANAGE_ROLES))
|
||||
) {
|
||||
if (!grantConcordRole(communityId, member, emptyList())) {
|
||||
Log.w("Concord") { "Kick of $member in $communityId: could not strip their roles first; kicking anyway" }
|
||||
}
|
||||
}
|
||||
val entry = session.entry
|
||||
val guestbook = ConcordActions.guestbookPlane(entry.root.hexToByteArray(), entry.id.hexToByteArray(), entry.rootEpoch)
|
||||
val citation =
|
||||
session.state.value
|
||||
?.authority
|
||||
?.let { AuthorityCitations.forActor(it, me) }
|
||||
val wrap = ConcordActions.buildGuestbookKick(account.signer, guestbook, member, citation, TimeUtils.now())
|
||||
publishConcordWrap(entry, wrap)
|
||||
return true
|
||||
}
|
||||
|
||||
private val _concordKicks = MutableSharedFlow<ConcordKickNotice>(extraBufferCapacity = 16)
|
||||
|
||||
/** One notice per community this account left because an honored Kick named it ([drainConcordKicks]). */
|
||||
val concordKicks: SharedFlow<ConcordKickNotice> = _concordKicks.asSharedFlow()
|
||||
|
||||
// Rumor ids of the Kicks already complied with, so a revision tick racing the List write acts once.
|
||||
private val handledConcordKicks = ConcurrentSet<HexKey>()
|
||||
|
||||
/**
|
||||
* Compliance with a Kick against this account (CORD-04 §6): a compliant client tears the
|
||||
* Community down locally. For each joined community whose coalesced Guestbook carries an honored
|
||||
* Kick naming us that postdates this membership ([ConcordCommunitySession.kickedMe]), drop the
|
||||
* List entry and tombstone it, exactly as a Leave does, and tell the user
|
||||
* ([concordKicks]). Network-silent beyond the List write (no Guestbook Leave): the Kick already
|
||||
* says we departed. The tombstone never blocks a later re-join — re-adding an entry bumps its
|
||||
* `added_at` past the tombstone, and that newer `added_at` puts this Kick behind the new
|
||||
* membership. Runs on the Concord revision tick; a Guestbook arrival and a control fold (which can
|
||||
* newly honor a parked Kick) both bump it.
|
||||
*/
|
||||
internal suspend fun drainConcordKicks() {
|
||||
if (!account.isWriteable()) return
|
||||
for (session in account.concordSessions.sessions()) {
|
||||
val kick = session.kickedMe() ?: continue
|
||||
if (!handledConcordKicks.add(kick.rumorId)) continue
|
||||
val communityId = session.entry.id
|
||||
val name =
|
||||
session.state.value
|
||||
?.metadata
|
||||
?.name
|
||||
if (leaveConcordCommunity(communityId)) {
|
||||
Log.i("Concord") { "Kicked from $communityId by ${kick.author}: left the community locally (CORD-04 §6)" }
|
||||
_concordKicks.tryEmit(ConcordKickNotice(communityId, name, kick.author))
|
||||
} else {
|
||||
handledConcordKicks.remove(kick.rumorId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Concord pins (CORD-04 §7) ─────────────────────────────────────────────
|
||||
// A Channel's Pin List rides the Control Plane as one replace-entire edition (vsk 11) of
|
||||
// self-proving entries. The read side verifies every entry and applies what this client holds
|
||||
@@ -1625,6 +1846,18 @@ class AccountConcordActions(
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* [pinned] as the rumor the chat feed would render — its newest proven words over the original's
|
||||
* tags (the NIP-92 `imeta` attachments), with the encrypted attachments' keys registered so the
|
||||
* shared media pipeline fetches and decrypts them exactly as for a feed message. A pin proves its
|
||||
* message without this account ever having held it, so the keys come from the proof itself.
|
||||
*/
|
||||
fun concordPinnedRumor(pinned: ConcordPinnedMessage): Event {
|
||||
val rumor = pinned.toRumor()
|
||||
account.registerConcordEncryptedImages(rumor)
|
||||
return rumor
|
||||
}
|
||||
|
||||
/** The author's newest Concord Edit this account holds for [rumorId], or null. */
|
||||
private fun heldConcordEdit(
|
||||
rumorId: HexKey,
|
||||
@@ -1655,6 +1888,11 @@ class AccountConcordActions(
|
||||
/**
|
||||
* Runs one pin write: re-reads the list inside the lock (the previous write was echoed into the
|
||||
* session, so this chains onto it), resolves the context, and publishes the edition [op] builds.
|
||||
*
|
||||
* The publish waits for a relay OK ([ConcordPinOutcome.NOT_CONFIRMED] otherwise). Then, like a
|
||||
* ban, the write re-heals: a concurrent curator's edition at the same version may win the fold
|
||||
* (CORD-04 §1), silently dropping this change, so when the refolded head is not ours the same
|
||||
* [op] runs again on top of the winner — at most [PIN_REHEAL_RETRIES] times.
|
||||
*/
|
||||
private suspend fun writeConcordPins(
|
||||
communityId: String,
|
||||
@@ -1663,6 +1901,7 @@ class AccountConcordActions(
|
||||
): ConcordPinOutcome =
|
||||
concordPinMutex.withLock {
|
||||
if (!account.isWriteable()) return@withLock ConcordPinOutcome.NOT_WRITEABLE
|
||||
repeat(1 + PIN_REHEAL_RETRIES) {
|
||||
val session = account.concordSessions.sessionFor(communityId) ?: return@withLock ConcordPinOutcome.NOT_FOLDED
|
||||
val definition =
|
||||
session.state.value
|
||||
@@ -1684,8 +1923,16 @@ class AccountConcordActions(
|
||||
authorized = holdsConcordPinBit(session),
|
||||
)
|
||||
val write = op(session, ctx)
|
||||
write.wrap?.let { publishConcordWrap(session.entry, it) }
|
||||
write.outcome
|
||||
// A retry that finds the winner already says what we meant (ALREADY_PINNED, NOT_PINNED,
|
||||
// NOTHING_TO_DO) ends here too.
|
||||
val wrap = write.wrap ?: return@withLock write.outcome
|
||||
if (!publishConcordWrapConfirmed(session.entry, wrap)) return@withLock ConcordPinOutcome.NOT_CONFIRMED
|
||||
val ours = ConcordStreamEnvelope.openOrNull(wrap, ctx.controlPlane)?.let { ControlEdition.fromOpened(it) }?.rumorId
|
||||
if (ours == null || session.pinHeads.value[channelIdHex]?.rumorId == ours) return@withLock ConcordPinOutcome.PUBLISHED
|
||||
Log.i("Concord") { "Pin List edit in $communityId lost the fold to a concurrent edition; re-applying on the winner" }
|
||||
}
|
||||
// Landed every time but kept losing the tie-break: it is on the relays, just not the head.
|
||||
ConcordPinOutcome.PUBLISHED
|
||||
}
|
||||
|
||||
/** Pin Concord message [note] into its channel's Pin List, proving it with its original seal. */
|
||||
@@ -1745,6 +1992,30 @@ class AccountConcordActions(
|
||||
}
|
||||
}
|
||||
|
||||
private val concordPinDuties = ConcordPinDutyScheduler()
|
||||
|
||||
/**
|
||||
* Schedules, in [scope], the delayed pin duties (CORD-04 §7) this account owes in every joined
|
||||
* community where it may write pins: for each Channel with a Pin List head whose read owes a
|
||||
* republish, one [settleConcordPins] after the 3–15 s random wait ([ConcordPinDutyScheduler]).
|
||||
* Called by the account on the Concord revision tick and when a delete or an Edit lands — the
|
||||
* duties no longer depend on the channel screen being open.
|
||||
*/
|
||||
internal fun scheduleConcordPinDuties(scope: CoroutineScope) {
|
||||
if (!account.isWriteable()) return
|
||||
for (session in account.concordSessions.sessions()) {
|
||||
val communityId = session.entry.id
|
||||
if (!canPinConcord(communityId)) continue
|
||||
for (channelIdHex in session.pinHeads.value.keys) {
|
||||
val debt = ConcordPinDutyScheduler.debtOf(concordChannelPins(communityId, channelIdHex))
|
||||
concordPinDuties.schedule(scope, ConcordPinDutyScheduler.keyOf(communityId, channelIdHex), debt) {
|
||||
runCatching { settleConcordPins(communityId, channelIdHex) }
|
||||
.onFailure { Log.w("Concord", "pin duty for $channelIdHex in $communityId failed", it) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Concord refounding / rekey (CORD-06) ──────────────────────────────────
|
||||
// A ban is a soft removal — the banned member still holds the room key and can
|
||||
// still decrypt traffic; every client just declines to *show* their posts. A
|
||||
@@ -1921,9 +2192,14 @@ class AccountConcordActions(
|
||||
}
|
||||
if (!compactionLanded) Log.w("Concord") { "Refounding ${entry.id}: some compacted Control Plane heads were not accepted at epoch ${build.newEpoch}" }
|
||||
|
||||
// 5b. Rotate every held Private Channel (CORD-06 §3), each to its OWN entitled set among the
|
||||
// kept members, sealed under the PRIOR root so a base-fork loser can still open it. A
|
||||
// channel that fails to land keeps its key (and is logged): resumable, not atomic.
|
||||
val rotatedEntry = rotatePrivateChannelsForRefounding(entry, recipients.toSet(), priorRoot, citation)
|
||||
|
||||
// 6. Adopt the new epoch ourselves. This rebuilds our session under the new root and
|
||||
// re-folds the compacted Control Plane (with the ban), dropping the removed members.
|
||||
val adopted = adoptConcordRoot(entry, keys.newRoot, build.newEpoch, build.newControlKeys.address.hexToByteArray(), keys.newControlRoot)
|
||||
val adopted = adoptConcordRoot(rotatedEntry, keys.newRoot, build.newEpoch, build.newControlKeys.address.hexToByteArray(), keys.newControlRoot)
|
||||
pendingConcordRefoundings.remove(entry.id)
|
||||
|
||||
// 7. Move every link we minted to the new epoch. Without this the Refounding orphans them,
|
||||
@@ -1935,6 +2211,40 @@ class AccountConcordActions(
|
||||
return compactionLanded
|
||||
}
|
||||
|
||||
/**
|
||||
* The Refounding's channel duty (CORD-06 §3): every held key of a live Private Channel is
|
||||
* rotated to the members still entitled to it ∩ [kept], plus ourselves, sealed under
|
||||
* [priorRoot]. Returns [entry] with each rotated channel moved to its new key (the caller adopts
|
||||
* the new root from it); a channel whose rotation didn't land keeps its old key.
|
||||
*/
|
||||
private suspend fun rotatePrivateChannelsForRefounding(
|
||||
entry: ConcordCommunityListEntry,
|
||||
kept: Set<HexKey>,
|
||||
priorRoot: ByteArray,
|
||||
citation: AuthorityCitation?,
|
||||
): ConcordCommunityListEntry {
|
||||
val session = account.concordSessions.sessionFor(entry.id) ?: return entry
|
||||
val state = session.state.value ?: return entry
|
||||
val me = account.signer.pubKey.lowercase()
|
||||
val publishTo = entry.relays.mapNotNullTo(mutableSetOf()) { RelayUrlNormalizer.normalizeOrNull(it) }
|
||||
val keptLower = kept.mapTo(HashSet()) { it.lowercase() }
|
||||
var next = entry
|
||||
for (held in entry.privateChannels) {
|
||||
val id = held.channelId.lowercase()
|
||||
if (id !in state.privateChannelIds || ConcordChannelKeyring.heldKey(entry, id) == null) continue
|
||||
val keep = ConcordPrivateChannels.keepSet(state.authority, id, me).filterTo(HashSet()) { it in keptLower || it == me }
|
||||
val newKey = ConcordChannelRekey.mintKey()
|
||||
val wraps = ConcordPrivateChannels.buildRotation(account.signer, priorRoot, held, newKey, keep, TimeUtils.now(), citation)
|
||||
val landed = wraps.all { runCatching { account.client.publishAndConfirm(it, publishTo) }.getOrDefault(false) }
|
||||
if (!landed) {
|
||||
Log.w("Concord") { "Refounding ${entry.id}: the rekey of private channel $id did not land; it keeps its key" }
|
||||
continue
|
||||
}
|
||||
next = ConcordChannelKeyring.withRotatedKey(next, id, newKey.toHexKey(), held.epoch + 1) ?: next
|
||||
}
|
||||
return next
|
||||
}
|
||||
|
||||
// Keys reserved for a Refounding in flight, per community (CORD-06 §3): a retry of the same
|
||||
// rotation reuses them. Process-local — a restart mid-rotation mints afresh, which is why the
|
||||
// rekey chunks are all confirmed before anything is adopted.
|
||||
@@ -2265,12 +2575,15 @@ class AccountConcordActions(
|
||||
if (!account.isWriteable()) return false
|
||||
val cp = controlKeysForAction(session, ConcordPermissions.MANAGE_METADATA) ?: return false
|
||||
// Start from the folded metadata so a field this form doesn't edit — the CORD-08 timer, above
|
||||
// all — is carried forward instead of reset (CORD-02 §6 round-trip).
|
||||
val standing = session.state.value?.metadata ?: MetadataEntity()
|
||||
// all — is carried forward instead of reset (CORD-02 §6 round-trip). Only from a drained fold:
|
||||
// minting over a head we were never served would chain a fresh v1 (or a stale version) that
|
||||
// wipes the name, relays and timer — the owner included, who may otherwise act before the fold.
|
||||
val folded = session.foldForWrite() ?: return false
|
||||
val standing = folded.metadata ?: MetadataEntity()
|
||||
val metadata = standing.copy(name = name, icon = icon, banner = banner, description = description, relays = relays)
|
||||
// CORD-02 §6 caps are fold gates too: an edition past them would be dropped by every reader.
|
||||
if (!ConcordLimits.metadataFits(metadata)) return false
|
||||
val wrap = ConcordModeration.editMetadata(account.signer, cp, communityId.hexToByteArray(), metadata, session.controlEditions(), TimeUtils.now(), owner = session.entry.owner)
|
||||
val wrap = ConcordModeration.editMetadata(account.signer, cp, communityId.hexToByteArray(), metadata, session.controlEditions(), TimeUtils.now(), owner = session.entry.owner, floors = session.controlFloors())
|
||||
publishConcordWrap(session.entry, wrap)
|
||||
return true
|
||||
}
|
||||
@@ -2290,9 +2603,11 @@ class AccountConcordActions(
|
||||
if (!account.isWriteable()) return false
|
||||
val cp = controlKeysForAction(session, ConcordPermissions.MANAGE_METADATA) ?: return false
|
||||
val timer = secs?.takeIf { it >= 1 }
|
||||
val standing = session.state.value?.metadata ?: MetadataEntity()
|
||||
// Same rule as editConcordMetadata: never lay the timer over a head we were not served.
|
||||
val folded = session.foldForWrite() ?: return false
|
||||
val standing = folded.metadata ?: MetadataEntity()
|
||||
if (standing.messageExpirationSecs() == timer) return false
|
||||
val wrap = ConcordModeration.setMessageExpiration(account.signer, cp, communityId.hexToByteArray(), standing, timer, session.controlEditions(), TimeUtils.now(), owner = session.entry.owner)
|
||||
val wrap = ConcordModeration.setMessageExpiration(account.signer, cp, communityId.hexToByteArray(), standing, timer, session.controlEditions(), TimeUtils.now(), owner = session.entry.owner, floors = session.controlFloors())
|
||||
publishConcordWrap(session.entry, wrap)
|
||||
postConcordTimerNotices(session, timer ?: 0)
|
||||
return true
|
||||
@@ -2337,10 +2652,13 @@ class AccountConcordActions(
|
||||
suspend fun createConcordChannel(
|
||||
communityId: String,
|
||||
name: String,
|
||||
private: Boolean = false,
|
||||
accessRoleName: String? = null,
|
||||
): Boolean {
|
||||
val session = account.concordSessions.sessionFor(communityId) ?: return false
|
||||
if (!account.isWriteable()) return false
|
||||
val cp = controlKeysForAction(session, ConcordPermissions.MANAGE_CHANNELS) ?: return false
|
||||
if (private) return createPrivateConcordChannel(session, cp, communityId, name, accessRoleName)
|
||||
val channelId = RandomInstance.bytes(32)
|
||||
val channel = ChannelEntity(name = name.trim())
|
||||
// Readers drop an empty or over-64-byte name (CORD-03 §2); never mint one.
|
||||
@@ -2397,6 +2715,244 @@ class AccountConcordActions(
|
||||
return true
|
||||
}
|
||||
|
||||
// ── Private Channels (CORD-03 §1-2, CORD-04 §2, CORD-05 §6, CORD-06 §1-3) ──────────────────
|
||||
// A Private Channel reads on its own independent key. Its access list is the Roles scoped to it:
|
||||
// a Grant that opens one vends the key by Direct Invite, a Grant (or ban) that closes one rotates
|
||||
// it to the members still entitled. The protocol decisions live in ConcordPrivateChannels /
|
||||
// ConcordChannelRekey (shared with `amy`); only the network and the List write are here.
|
||||
|
||||
/**
|
||||
* A new Private Channel (CORD-03 §2): an independent key at channel epoch 0 stored in the List
|
||||
* FIRST (a lost List write would orphan the only copy), then its access Role and the channel
|
||||
* edition. Nobody holds the Role yet; granting it vends the key ([grantConcordRole]).
|
||||
*/
|
||||
private suspend fun createPrivateConcordChannel(
|
||||
session: ConcordCommunitySession,
|
||||
cp: ControlPlaneKeys,
|
||||
communityId: String,
|
||||
name: String,
|
||||
accessRoleName: String?,
|
||||
): Boolean {
|
||||
val build =
|
||||
ConcordPrivateChannels.create(
|
||||
actor = account.signer,
|
||||
cp = cp,
|
||||
communityId = communityId.hexToByteArray(),
|
||||
name = name,
|
||||
accessRoleName = accessRoleName,
|
||||
current = session.controlEditions(),
|
||||
authority = session.state.value?.authority,
|
||||
owner = session.entry.owner,
|
||||
createdAt = TimeUtils.now(),
|
||||
) ?: return false
|
||||
if (!updateConcordEntry(communityId) { cur -> ConcordChannelKeyring.withChannelKey(cur, build.key) }) return false
|
||||
build.wraps.forEach { publishConcordWrap(session.entry, it) }
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts the Public channel [channelIdHex] to Private (CORD-03 §2): a fresh key at the NEXT
|
||||
* channel epoch — floored at the highest channel rotation seen on the wire, since a privatiser
|
||||
* may never have held an earlier generation and a reused epoch is silent and unrecoverable — plus
|
||||
* a new access Role, then the flag. Protects the future only. MANAGE_CHANNELS.
|
||||
*/
|
||||
suspend fun privatizeConcordChannel(
|
||||
communityId: String,
|
||||
channelIdHex: HexKey,
|
||||
accessRoleName: String? = null,
|
||||
): Boolean {
|
||||
val session = account.concordSessions.sessionFor(communityId) ?: return false
|
||||
if (!account.isWriteable()) return false
|
||||
val cp = controlKeysForAction(session, ConcordPermissions.MANAGE_CHANNELS) ?: return false
|
||||
val state = session.state.value ?: return false
|
||||
val standing = state.channels[channelIdHex]?.definition ?: return false
|
||||
if (standing.private) return false
|
||||
val floor = observedChannelEpochFloor(session.entry, channelIdHex) ?: return false
|
||||
val build =
|
||||
ConcordPrivateChannels.privatize(
|
||||
actor = account.signer,
|
||||
cp = cp,
|
||||
entry = session.entry,
|
||||
channelIdHex = channelIdHex,
|
||||
standing = standing,
|
||||
accessRoleName = accessRoleName,
|
||||
current = session.controlEditions(),
|
||||
authority = state.authority,
|
||||
createdAt = TimeUtils.now(),
|
||||
observedFloor = floor,
|
||||
) ?: return false
|
||||
if (!updateConcordEntry(communityId) { cur -> ConcordChannelKeyring.withChannelKey(cur, build.key) }) return false
|
||||
build.wraps.forEach { publishConcordWrap(session.entry, it) }
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* The highest channel epoch a rotation of [channelIdHex] was ever published at, read off the
|
||||
* rekey addresses every held root derives (CORD-06 §2 addresses need no channel key), or null
|
||||
* when the read is inconclusive — a rotation at the window's top may have more above it, and
|
||||
* minting on a guess could reuse an epoch (Armada `channelEpochFloor`). 0 when none is seen or no
|
||||
* relay answers.
|
||||
*/
|
||||
private suspend fun observedChannelEpochFloor(
|
||||
entry: ConcordCommunityListEntry,
|
||||
channelIdHex: HexKey,
|
||||
): Long? {
|
||||
val relays = entry.relays.mapNotNullTo(mutableSetOf()) { RelayUrlNormalizer.normalizeOrNull(it) }
|
||||
if (relays.isEmpty()) return 0
|
||||
val channelId = channelIdHex.hexToByteArray()
|
||||
val window = HashMap<HexKey, Long>()
|
||||
for (root in (listOf(entry.root) + entry.heldRoots.map { it.key }).distinct()) {
|
||||
for (epoch in 1L..MAX_PROBED_CHANNEL_EPOCH) window[ConcordChannelRekey.address(root.hexToByteArray(), channelId, epoch).publicKeyHex] = epoch
|
||||
}
|
||||
val seen = runCatching { account.client.fetchAll(filters = relays.associateWith { listOf(ConcordActions.planeFilterFor(window.keys.toList())) }) }.getOrDefault(emptyList())
|
||||
val highest = seen.mapNotNull { window[it.pubKey] }.maxOrNull() ?: 0
|
||||
return if (highest >= MAX_PROBED_CHANNEL_EPOCH) null else highest
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts the Private channel [channelIdHex] back to Public (CORD-03 §2): the flag only. The
|
||||
* held key stays, so its holders keep reading the private era. MANAGE_CHANNELS.
|
||||
*/
|
||||
suspend fun publicizeConcordChannel(
|
||||
communityId: String,
|
||||
channelIdHex: HexKey,
|
||||
): Boolean {
|
||||
val session = account.concordSessions.sessionFor(communityId) ?: return false
|
||||
if (!account.isWriteable()) return false
|
||||
val cp = controlKeysForAction(session, ConcordPermissions.MANAGE_CHANNELS) ?: return false
|
||||
val standing =
|
||||
session.state.value
|
||||
?.channels
|
||||
?.get(channelIdHex)
|
||||
?.definition ?: return false
|
||||
val wrap = ConcordPrivateChannels.publicize(account.signer, cp, communityId.hexToByteArray(), channelIdHex, standing, session.controlEditions(), session.entry.owner, TimeUtils.now()) ?: return false
|
||||
publishConcordWrap(session.entry, wrap)
|
||||
return true
|
||||
}
|
||||
|
||||
// Channel keys reserved for a rotation in flight, keyed by (community, channel, new epoch,
|
||||
// prevcommit): a retry re-delivers the SAME key rather than minting a sibling that would split
|
||||
// the members across two keys at one epoch (Armada `mintOrReuseRotationKey`). Process-local.
|
||||
private val pendingConcordChannelRotations = ConcurrentMap<String, ByteArray>()
|
||||
|
||||
/**
|
||||
* Rotates Private Channel [channelIdHex] (a single-channel Rekey, CORD-06 §1-2) to exactly the
|
||||
* members entitled to it today plus ourselves, cutting everyone else. [removed] names who the
|
||||
* rotation cuts, for the authority check — the Rotator must hold MANAGE_CHANNELS and strictly
|
||||
* outrank each of them; null takes every known member who is not kept. Every chunk must land on
|
||||
* a relay before we adopt the new key. Returns whether the rotation was published and adopted.
|
||||
*/
|
||||
suspend fun rekeyConcordChannel(
|
||||
communityId: String,
|
||||
channelIdHex: HexKey,
|
||||
removed: Set<HexKey>? = null,
|
||||
): Boolean {
|
||||
val session = account.concordSessions.sessionFor(communityId) ?: return false
|
||||
if (!account.isWriteable()) return false
|
||||
val state = session.state.value ?: return false
|
||||
if (state.dissolved) return false
|
||||
val entry = session.entry
|
||||
val me = account.signer.pubKey
|
||||
val held = ConcordChannelKeyring.heldKey(entry, channelIdHex) ?: return false
|
||||
val authority = state.authority
|
||||
val keep = ConcordPrivateChannels.keepSet(authority, channelIdHex, me)
|
||||
val cut = removed ?: (session.allMembers() - keep)
|
||||
if (!ConcordPrivateChannels.canRotate(authority, me, cut)) {
|
||||
Log.w("Concord") { "Refusing to rotate $channelIdHex in $communityId: not MANAGE_CHANNELS, or does not outrank a cut member (CORD-06 §3)" }
|
||||
return false
|
||||
}
|
||||
val editions = session.controlEditions()
|
||||
val citation = ConcordReceive.rotationCitation(entry, editions, me)
|
||||
if (citation == null && !authority.isOwner(me)) return false
|
||||
val publishTo = entry.relays.mapNotNullTo(mutableSetOf()) { RelayUrlNormalizer.normalizeOrNull(it) }
|
||||
if (publishTo.isEmpty()) return false
|
||||
|
||||
val newEpoch = held.epoch + 1
|
||||
val reservation = "${entry.id}:${held.channelId.lowercase()}:$newEpoch:${ConcordChannelRekey.prevCommit(held.epoch, held.key.hexToByteArray())}"
|
||||
val newKey = pendingConcordChannelRotations.getOrPut(reservation) { ConcordChannelRekey.mintKey() }
|
||||
val wraps = ConcordPrivateChannels.buildRotation(account.signer, entry.root.hexToByteArray(), held, newKey, keep, TimeUtils.now(), citation)
|
||||
for (wrap in wraps) {
|
||||
if (!runCatching { account.client.publishAndConfirm(wrap, publishTo) }.getOrDefault(false)) {
|
||||
Log.w("Concord") { "Channel rekey of $channelIdHex aborted: a chunk was not accepted by any relay; retrying reuses the same key" }
|
||||
return false
|
||||
}
|
||||
}
|
||||
// Adopt at once: the rotator must never keep writing under the severed key.
|
||||
val adopted =
|
||||
updateConcordEntry(entry.id) { cur ->
|
||||
if (ConcordChannelKeyring.heldKey(cur, channelIdHex)?.epoch != held.epoch) null else ConcordChannelKeyring.withRotatedKey(cur, channelIdHex, newKey.toHexKey(), newEpoch)
|
||||
}
|
||||
if (adopted) pendingConcordChannelRotations.remove(reservation)
|
||||
return adopted
|
||||
}
|
||||
|
||||
/**
|
||||
* Follows a roster change with the keys it implies (Armada `handleToggleRole`): every Private
|
||||
* Channel whose entitled set moved between [before] and the current fold ([ConcordInviteVend.accessChanges])
|
||||
* — a member who gained one is handed its key by Direct Invite (only the channels gained), and
|
||||
* one who lost one is cut by rotating it. Only keys we hold can move; a channel we can't vend or
|
||||
* rotate is logged, since a revoke that cuts nobody is the failure to hear about.
|
||||
*/
|
||||
private suspend fun reconcileConcordChannelAccess(
|
||||
communityId: String,
|
||||
before: AuthorityResolver?,
|
||||
) {
|
||||
val session = account.concordSessions.sessionFor(communityId) ?: return
|
||||
val state = session.state.value ?: return
|
||||
if (before == null || state.dissolved) return
|
||||
val me = account.signer.pubKey.lowercase()
|
||||
val changes = ConcordInviteVend.accessChanges(before, state.authority, state.privateChannelIds)
|
||||
if (changes.isEmpty()) return
|
||||
|
||||
val vend = HashMap<HexKey, MutableSet<HexKey>>()
|
||||
for (change in changes) {
|
||||
val held = ConcordChannelKeyring.heldKey(session.entry, change.channelIdHex)
|
||||
if (held == null) {
|
||||
Log.w("Concord") { "Access to ${change.channelIdHex} changed, but we hold no key to vend or rotate it" }
|
||||
continue
|
||||
}
|
||||
for (member in change.gained - me) vend.getOrPut(member) { HashSet() }.add(change.channelIdHex)
|
||||
val cut = change.lost - me
|
||||
if (cut.isNotEmpty() && !rekeyConcordChannel(communityId, change.channelIdHex, cut)) {
|
||||
Log.w("Concord") { "Could not rotate ${change.channelIdHex}: ${cut.size} member(s) may keep reading it until someone who can rotates it" }
|
||||
}
|
||||
}
|
||||
for ((member, channels) in vend) {
|
||||
val sent = sendConcordDirectInvite(communityId, member, onlyChannelIds = channels)
|
||||
if (sent != ConcordDirectInviteSendResult.SENT) Log.w("Concord") { "Could not deliver ${channels.size} channel key(s) to $member: $sent" }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Drains the buffered channel rekeys of every joined community (CORD-06 §2 receive path): for
|
||||
* each held Private Channel, a complete, honored rotation carrying our blob off the key we hold
|
||||
* moves the key forward; a complete one from a Rotator who outranks us that omits us drops it and
|
||||
* records the cut. Runs on the revision tick; idempotent once applied (the held epoch moves on).
|
||||
*/
|
||||
internal suspend fun drainConcordChannelRekeys() {
|
||||
if (!account.isWriteable()) return
|
||||
for (session in account.concordSessions.sessions()) {
|
||||
val state = session.state.value ?: continue
|
||||
// Death wins every race (CORD-02 §9).
|
||||
if (state.dissolved) continue
|
||||
val wraps = session.pendingChannelRekeyWraps()
|
||||
if (wraps.isEmpty()) continue
|
||||
val entry = session.entry
|
||||
val outcomes = ConcordPrivateChannels.receive(entry, wraps, session.controlEditions(), state.authority, account.signer)
|
||||
if (outcomes.isEmpty()) continue
|
||||
val fromEpochs = entry.privateChannels.associate { it.channelId.lowercase() to it.epoch }
|
||||
if (updateConcordEntry(entry.id) { cur -> ConcordPrivateChannels.applyOutcome(cur, outcomes, fromEpochs) }) {
|
||||
for ((id, outcome) in outcomes) {
|
||||
when (outcome) {
|
||||
is ChannelRekeyOutcome.Adopted -> Log.i("Concord") { "Channel rekey ${entry.id}/$id: adopted epoch ${outcome.epoch}" }
|
||||
is ChannelRekeyOutcome.Removed -> Log.i("Concord") { "Channel rekey ${entry.id}/$id: cut at epoch ${outcome.epoch}" }
|
||||
ChannelRekeyOutcome.None -> Unit
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read-only preview of an invite link: parse it, fetch the kind-33301 bundle from
|
||||
* the link's relays (+ our outbox), and unlock it with the fragment token — WITHOUT
|
||||
@@ -2523,7 +3079,7 @@ class AccountConcordActions(
|
||||
* never asked for again and stays invisible. This sweep uses **no `since`**: it re-fetches the
|
||||
* whole plane every run.
|
||||
* - **Per-filter cap:** a relay caps a REQ's result (~100/filter on relay.dreamith.to), which can
|
||||
* crop a busy Control Plane. This **pages past the cap** ([fetchAllPagesFromPool] walks `until`
|
||||
* crop a busy Control Plane. This **pages past the cap** ([fetchAllPages] walks `until`
|
||||
* cursors until a plane is drained), so the fold sees every edition regardless of the cap.
|
||||
*
|
||||
* Current + every held-prior epoch's Control Plane is swept (the anti-rollback floor folds from the
|
||||
@@ -2543,9 +3099,74 @@ class AccountConcordActions(
|
||||
if (authorsByRelay.isEmpty()) return
|
||||
// No `since`, no `limit` → fetchAllPages treats each filter as unbounded and pages until a
|
||||
// plane is fully drained (empty page), so the whole Control Plane lands regardless of the cap.
|
||||
val byRelay = authorsByRelay.mapValues { (_, authors) -> listOf(ConcordActions.planeFilterFor(authors.toList())) }
|
||||
var drained = 0
|
||||
account.client.fetchAllPagesFromPool(filters = byRelay) { _, _ -> drained++ }
|
||||
Log.d("Concord") { "syncConcordControlPlanes: paged ${authorsByRelay.size} relay(s), drained $drained control wrap(s)" }
|
||||
// Paged per relay (8 at a time) rather than through the pool helper, because the drained flag
|
||||
// below needs each relay's ending: only DRAINED proves the relay had nothing more.
|
||||
val gate = Semaphore(8)
|
||||
val perRelay =
|
||||
coroutineScope {
|
||||
authorsByRelay
|
||||
.map { (relay, authors) ->
|
||||
async {
|
||||
gate.withPermit {
|
||||
val wraps = ArrayList<Event>()
|
||||
val end =
|
||||
try {
|
||||
account.client.fetchAllPages(relay, listOf(ConcordActions.planeFilterFor(authors.toList()))) { wraps.add(it) }.end
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
Log.w("Concord", "Control Plane sweep failed on $relay", e)
|
||||
null
|
||||
}
|
||||
Triple(authors, end == PagedFetchResult.End.DRAINED, wraps)
|
||||
}
|
||||
}
|
||||
}.awaitAll()
|
||||
}
|
||||
// Fold the swept wraps in before flagging anything drained: the global cache connector also
|
||||
// ingests them, but asynchronously, and the flag must never run ahead of the fold (the session
|
||||
// dedups by wrap id, so the second delivery is a no-op).
|
||||
var swept = 0
|
||||
for ((_, _, wraps) in perRelay) {
|
||||
for (wrap in wraps) {
|
||||
account.concordSessions.ingest(wrap)
|
||||
swept++
|
||||
}
|
||||
}
|
||||
val drainedAddresses = perRelay.filter { it.second }.flatMapTo(HashSet()) { it.first }
|
||||
// One relay drained whole is enough for everyday writes (a dead relay must not freeze pins and
|
||||
// metadata forever); the Refounding compaction keeps its own majority rule.
|
||||
for (entry in entries) {
|
||||
val session = account.concordSessions.sessionFor(entry.id) ?: continue
|
||||
if (session.controlPlaneAddress in drainedAddresses) session.markControlDrained()
|
||||
}
|
||||
Log.d("Concord") { "syncConcordControlPlanes: paged ${authorsByRelay.size} relay(s), swept $swept control wrap(s), ${drainedAddresses.size} plane(s) drained" }
|
||||
pruneExpiredConcordRegistries(entries)
|
||||
}
|
||||
|
||||
// Communities (at an epoch) whose registry was already checked for elapsed links this process.
|
||||
private val registryPruneChecked = ConcurrentSet<String>()
|
||||
|
||||
/**
|
||||
* CORD-05 §5: once a community's Control Plane has drained, republish this account's Invite
|
||||
* Registry there pruned when it still lists a link the Invite List says has expired (or no longer
|
||||
* holds). Otherwise an elapsed link — which nothing else ever retires — keeps the community Public
|
||||
* forever, and a Private ban would never Refound. Once per community and epoch per process; the
|
||||
* Invite List is read only when some drained community actually has a registry of ours.
|
||||
*/
|
||||
private suspend fun pruneExpiredConcordRegistries(entries: List<ConcordCommunityListEntry>) {
|
||||
if (!account.isWriteable()) return
|
||||
val me = account.signer.pubKey
|
||||
val due =
|
||||
entries.filter { entry ->
|
||||
val state = account.concordSessions.sessionFor(entry.id)?.foldForWrite() ?: return@filter false
|
||||
!state.dissolved && state.registryOf(me).isNotEmpty() && registryPruneChecked.add("${entry.id}@${entry.rootEpoch}")
|
||||
}
|
||||
if (due.isEmpty()) return
|
||||
val list = readConcordInviteList() ?: return
|
||||
for (entry in due) {
|
||||
// Publishes only when the pruned list differs from the honored one.
|
||||
if (publishConcordInviteRegistry(entry, list)) Log.i("Concord") { "Pruned elapsed invite links from this account's registry in ${entry.id}" }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+36
-6
@@ -37,6 +37,7 @@ import com.vitorpamplona.quartz.marmot.mip03GroupMessages.GroupEvent
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.core.IEvent
|
||||
import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKeyable
|
||||
import com.vitorpamplona.quartz.nip21UriScheme.toNostrUri
|
||||
import com.vitorpamplona.quartz.nip28PublicChat.message.ChannelMessageEvent
|
||||
import com.vitorpamplona.quartz.nip37Drafts.DraftWrapEvent
|
||||
import com.vitorpamplona.quartz.nip53LiveActivities.chat.LiveActivitiesChatMessageEvent
|
||||
@@ -346,7 +347,21 @@ class GiftWrapEventHandler(
|
||||
eventNote: Note,
|
||||
publicNote: Note,
|
||||
) {
|
||||
val innerGift = event.unwrapOrNull(account.signer) ?: return
|
||||
val innerGift =
|
||||
try {
|
||||
event.unwrapThrowing(account.signer)
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
// A Direct Invite-tagged wrap (CORD-05 §6) that will never open is written off in the
|
||||
// invite inbox too, so its sweep does not decrypt it again; a signer that merely
|
||||
// could not answer now leaves it to be retried.
|
||||
if (ConcordDirectInvite.isInviteTagged(event) && !ConcordDirectInvite.isTransientSignerFailure(e)) {
|
||||
account.concord.directInviteInbox.markNotInvite(event.id)
|
||||
}
|
||||
Log.d("GiftWrapEvent") { "Couldn't Decrypt the content " + event.toNostrUri() }
|
||||
return
|
||||
}
|
||||
|
||||
eventNote.event = event.copyNoContent()
|
||||
|
||||
@@ -535,18 +550,33 @@ class SealEventHandler(
|
||||
eventNote: Note,
|
||||
publicNote: Note,
|
||||
) {
|
||||
val innerRumor = event.unsealOrNull(account.signer) ?: return
|
||||
// Decrypted once, kept as the seal carries it (claimed author intact) so a Direct Invite can be
|
||||
// checked against NIP-59 anti-spoofing without a second decrypt.
|
||||
val rumor =
|
||||
try {
|
||||
event.unsealRumorThrowing(account.signer)
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
Log.w("RumorEvent", "Fail to decrypt or parse Rumor", e)
|
||||
return
|
||||
}
|
||||
val innerRumor = event.unsealed(rumor)
|
||||
|
||||
// A Concord Direct Invite (CORD-05 §6) is a standard NIP-59 giftwrap, so the DM inbox sees
|
||||
// it too — tagged `k=3313` or not. It is not a DM: its rumor carries a community's keys. Hand
|
||||
// the seal to the Concord invite inbox, which re-opens it with the NIP-59 anti-spoofing check
|
||||
// the generic unseal skips and parks it for the user, and keep the rumor out of the cache and
|
||||
// every chat feed. Must run before the seal's content is stripped below.
|
||||
// the seal and its rumor to the Concord invite inbox, which runs the NIP-59 anti-spoofing
|
||||
// check the generic unseal skips and parks it for the user, and keep the rumor out of the
|
||||
// cache and every chat feed.
|
||||
if (innerRumor.kind == ConcordDirectInvite.KIND) {
|
||||
account.concord.directInviteInbox.offerSeal(publicNote.event ?: event, event)
|
||||
account.concord.directInviteInbox.offerRumor(publicNote.event ?: event, event, rumor)
|
||||
eventNote.event = event.copyNoContent()
|
||||
return
|
||||
}
|
||||
// Tagged as an invite but carrying something else: never one, so the inbox sweep skips it.
|
||||
(publicNote.event as? GiftWrapEvent)?.let { wrap ->
|
||||
if (ConcordDirectInvite.isInviteTagged(wrap)) account.concord.directInviteInbox.markNotInvite(wrap.id)
|
||||
}
|
||||
|
||||
eventNote.event = event.copyNoContent()
|
||||
|
||||
|
||||
+35
@@ -196,6 +196,19 @@ class ConcordChannelListState(
|
||||
emptyList(),
|
||||
)
|
||||
|
||||
/**
|
||||
* When this account left each community it no longer holds (community id → the List
|
||||
* tombstone's `removed_at`, unix ms, CORD-02 §8). A Direct Invite sent at or before that moment
|
||||
* stays buried instead of resurfacing right after the leave.
|
||||
*/
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
val removedAt: StateFlow<Map<String, Long>> =
|
||||
listChanges
|
||||
.transformLatest { emit(document().residue.removals()) }
|
||||
.onStart { emit(document().residue.removals()) }
|
||||
.flowOn(Dispatchers.IO)
|
||||
.stateIn(scope, SharingStarted.Eagerly, emptyMap())
|
||||
|
||||
/** The distinct community ids across the joined list — the "servers" rail. */
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
val liveServers: StateFlow<Set<String>> =
|
||||
@@ -243,6 +256,28 @@ class ConcordChannelListState(
|
||||
write(set, doc.entries.filterNot { it.id == entry.id } + live, doc.residue)
|
||||
}
|
||||
|
||||
/**
|
||||
* Read-modify-write one membership: [transform] receives the entry for [communityId] **as the
|
||||
* List holds it inside the write lock** and returns its replacement, or null to write nothing.
|
||||
* Returns the fragment events to publish (empty when the community isn't held or nothing
|
||||
* changed).
|
||||
*
|
||||
* Use this, never [follow] with a snapshot, for any change that touches one field of a live
|
||||
* entry (a channel key, a cut): the List can move between reading a snapshot and writing it —
|
||||
* a rekey adopted, a new root imported — and following the stale copy would write the old
|
||||
* root back over it.
|
||||
*/
|
||||
suspend fun update(
|
||||
communityId: String,
|
||||
transform: (ConcordCommunityListEntry) -> ConcordCommunityListEntry?,
|
||||
): List<Event> =
|
||||
writeLock.withLock {
|
||||
val (set, doc) = snapshot()
|
||||
val current = doc.entries.firstOrNull { it.id == communityId } ?: return@withLock emptyList()
|
||||
val next = transform(current) ?: return@withLock emptyList()
|
||||
write(set, doc.entries.map { if (it.id == communityId) next else it }, doc.residue)
|
||||
}
|
||||
|
||||
/**
|
||||
* Leave [communityId]: drop its membership and tombstone it (CORD-02 §8 — only a tombstone
|
||||
* subtracts a membership; a missing entry is just unseen news another fragment may still
|
||||
|
||||
+252
-20
@@ -27,14 +27,18 @@ import com.vitorpamplona.amethyst.commons.actions.ConcordLocalEdit
|
||||
import com.vitorpamplona.amethyst.commons.actions.ConcordPinSource
|
||||
import com.vitorpamplona.amethyst.commons.actions.ConcordPinVerifier
|
||||
import com.vitorpamplona.amethyst.commons.actions.ConcordPinning
|
||||
import com.vitorpamplona.amethyst.commons.actions.ConcordPrivateChannels
|
||||
import com.vitorpamplona.amethyst.commons.util.KmpLock
|
||||
import com.vitorpamplona.amethyst.commons.util.withLock
|
||||
import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEntry
|
||||
import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityState
|
||||
import com.vitorpamplona.quartz.concord.cord02Community.ConcordDissolution
|
||||
import com.vitorpamplona.quartz.concord.cord02Community.Guestbook
|
||||
import com.vitorpamplona.quartz.concord.cord02Community.GuestbookAction
|
||||
import com.vitorpamplona.quartz.concord.cord02Community.GuestbookEntry
|
||||
import com.vitorpamplona.quartz.concord.cord03Channels.ChannelChat
|
||||
import com.vitorpamplona.quartz.concord.cord03Channels.ConcordDisappearing
|
||||
import com.vitorpamplona.quartz.concord.cord03Channels.ConcordWebxdc
|
||||
import com.vitorpamplona.quartz.concord.cord04Roles.ControlEdition
|
||||
import com.vitorpamplona.quartz.concord.cord04Roles.EditionFold
|
||||
import com.vitorpamplona.quartz.concord.cord04Roles.EntityFloor
|
||||
@@ -73,6 +77,8 @@ data class ExpiredConcordRumor(
|
||||
val wrapId: HexKey,
|
||||
val rumorId: HexKey,
|
||||
val expiresAt: Long,
|
||||
/** The URLs of the rumor's encrypted attachments, whose decryption keys go with it (CORD-08 §3). */
|
||||
val attachmentUrls: List<String> = emptyList(),
|
||||
)
|
||||
|
||||
/**
|
||||
@@ -283,6 +289,14 @@ class ConcordCommunitySession(
|
||||
private val baseRekeyWraps = LinkedHashMap<HexKey, Event>()
|
||||
private val siblingRekeyWraps = LinkedHashMap<HexKey, Event>()
|
||||
|
||||
// Channel-rekey addresses (CORD-06 §2) for each held Private Channel's next epochs, under the
|
||||
// current root and the prior one (a Refounding seals its channel rekeys under the prior root,
|
||||
// CORD-06 §3) -> key. Re-derived whenever the held channel keys change, since each adoption
|
||||
// moves the window forward.
|
||||
@Volatile
|
||||
private var channelRekeyKeys: Map<HexKey, GroupKey> = ConcordPrivateChannels.watchKeys(entry)
|
||||
private val channelRekeyWraps = LinkedHashMap<HexKey, Event>()
|
||||
|
||||
// Current channel plane pubkey -> plane (channel id, key, bound epoch), refreshed on each control
|
||||
// re-fold. A Public Channel's plane derives from the root at the root epoch; a Private one's from
|
||||
// its held channel key at the channel epoch (CORD-03 §1). A Private Channel we hold no key for has
|
||||
@@ -301,6 +315,46 @@ class ConcordCommunitySession(
|
||||
private val _state = MutableStateFlow<ConcordCommunityState?>(null)
|
||||
val state: StateFlow<ConcordCommunityState?> = _state
|
||||
|
||||
private val _controlDrained = MutableStateFlow(false)
|
||||
|
||||
/**
|
||||
* True once this session's current Control Plane has been swept whole at least once — every
|
||||
* relay page drained and ingested ([markControlDrained]) — so [state] is a fold of the full plane
|
||||
* rather than of whatever the live subscription delivered first.
|
||||
*
|
||||
* Until then the fold may be partial (a cropped first page, an edition below the live `since`
|
||||
* cursor), and a write built on it can erase what it never saw: a Pin List edition replaces the
|
||||
* list entire (CORD-04 §7: never write from a list the fold was not served), a metadata edition
|
||||
* minted without the head resets the name and relays, a registry edit chains onto a stale head.
|
||||
* Writers refuse while this is false; readers may show a partial fold but must not call an
|
||||
* absent entity "none". One way: a session never un-drains (a Refounding builds a new session).
|
||||
*/
|
||||
val controlDrained: StateFlow<Boolean> = _controlDrained
|
||||
|
||||
/** Records that the whole current Control Plane has been paged in and ingested (see [controlDrained]). */
|
||||
fun markControlDrained() {
|
||||
_controlDrained.value = true
|
||||
}
|
||||
|
||||
/**
|
||||
* The fold a Control Plane write may be built from: [state] once the plane has drained, else
|
||||
* null. Every writer that lays its edition over a folded head (metadata, timer, registry, pins)
|
||||
* goes through this, the owner included — the owner may act before the fold, but not write over
|
||||
* a head they have not been served.
|
||||
*/
|
||||
fun foldForWrite(): ConcordCommunityState? = if (_controlDrained.value) _state.value else null
|
||||
|
||||
// The anti-rollback floors the last fold used, so a writer can chain onto the same floor-aware head.
|
||||
@Volatile
|
||||
private var lastFloors: Map<String, EntityFloor> = emptyMap()
|
||||
|
||||
/**
|
||||
* The per-entity anti-rollback floors (from the prior epochs' Control Planes) the current fold
|
||||
* honors — what a writer passes so it chains onto the head readers fold to, not the head of the
|
||||
* current epoch's editions alone.
|
||||
*/
|
||||
fun controlFloors(): Map<String, EntityFloor> = lastFloors
|
||||
|
||||
private val _pinHeads = MutableStateFlow<Map<HexKey, ControlEdition>>(emptyMap())
|
||||
|
||||
/**
|
||||
@@ -312,9 +366,22 @@ class ConcordCommunitySession(
|
||||
|
||||
private val _members = MutableStateFlow<Set<HexKey>>(emptySet())
|
||||
|
||||
/** The live Guestbook membership set (self-signed joins minus later leaves). */
|
||||
/** The live Guestbook membership set (self-signed joins minus later leaves and honored Kicks). */
|
||||
val members: StateFlow<Set<HexKey>> = _members
|
||||
|
||||
private val _guestbook = MutableStateFlow<Map<HexKey, GuestbookEntry>>(emptyMap())
|
||||
|
||||
/**
|
||||
* The coalesced Guestbook (CORD-02 §5): each npub's latest Join, Leave or honored Kick, by
|
||||
* lowercase pubkey. Kicks are judged against the current roster, so this re-derives on every
|
||||
* control fold as well as on every Guestbook arrival.
|
||||
*/
|
||||
val guestbook: StateFlow<Map<HexKey, GuestbookEntry>> = _guestbook
|
||||
|
||||
// Author (lowercase) -> the newest CORD-02 §4 ms of a message of theirs we decrypted: observation
|
||||
// only counts *forward* of their latest Leave or Kick (CORD-02 §5).
|
||||
private val observedAtMs = HashMap<HexKey, Long>()
|
||||
|
||||
private val _observedAuthors = MutableStateFlow<Set<HexKey>>(emptySet())
|
||||
|
||||
/**
|
||||
@@ -362,7 +429,37 @@ class ConcordCommunitySession(
|
||||
val s = _state.value
|
||||
val roster = if (s != null) s.authority.roleHolders() + s.ownerPubKey.lowercase() else emptySet()
|
||||
val banned = s?.authority?.bannedMembers().orEmpty()
|
||||
return (_members.value + _observedAuthors.value + roster) - banned
|
||||
return (_members.value + _observedAuthors.value + roster) - banned - departedMembers().keys
|
||||
}
|
||||
|
||||
/**
|
||||
* Members the Guestbook shows as departed (lowercase hex -> their winning Leave or Kick): their
|
||||
* latest motion is a Leave or an honored Kick and we have seen nothing of theirs since
|
||||
* (CORD-02 §5: observation only counts forward). The owner never departs by a Kick.
|
||||
*/
|
||||
fun departedMembers(): Map<HexKey, GuestbookEntry> {
|
||||
val coalesced = _guestbook.value
|
||||
if (coalesced.isEmpty()) return emptyMap()
|
||||
val owner = entry.owner.lowercase()
|
||||
return lock.withLock {
|
||||
coalesced.filter { (pubkey, motion) ->
|
||||
motion.action != GuestbookAction.JOIN &&
|
||||
pubkey != owner &&
|
||||
(observedAtMs[pubkey] ?: Long.MIN_VALUE) <= motion.ms
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The honored Kick naming this account that postdates this membership (CORD-04 §6), or null.
|
||||
* 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 }
|
||||
}
|
||||
|
||||
/** The size of [allMembers] — the community's true (best-effort) member count. */
|
||||
@@ -389,6 +486,7 @@ class ConcordCommunitySession(
|
||||
address == nextBaseRekeyAddress ||
|
||||
address == siblingBaseRekeyAddress ||
|
||||
address == dissolvedAddress ||
|
||||
address in channelRekeyKeys ||
|
||||
address in historicalControlKeys ||
|
||||
lock.withLock { address in channelKeysByAddress || address in historicalChannelKeysByAddress }
|
||||
|
||||
@@ -474,7 +572,13 @@ class ConcordCommunitySession(
|
||||
* The auxiliary plane keys (Guestbook, next base-rekey, and the CORD-02 §9 dissolution address)
|
||||
* for their own isolated AUTH.
|
||||
*/
|
||||
fun auxStreamKeys(): List<GroupKey> = listOfNotNull(guestbookKey, nextBaseRekeyKey, dissolvedKey, siblingBaseRekeyKey)
|
||||
fun auxStreamKeys(): List<GroupKey> = listOfNotNull(guestbookKey, nextBaseRekeyKey, dissolvedKey, siblingBaseRekeyKey) + channelRekeyKeys.values
|
||||
|
||||
/** The channel-rekey addresses this session watches (CORD-06 §2), for the auxiliary subscription. */
|
||||
fun channelRekeyAddresses(): Set<HexKey> = channelRekeyKeys.keys
|
||||
|
||||
/** The buffered kind-3303 wraps seen at [channelRekeyAddresses], for the account's channel-rekey drain. */
|
||||
fun pendingChannelRekeyWraps(): List<Event> = lock.withLock { channelRekeyWraps.values.toList() }
|
||||
|
||||
/** The community's current Control Plane editions — the input a moderation edition chains onto. */
|
||||
fun controlEditions(): List<ControlEdition> = lock.withLock { editionsLocked(controlWraps.values.toList(), controlKeys) }
|
||||
@@ -540,6 +644,7 @@ class ConcordCommunitySession(
|
||||
// Control material may already have swapped in an entry carrying the new keys.
|
||||
if (privateKeySet(newEntry) == derivedPrivateKeys) return false
|
||||
entry = newEntry
|
||||
channelRekeyKeys = ConcordPrivateChannels.watchKeys(newEntry)
|
||||
true
|
||||
}
|
||||
// Nothing folded yet: the first control wrap derives the planes from the swapped-in entry.
|
||||
@@ -606,6 +711,14 @@ class ConcordCommunitySession(
|
||||
lock.withLock { siblingRekeyWraps[wrap.id] = wrap }
|
||||
return ConcordIngestOutcome.STRUCTURAL
|
||||
}
|
||||
in channelRekeyKeys -> {
|
||||
// Buffer only, like the base rekeys: opening a blob takes the account signer, and the
|
||||
// rotator's authority is judged against the fold at drain time.
|
||||
lock.withLock {
|
||||
if (channelRekeyWraps.put(wrap.id, wrap) != null) return ConcordIngestOutcome.NON_STRUCTURAL // dup
|
||||
}
|
||||
return ConcordIngestOutcome.STRUCTURAL
|
||||
}
|
||||
else -> {
|
||||
// A prior-epoch Control Plane wrap: buffer it and re-fold, so the anti-rollback
|
||||
// floor rises as the old epochs drain in. Structural — the floor can change the
|
||||
@@ -646,6 +759,8 @@ class ConcordCommunitySession(
|
||||
ingestTyping(wrap, channelIdHex, key, epoch)
|
||||
return ConcordIngestOutcome.NON_STRUCTURAL
|
||||
}
|
||||
// An expired wrap this session already swept, delivered again: ours, but never opened again.
|
||||
if (wasSwept(wrap.id)) return ConcordIngestOutcome.NON_STRUCTURAL
|
||||
val isNew =
|
||||
lock.withLock {
|
||||
channelWrapsById.getOrPut(channelIdHex) { LinkedHashMap() }.put(wrap.id, wrap) == null
|
||||
@@ -700,6 +815,7 @@ class ConcordCommunitySession(
|
||||
val wraps = controlWraps.values.toList()
|
||||
val editions = editionsLocked(wraps, controlKeys)
|
||||
val floors = controlFloorsLocked()
|
||||
lastFloors = floors
|
||||
val folded = ConcordCommunityState.fold(editions, communityIdBytes, entry.owner, floors)
|
||||
|
||||
val prevAddresses = channelKeysByAddress.keys.toHashSet()
|
||||
@@ -725,6 +841,9 @@ class ConcordCommunitySession(
|
||||
next.filterKeys { it !in prevAddresses }.values.map { it.channelIdHex }
|
||||
}
|
||||
|
||||
// Kicks are judged against the roster, so a fold can honor (or drop) a held Kick.
|
||||
refoldGuestbook()
|
||||
|
||||
// Project only channels whose current plane is new (a first fold, or a plane that moved when a
|
||||
// Private Channel's key arrived). Existing planes' wraps were already emitted incrementally as
|
||||
// they arrived — re-projecting all channels on every control edition would be
|
||||
@@ -785,7 +904,9 @@ class ConcordCommunitySession(
|
||||
ConcordActions.guestbookEntry(wrap, guestbookKey).also { guestbookEntryByWrapId[wrap.id] = it }
|
||||
}
|
||||
}
|
||||
_members.value = ConcordActions.projectGuestbook(entries)
|
||||
val coalesced = Guestbook.coalesce(entries, TimeUtils.nowMillis(), _state.value?.authority)
|
||||
_guestbook.value = coalesced
|
||||
_members.value = ConcordActions.joinedMembers(coalesced)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -813,7 +934,17 @@ class ConcordCommunitySession(
|
||||
val authors = HashSet<HexKey>()
|
||||
val now = TimeUtils.now()
|
||||
for (wrap in wraps) {
|
||||
val rumor = ConcordActions.openChannelRumorAnyExpiry(wrap, key, channelIdHex, epoch) ?: continue
|
||||
val rumor = ConcordActions.openChannelRumorAnyExpiry(wrap, key, channelIdHex, epoch, ChannelChat.PLANE_KINDS) ?: continue
|
||||
// A WebXDC signal (kind 3310) rides the plane but is never a chat row: held apart for a
|
||||
// WebXDC host, never handed to the store, so it can't reach a feed, a preview or an
|
||||
// unread count. Its author is still observably present (CORD-02 §5).
|
||||
if (ConcordWebxdc.isWebxdc(rumor)) {
|
||||
if (!ConcordDisappearing.isExpired(rumor, now) && holdWebxdc(channelIdHex, rumor)) {
|
||||
observe(rumor)
|
||||
authors.add(rumor.pubKey.lowercase())
|
||||
}
|
||||
continue
|
||||
}
|
||||
// Pins reopen the carrying wrap to disclose this one message's keys (CORD-04 §7).
|
||||
lock.withLock { wrapIdByRumorId[rumor.id] = wrap.id }
|
||||
// CORD-08 §3: only the rumor's own tag counts. A rumor carrying one is remembered so the
|
||||
@@ -821,10 +952,11 @@ class ConcordCommunitySession(
|
||||
// never handed to the store — and queued for the next sweep so its wrap goes too.
|
||||
val expiresAt = ConcordDisappearing.expirationOf(rumor)
|
||||
if (expiresAt != null) {
|
||||
trackExpiring(wrap.id, channelIdHex, rumor.id, expiresAt)
|
||||
trackExpiring(wrap.id, channelIdHex, rumor.id, expiresAt, ChannelChat.encryptedImagesOf(rumor).map { it.url })
|
||||
if (expiresAt <= now) continue
|
||||
}
|
||||
authors.add(rumor.pubKey.lowercase())
|
||||
observe(rumor)
|
||||
onRumor(entry.id, channelIdHex, rumor, seenOnRelays)
|
||||
}
|
||||
// Every author we just decrypted is observably present (CORD-02 §5), so fold them into the
|
||||
@@ -834,6 +966,54 @@ class ConcordCommunitySession(
|
||||
}
|
||||
}
|
||||
|
||||
/** Records [rumor]'s author as seen at its CORD-02 §4 time (observation counts forward only). */
|
||||
private fun observe(rumor: Event) {
|
||||
val author = rumor.pubKey.lowercase()
|
||||
val atMs = ChannelChat.orderingMs(rumor) ?: (rumor.createdAt * 1000)
|
||||
lock.withLock { if (atMs > (observedAtMs[author] ?: Long.MIN_VALUE)) observedAtMs[author] = atMs }
|
||||
}
|
||||
|
||||
// ── WebXDC signals (kind 3310) ───────────────────────────────────────────
|
||||
|
||||
// Channel id -> its WebXDC signals by rumor id, in arrival order, bounded per channel.
|
||||
private val webxdcByChannel = HashMap<HexKey, LinkedHashMap<HexKey, Event>>()
|
||||
|
||||
private val _webxdcRevision = MutableStateFlow(0L)
|
||||
|
||||
/** Bumps whenever a new WebXDC signal is held — what a WebXDC host re-reads [webxdcSignals] on. */
|
||||
val webxdcRevision: StateFlow<Long> = _webxdcRevision
|
||||
|
||||
/**
|
||||
* [channelIdHex]'s held WebXDC signals (kind 3310: app state updates and realtime peer signals,
|
||||
* [ConcordWebxdc]) not yet expired (CORD-08: app state disappears with the chat plane), oldest
|
||||
* first on the CORD-02 §4 basis. Amethyst has no WebXDC host; this is the plumbing one would read.
|
||||
*/
|
||||
fun webxdcSignals(
|
||||
channelIdHex: HexKey,
|
||||
now: Long = TimeUtils.now(),
|
||||
): List<Event> =
|
||||
lock
|
||||
.withLock { webxdcByChannel[channelIdHex]?.values?.toList() }
|
||||
.orEmpty()
|
||||
.filterNot { ConcordDisappearing.isExpired(it, now) }
|
||||
.sortedBy { ChannelChat.orderingMs(it) ?: (it.createdAt * 1000) }
|
||||
|
||||
/** Holds [rumor] for [channelIdHex]; false when already held. Keeps the newest [MAX_WEBXDC_PER_CHANNEL] arrivals. */
|
||||
private fun holdWebxdc(
|
||||
channelIdHex: HexKey,
|
||||
rumor: Event,
|
||||
): Boolean {
|
||||
val added =
|
||||
lock.withLock {
|
||||
val held = webxdcByChannel.getOrPut(channelIdHex) { LinkedHashMap() }
|
||||
if (held.put(rumor.id, rumor) != null) return@withLock false
|
||||
while (held.size > MAX_WEBXDC_PER_CHANNEL) held.remove(held.keys.first())
|
||||
true
|
||||
}
|
||||
if (added) _webxdcRevision.update { it + 1 }
|
||||
return added
|
||||
}
|
||||
|
||||
// ── Disappearing messages (CORD-08) ──────────────────────────────────────
|
||||
|
||||
/** Wrap id -> the expiring rumor it carries, for every rumor with an `expiration` we emitted or refused. */
|
||||
@@ -854,42 +1034,86 @@ class ConcordCommunitySession(
|
||||
*/
|
||||
fun messageExpirationSecs(): Long? = _state.value?.metadata?.messageExpirationSecs()
|
||||
|
||||
/**
|
||||
* The same entries as [expiringByWrapId], kept sorted by `expiresAt` (then wrap id), so a sweep
|
||||
* pops only what is due instead of scanning every tracked message, and the next deadline is the
|
||||
* head. Common code has no priority queue; a binary-searched insert into an array list is the
|
||||
* same order of cost here.
|
||||
*/
|
||||
private val expiringByDeadline = ArrayList<ExpiredConcordRumor>()
|
||||
|
||||
/**
|
||||
* Wrap ids this session already swept, newest last and bounded: relays keep re-delivering an
|
||||
* expired wrap (a relay that ignores NIP-40, a backfill page), and each would otherwise be opened
|
||||
* again only to be refused and swept again.
|
||||
*/
|
||||
private val sweptWrapIds = LinkedHashSet<HexKey>()
|
||||
|
||||
private val deadlineOrder = compareBy<ExpiredConcordRumor>({ it.expiresAt }, { it.wrapId })
|
||||
|
||||
private fun trackExpiring(
|
||||
wrapId: HexKey,
|
||||
channelIdHex: HexKey,
|
||||
rumorId: HexKey,
|
||||
expiresAt: Long,
|
||||
attachmentUrls: List<String> = emptyList(),
|
||||
) {
|
||||
lock.withLock {
|
||||
expiringByWrapId[wrapId] = ExpiredConcordRumor(channelIdHex, wrapId, rumorId, expiresAt)
|
||||
_nextExpiry.update { if (it == null || expiresAt < it) expiresAt else it }
|
||||
lock.withLock { trackLocked(ExpiredConcordRumor(channelIdHex, wrapId, rumorId, expiresAt, attachmentUrls)) }
|
||||
}
|
||||
|
||||
private fun trackLocked(entry: ExpiredConcordRumor) {
|
||||
val prior = expiringByWrapId[entry.wrapId]
|
||||
if (prior != null) {
|
||||
// A re-projection re-emits the same wrap: same rumor, same deadline — nothing to move.
|
||||
if (prior.expiresAt == entry.expiresAt) return
|
||||
val at = expiringByDeadline.binarySearch(prior, deadlineOrder)
|
||||
if (at >= 0) expiringByDeadline.removeAt(at)
|
||||
}
|
||||
expiringByWrapId[entry.wrapId] = entry
|
||||
val at = expiringByDeadline.binarySearch(entry, deadlineOrder)
|
||||
expiringByDeadline.add(if (at < 0) -at - 1 else at, entry)
|
||||
_nextExpiry.value = expiringByDeadline.first().expiresAt
|
||||
}
|
||||
|
||||
/** True when [wrapId] was already swept as expired: a re-delivery is dropped before it is opened. */
|
||||
private fun wasSwept(wrapId: HexKey): Boolean = lock.withLock { wrapId in sweptWrapIds }
|
||||
|
||||
/**
|
||||
* Forgets every rumor whose `expiration` is at or before [now] (CORD-08 §3): its wrap leaves the
|
||||
* channel buffer, so no re-projection can resurrect it, and it is returned so the caller purges
|
||||
* the rumor's note and the wrap's note from its store. A wrap re-delivered later is refused again
|
||||
* at ingest.
|
||||
* the rumor's note and the wrap's note from its store. A wrap re-delivered later is dropped at
|
||||
* ingest without being opened.
|
||||
*/
|
||||
fun sweepExpired(now: Long = TimeUtils.now()): List<ExpiredConcordRumor> =
|
||||
lock.withLock {
|
||||
if (expiringByWrapId.isEmpty()) return@withLock emptyList()
|
||||
if (expiringByDeadline.isEmpty() || expiringByDeadline.first().expiresAt > now) return@withLock emptyList()
|
||||
val out = ArrayList<ExpiredConcordRumor>()
|
||||
val it = expiringByWrapId.values.iterator()
|
||||
while (it.hasNext()) {
|
||||
val expiring = it.next()
|
||||
if (expiring.expiresAt <= now) {
|
||||
while (expiringByDeadline.isNotEmpty() && expiringByDeadline.first().expiresAt <= now) {
|
||||
val expiring = expiringByDeadline.removeAt(0)
|
||||
expiringByWrapId.remove(expiring.wrapId)
|
||||
channelWrapsById[expiring.channelIdHex]?.remove(expiring.wrapId)
|
||||
wrapIdByRumorId.remove(expiring.rumorId)
|
||||
sweptWrapIds.add(expiring.wrapId)
|
||||
out.add(expiring)
|
||||
it.remove()
|
||||
}
|
||||
}
|
||||
_nextExpiry.value = expiringByWrapId.values.minOfOrNull { it.expiresAt }
|
||||
while (sweptWrapIds.size > MAX_SWEPT_WRAP_IDS) sweptWrapIds.remove(sweptWrapIds.first())
|
||||
_nextExpiry.value = expiringByDeadline.firstOrNull()?.expiresAt
|
||||
out
|
||||
}
|
||||
|
||||
/** Every disappearing rumor this session still tracks — handed to the session that replaces it. */
|
||||
fun trackedExpiring(): List<ExpiredConcordRumor> = lock.withLock { expiringByDeadline.toList() }
|
||||
|
||||
/**
|
||||
* Adopts [entries] tracked by the session this one replaces (a Refounding rebuilds the session):
|
||||
* their rumors are already in the store, and without this nothing would ever purge them once
|
||||
* their deadline passes, since the new session never sees the old epoch's wraps again.
|
||||
*/
|
||||
fun carryExpiring(entries: Collection<ExpiredConcordRumor>) {
|
||||
if (entries.isEmpty()) return
|
||||
lock.withLock { entries.forEach { trackLocked(it) } }
|
||||
}
|
||||
|
||||
/** True while [channelIdHex]'s buffer holds [wrapId] — for tests of the sweep. */
|
||||
internal fun isBuffered(
|
||||
channelIdHex: HexKey,
|
||||
@@ -924,7 +1148,9 @@ class ConcordCommunitySession(
|
||||
// An expired message leaves the pinned list too (CORD-08 §3: never display an expired rumor);
|
||||
// its proof is still valid, but the rumor's own tag says it is gone.
|
||||
val hidden = { pin: ConcordPins.VerifiedPin -> isKilled(pin) || pin.tags.isExpirationBefore(now) }
|
||||
return ConcordPinning.read(_pinHeads.value[channelIdHex], channelIdHex, { pinUnsealKey(channelIdHex, it) }, pinVerifier, hidden, newestEdit)
|
||||
// Not drained yet: the head may simply not have been served, so the result is marked partial
|
||||
// (shown, never written from — CORD-04 §7).
|
||||
return ConcordPinning.read(_pinHeads.value[channelIdHex], channelIdHex, { pinUnsealKey(channelIdHex, it) }, pinVerifier, hidden, newestEdit, complete = _controlDrained.value)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -955,5 +1181,11 @@ class ConcordCommunitySession(
|
||||
|
||||
/** A typing heartbeat is considered current for this many seconds after it's seen. */
|
||||
const val TYPING_STALE_SECS = 8L
|
||||
|
||||
/** WebXDC signals held per channel (the reference client scans its newest 2000 for peer signals). */
|
||||
const val MAX_WEBXDC_PER_CHANNEL = 2000
|
||||
|
||||
/** How many swept (expired) wrap ids a session remembers to drop their re-deliveries unopened. */
|
||||
const val MAX_SWEPT_WRAP_IDS = 4096
|
||||
}
|
||||
}
|
||||
|
||||
+192
-45
@@ -31,8 +31,11 @@ import com.vitorpamplona.quartz.concord.cord05Invites.OpenedDirectInvite
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
|
||||
import com.vitorpamplona.quartz.nip59Giftwrap.rumors.Rumor
|
||||
import com.vitorpamplona.quartz.nip59Giftwrap.seals.SealEvent
|
||||
import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent
|
||||
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
@@ -52,6 +55,12 @@ class ConcordDirectInviteView(
|
||||
val opened: OpenedDirectInvite,
|
||||
val catchUp: Boolean,
|
||||
val expired: Boolean,
|
||||
/** For a [catchUp]: the ids of the Private Channels it would newly add (not every key it carries). */
|
||||
val newChannelIds: List<HexKey> = emptyList(),
|
||||
/** The rumor's `sentAt` clamped to the time it was ranked, so a future date buys no rank. */
|
||||
val clampedSentAt: Long = opened.sentAt,
|
||||
/** True when this account follows the sender: ranked above strangers. */
|
||||
val followedSender: Boolean = false,
|
||||
) {
|
||||
val wrapId: HexKey get() = opened.wrapId
|
||||
val sender: HexKey get() = opened.sender
|
||||
@@ -60,11 +69,17 @@ class ConcordDirectInviteView(
|
||||
val name: String get() = opened.invite.name
|
||||
val icon: ImagePointer? get() = opened.invite.icon
|
||||
|
||||
/** Names of the Private Channels the bundle carries (what a catch-up would add). */
|
||||
val channelNames: List<String> get() =
|
||||
opened.invite.channels
|
||||
.filter { it.key.isNotBlank() }
|
||||
.map { it.name }
|
||||
/**
|
||||
* Names of the Private Channels a catch-up would newly add — [newChannelIds] only, named by
|
||||
* [foldedName] (the held community's folded channel name) when it knows the channel, else by the
|
||||
* bundle's own label.
|
||||
*/
|
||||
fun newChannelNames(foldedName: (HexKey) -> String? = { null }): List<String> {
|
||||
val ids = newChannelIds.mapTo(HashSet()) { it.lowercase() }
|
||||
return opened.invite.channels
|
||||
.filter { it.id.lowercase() in ids }
|
||||
.map { foldedName(it.id)?.takeIf { name -> name.isNotBlank() } ?: it.name }
|
||||
}
|
||||
}
|
||||
|
||||
/** What accepting a Direct Invite does; see [ConcordDirectInviteInbox.acceptPlan]. */
|
||||
@@ -75,9 +90,14 @@ sealed interface DirectInviteAcceptPlan {
|
||||
/** A community we don't hold: run the shared join path. */
|
||||
data object Join : DirectInviteAcceptPlan
|
||||
|
||||
/** A held community: store [entry] — the held one plus the newly granted Private Channel keys. */
|
||||
/**
|
||||
* A held community: store [entry] — the held one plus the newly granted Private Channel keys
|
||||
* ([channelIds]). A writer re-applies [channelIds] to the entry it reads inside the List write
|
||||
* ([ConcordInviteVend.adoptCatchUp] with `only`), never [entry] itself, which is a snapshot.
|
||||
*/
|
||||
class CatchUp(
|
||||
val entry: ConcordCommunityListEntry,
|
||||
val channelIds: List<HexKey>,
|
||||
) : DirectInviteAcceptPlan
|
||||
|
||||
/** A held community the bundle adds nothing to (or can't: a different base, or dissolved). */
|
||||
@@ -96,23 +116,37 @@ sealed interface DirectInviteAcceptPlan {
|
||||
* Wraps arrive from anywhere — a `{"kinds":[1059],"#p":[me],"#k":["3313"]}` sweep
|
||||
* ([com.vitorpamplona.amethyst.commons.actions.ConcordActions.directInvitesFilter]), or the general
|
||||
* NIP-17 giftwrap pipeline, which honours an untagged invite all the same — and are [offer]ed here.
|
||||
* The inbox opens each wrap once (two NIP-44 decrypts), dedupes by wrap id, drops a wrap whose NIP-40
|
||||
* `expiration` has passed, validates the bundle exactly like a fetched one, and parks it in
|
||||
* [pending]. **Nothing** else happens: no relay connection, no icon fetch, no Join, until the user
|
||||
* accepts (the caller's join path) or [decline]s.
|
||||
* The inbox opens each wrap once (two NIP-44 decrypts; none more when the DM pipeline already
|
||||
* unsealed it, [offerRumor]), dedupes by wrap id, drops a wrap whose NIP-40 `expiration` has passed,
|
||||
* validates the bundle exactly like a fetched one, and parks it in [pending]. **Nothing** else
|
||||
* happens: no relay connection, no icon fetch, no Join, until the user accepts (the caller's join
|
||||
* path) or [decline]s.
|
||||
*
|
||||
* Declined wrap ids are remembered ([declined], restorable via [restoreDeclined]) so a re-delivered
|
||||
* wrap never resurfaces. [newestWrapCreatedAt] is the sweep cursor; query from [since], which
|
||||
* rewinds it by NIP-59's two-day backdate window.
|
||||
* A wrap is written off ([seen]) only on a definitive outcome — opened, not an invite for us, or
|
||||
* expired. A signer that could not answer (timed out, busy, not approved) leaves it to be retried by
|
||||
* the next delivery or sweep.
|
||||
*
|
||||
* Bounded: at most [MAX_PENDING] invites are parked; past it the lowest-ranked one goes (a sender
|
||||
* [isFollowed] outranks a stranger, then newer outranks older). Invites from senders [isHidden]
|
||||
* (muted or blocked) are never parked.
|
||||
*
|
||||
* Declined wrap ids are remembered ([declined], restorable via [restoreDeclined], at most
|
||||
* [DECLINED_CAP]) so a re-delivered wrap never resurfaces. [newestWrapCreatedAt] is the sweep cursor;
|
||||
* query from [since], which rewinds it by NIP-59's two-day backdate window.
|
||||
*/
|
||||
class ConcordDirectInviteInbox(
|
||||
private val signer: NostrSigner,
|
||||
private val isHidden: (HexKey) -> Boolean = { false },
|
||||
private val isFollowed: (HexKey) -> Boolean = { false },
|
||||
) {
|
||||
private val mutex = Mutex()
|
||||
|
||||
/** Wrap ids already handled this session (opened, refused, or expired), oldest first. */
|
||||
/** Wrap ids with a definitive outcome this session (opened, refused, or expired), oldest first. */
|
||||
private val seen = LinkedHashSet<HexKey>()
|
||||
|
||||
/** Wrap ids being opened right now, so a concurrent delivery of the same wrap is not decrypted twice. */
|
||||
private val inFlight = HashSet<HexKey>()
|
||||
|
||||
private val _pending = MutableStateFlow<Map<HexKey, OpenedDirectInvite>>(emptyMap())
|
||||
|
||||
/** Parked invites by wrap id, as opened. See [visible] for what a UI should show. */
|
||||
@@ -120,7 +154,7 @@ class ConcordDirectInviteInbox(
|
||||
|
||||
private val _declined = MutableStateFlow<Set<HexKey>>(emptySet())
|
||||
|
||||
/** Wrap ids the user declined; persisted by the front end so they stay declined across restarts. */
|
||||
/** Wrap ids the user declined, oldest first; persisted by the front end so they stay declined across restarts. */
|
||||
val declined: StateFlow<Set<HexKey>> = _declined.asStateFlow()
|
||||
|
||||
/** The newest wrap `created_at` offered so far (the sweep cursor), or null on a cold inbox. */
|
||||
@@ -131,21 +165,27 @@ class ConcordDirectInviteInbox(
|
||||
/** The `since` for the next sweep: the cursor rewound by the backdate window (null = everything). */
|
||||
fun since(): Long? = ConcordDirectInvite.inboxSince(newestWrapCreatedAt)
|
||||
|
||||
/** Replaces the declined set — used to restore it from disk at startup. Drops any pending one. */
|
||||
fun restoreDeclined(wrapIds: Set<HexKey>) {
|
||||
_declined.value = wrapIds
|
||||
/**
|
||||
* Replaces the declined set — used to restore it from disk at startup — keeping the newest
|
||||
* [DECLINED_CAP]. Drops any pending one. Serialized with [offer] so a restore can't race a park.
|
||||
*/
|
||||
suspend fun restoreDeclined(wrapIds: Set<HexKey>) {
|
||||
mutex.withLock {
|
||||
_declined.value = bounded(wrapIds)
|
||||
_pending.update { current -> current.filterKeys { it !in wrapIds } }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Considers one kind-1059 [wrap] addressed to us. Returns the parked invite (new or already
|
||||
* pending), or null when it isn't one: not a direct invite for us, a forgery, an invalid
|
||||
* bundle, an expired handoff, or a wrap the user already declined. Never throws.
|
||||
* bundle, an expired handoff, a hidden sender, a wrap the user already declined — or a signer
|
||||
* that could not answer now (retried on the next offer). Never throws but for cancellation.
|
||||
*/
|
||||
suspend fun offer(
|
||||
wrap: Event,
|
||||
nowSecs: Long = TimeUtils.now(),
|
||||
): OpenedDirectInvite? = admit(wrap, nowSecs) { ConcordDirectInvite.open(wrap, signer) }
|
||||
): OpenedDirectInvite? = admit(wrap, nowSecs) { ConcordDirectInvite.openOrRetry(wrap, signer) }
|
||||
|
||||
/**
|
||||
* [offer] for a pipeline that already peeled [wrap] down to its kind-13 [seal] (the NIP-17
|
||||
@@ -156,7 +196,27 @@ class ConcordDirectInviteInbox(
|
||||
wrap: Event,
|
||||
seal: Event,
|
||||
nowSecs: Long = TimeUtils.now(),
|
||||
): OpenedDirectInvite? = admit(wrap, nowSecs) { ConcordDirectInvite.openSeal(wrap.id, seal, signer) }
|
||||
): OpenedDirectInvite? = admit(wrap, nowSecs) { ConcordDirectInvite.openSealOrRetry(wrap.id, seal, signer) }
|
||||
|
||||
/**
|
||||
* [offerSeal] for a pipeline that already decrypted [seal] into [rumor] (the rumor as the seal
|
||||
* carries it, its claimed author intact — [SealEvent.unsealRumorThrowing]): validated without
|
||||
* any further decrypt.
|
||||
*/
|
||||
suspend fun offerRumor(
|
||||
wrap: Event,
|
||||
seal: Event,
|
||||
rumor: Rumor,
|
||||
nowSecs: Long = TimeUtils.now(),
|
||||
): OpenedDirectInvite? = admit(wrap, nowSecs) { ConcordDirectInvite.openRumor(wrap.id, seal, rumor) }
|
||||
|
||||
/**
|
||||
* Records [wrapId] as definitively not an invite for us (it failed to open for a reason no retry
|
||||
* changes, or opened to something else), so a sweep that fetches it again skips the decrypt.
|
||||
*/
|
||||
suspend fun markNotInvite(wrapId: HexKey) {
|
||||
mutex.withLock { if (wrapId !in _pending.value) remember(wrapId) }
|
||||
}
|
||||
|
||||
private suspend fun admit(
|
||||
wrap: Event,
|
||||
@@ -165,20 +225,54 @@ class ConcordDirectInviteInbox(
|
||||
): OpenedDirectInvite? {
|
||||
if (wrap.kind != GiftWrapEvent.KIND) return null
|
||||
mutex.withLock {
|
||||
// The cursor only advances to a time that has happened (plus the skew allowance): a
|
||||
// future-dated wrap would otherwise push `since` past every invite sent until then.
|
||||
val stamp = minOf(wrap.createdAt, nowSecs + FUTURE_SKEW_SECS)
|
||||
val newest = newestWrapCreatedAt
|
||||
if (newest == null || wrap.createdAt > newest) newestWrapCreatedAt = wrap.createdAt
|
||||
if (newest == null || stamp > newest) newestWrapCreatedAt = stamp
|
||||
_pending.value[wrap.id]?.let { return it }
|
||||
if (wrap.id in _declined.value || wrap.id in seen) return null
|
||||
remember(wrap.id)
|
||||
if (wrap.id in _declined.value || wrap.id in seen || wrap.id in inFlight) return null
|
||||
inFlight.add(wrap.id)
|
||||
}
|
||||
try {
|
||||
// An expired handoff is never decrypted or surfaced (NIP-40 on the wrap mirrors expires_at).
|
||||
if (ConcordDirectInvite.isWrapExpired(wrap, nowSecs)) return null
|
||||
val opened = open() ?: return null
|
||||
mutex.withLock {
|
||||
if (wrap.id in _declined.value) return null
|
||||
_pending.update { it + (wrap.id to opened) }
|
||||
if (ConcordDirectInvite.isWrapExpired(wrap, nowSecs)) {
|
||||
mutex.withLock { remember(wrap.id) }
|
||||
return null
|
||||
}
|
||||
return opened
|
||||
val opened =
|
||||
try {
|
||||
open()
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (_: Exception) {
|
||||
// The signer could not answer now: not written off, so the next offer retries it.
|
||||
return null
|
||||
}
|
||||
mutex.withLock {
|
||||
remember(wrap.id)
|
||||
if (opened == null || wrap.id in _declined.value) return null
|
||||
// Muted or blocked senders never reach the inbox.
|
||||
if (isHidden(opened.sender)) return null
|
||||
_pending.update { park(it, opened, nowSecs) }
|
||||
}
|
||||
return _pending.value[wrap.id]
|
||||
} finally {
|
||||
mutex.withLock { inFlight.remove(wrap.id) }
|
||||
}
|
||||
}
|
||||
|
||||
/** [current] plus [opened], shedding the lowest-ranked invite past [MAX_PENDING]. */
|
||||
private fun park(
|
||||
current: Map<HexKey, OpenedDirectInvite>,
|
||||
opened: OpenedDirectInvite,
|
||||
nowSecs: Long,
|
||||
): Map<HexKey, OpenedDirectInvite> {
|
||||
val next = current + (opened.wrapId to opened)
|
||||
if (next.size <= MAX_PENDING) return next
|
||||
val rank = compareBy<OpenedDirectInvite>({ isFollowed(it.sender) }, { clampedSentAt(it, nowSecs) }, { it.wrapId })
|
||||
val drop = next.values.minWithOrNull(rank) ?: return next
|
||||
return next - drop.wrapId
|
||||
}
|
||||
|
||||
/** The parked invite behind [wrapId], if any. */
|
||||
@@ -188,7 +282,7 @@ class ConcordDirectInviteInbox(
|
||||
fun decline(wrapId: HexKey): Boolean {
|
||||
val id = get(wrapId)?.wrapId ?: return false
|
||||
_pending.update { it - id }
|
||||
_declined.update { it + id }
|
||||
_declined.update { bounded(it + id) }
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -209,6 +303,23 @@ class ConcordDirectInviteInbox(
|
||||
/** Cap on remembered wrap ids; the oldest half is shed past it (a sweep re-dedupes deeper). */
|
||||
const val SEEN_CAP = 4096
|
||||
|
||||
/** Cap on parked invites (the reference client's bound): a flood can't grow the inbox without end. */
|
||||
const val MAX_PENDING = 256
|
||||
|
||||
/** Cap on remembered declines, newest kept: a declined wrap older than that has long expired or been buried. */
|
||||
const val DECLINED_CAP = 4096
|
||||
|
||||
/** Clock skew tolerated on a wrap's `created_at` before it stops moving the sweep cursor. */
|
||||
const val FUTURE_SKEW_SECS = 15 * 60L
|
||||
|
||||
private fun bounded(ids: Set<HexKey>): Set<HexKey> = if (ids.size <= DECLINED_CAP) ids else ids.toList().takeLast(DECLINED_CAP).toCollection(LinkedHashSet())
|
||||
|
||||
/** [opened]'s `sentAt` (the sender's word) clamped to [nowSecs]: a future date buys no rank. */
|
||||
fun clampedSentAt(
|
||||
opened: OpenedDirectInvite,
|
||||
nowSecs: Long,
|
||||
): Long = minOf(opened.sentAt, nowSecs)
|
||||
|
||||
/**
|
||||
* What accepting [opened] should do (CORD-05 §6), given the community entry this account
|
||||
* already [held] (if any) and its folded [heldState]:
|
||||
@@ -216,9 +327,11 @@ class ConcordDirectInviteInbox(
|
||||
* - not held → [DirectInviteAcceptPlan.Join] (the shared join path, which still ban-gates
|
||||
* against the community's own Control Plane);
|
||||
* - held on the SAME base with new Private Channel keys → [DirectInviteAcceptPlan.CatchUp],
|
||||
* the held entry with only those keys merged in — never moving the base (Armada
|
||||
* `catchUpChannelIds`) — unless the held roster bans [me]; refused while the roster isn't
|
||||
* folded ([DirectInviteAcceptPlan.RosterNotLoaded]);
|
||||
* the held entry with only those keys ADDED — never moving the base, never replacing a
|
||||
* held key (Armada `catchUpChannelIds`) — and only from a sender who is staff in the held
|
||||
* fold, for channels it knows as live Private Channels
|
||||
* ([ConcordInviteVend.admissibleCatchUpIds]); refused when the held roster bans [me], and
|
||||
* while it isn't folded ([DirectInviteAcceptPlan.RosterNotLoaded]);
|
||||
* - held otherwise (nothing new, a different base, dissolved) → [DirectInviteAcceptPlan.NothingNew].
|
||||
*/
|
||||
fun acceptPlan(
|
||||
@@ -230,49 +343,83 @@ class ConcordDirectInviteInbox(
|
||||
): DirectInviteAcceptPlan {
|
||||
if (opened.isExpired(nowMs)) return DirectInviteAcceptPlan.Expired
|
||||
if (held == null) return DirectInviteAcceptPlan.Join
|
||||
val adopted = ConcordInviteVend.adoptCatchUp(held, opened.invite) ?: return DirectInviteAcceptPlan.NothingNew
|
||||
if (ConcordInviteVend.catchUpChannelIds(held, opened.invite).isEmpty()) return DirectInviteAcceptPlan.NothingNew
|
||||
if (heldState == null) return DirectInviteAcceptPlan.RosterNotLoaded
|
||||
// Death wins every race (CORD-02 §9): a dissolved community takes no new keys.
|
||||
if (heldState.dissolved) return DirectInviteAcceptPlan.NothingNew
|
||||
if (heldState.authority.isBanned(me)) return DirectInviteAcceptPlan.Banned
|
||||
return DirectInviteAcceptPlan.CatchUp(adopted)
|
||||
val ids = ConcordInviteVend.admissibleCatchUpIds(held, opened.invite, heldState.authority, heldState.privateChannelIds, opened.sender)
|
||||
if (ids.isEmpty()) return DirectInviteAcceptPlan.NothingNew
|
||||
val adopted = ConcordInviteVend.adoptCatchUp(held, opened.invite, ids) ?: return DirectInviteAcceptPlan.NothingNew
|
||||
return DirectInviteAcceptPlan.CatchUp(adopted, ids)
|
||||
}
|
||||
|
||||
/**
|
||||
* What a UI shows out of [pending], given the communities this account already holds
|
||||
* ([joined]): newest first, with
|
||||
* ([joined]) and the ones it left ([removedAt], community id → the Community List
|
||||
* tombstone's `removed_at` in unix ms): followed senders first, then newest first, with
|
||||
* - an invite for a community already held on the SAME base that carries a Private Channel
|
||||
* key it lacks kept as a [ConcordDirectInviteView.catchUp];
|
||||
* - any other invite for a held community (nothing new, or a different base — which may
|
||||
* never move the held one) hidden;
|
||||
* - one invite per community (newest `sentAt`, ties by wrap id), catch-ups keyed by their
|
||||
* - an invite sent at or before the user left that community hidden (it would otherwise
|
||||
* resurface right after leaving; a fresh re-invite still shows — Armada `tombstonedAt`);
|
||||
* - invites from [isHidden] (muted/blocked) senders hidden;
|
||||
* - one invite per community and sender (newest clamped `sentAt`, ties by wrap id), so a
|
||||
* future-dated invite can only ever shadow its own sender's; catch-ups keyed by their
|
||||
* channel set too since each may vend a key no other wrap carries (Armada
|
||||
* `dedupeParkedInvites`).
|
||||
* `dedupeParkedInvites`). `sentAt` is the sender's word, so it is clamped to now for both
|
||||
* ordering and the tombstone check.
|
||||
*/
|
||||
fun visible(
|
||||
pending: Collection<OpenedDirectInvite>,
|
||||
joined: List<ConcordCommunityListEntry>,
|
||||
nowMs: Long = TimeUtils.nowMillis(),
|
||||
removedAt: Map<String, Long> = emptyMap(),
|
||||
isFollowed: (HexKey) -> Boolean = { false },
|
||||
isHidden: (HexKey) -> Boolean = { false },
|
||||
heldStateOf: (communityId: HexKey) -> ConcordCommunityState? = { null },
|
||||
): List<ConcordDirectInviteView> {
|
||||
val nowSecs = nowMs / 1000
|
||||
val heldById = joined.associateBy { it.id.lowercase() }
|
||||
val removedById = removedAt.mapKeys { it.key.lowercase() }
|
||||
val byKey = LinkedHashMap<String, ConcordDirectInviteView>()
|
||||
for (opened in pending) {
|
||||
if (isHidden(opened.sender)) continue
|
||||
val communityId = opened.invite.communityId.lowercase()
|
||||
val sentAt = clampedSentAt(opened, nowSecs)
|
||||
val buriedAt = removedById[communityId]
|
||||
if (buriedAt != null && sentAt * 1000 <= buriedAt) continue
|
||||
val held = heldById[communityId]
|
||||
val newChannels = ConcordInviteVend.catchUpChannelIds(held, opened.invite)
|
||||
// For a held community whose fold is in, only what accepting would actually adopt:
|
||||
// a catch-up from a non-staff sender, for channels the fold doesn't know as Private,
|
||||
// or into a dissolved community is refused by [acceptPlan], so it is not offered.
|
||||
val heldState = held?.let { heldStateOf(it.id) }
|
||||
val newChannels =
|
||||
when {
|
||||
held == null -> emptyList()
|
||||
heldState == null -> ConcordInviteVend.catchUpChannelIds(held, opened.invite)
|
||||
heldState.dissolved -> emptyList()
|
||||
else -> ConcordInviteVend.admissibleCatchUpIds(held, opened.invite, heldState.authority, heldState.privateChannelIds, opened.sender)
|
||||
}
|
||||
if (held != null && newChannels.isEmpty()) continue
|
||||
val catchUp = held != null
|
||||
val key = if (catchUp) communityId + "|" + newChannels.sorted().joinToString(",") else communityId
|
||||
val view = ConcordDirectInviteView(opened, catchUp, opened.isExpired(nowMs))
|
||||
val base = communityId + "|" + opened.sender.lowercase()
|
||||
val key = if (catchUp) base + "|" + newChannels.sorted().joinToString(",") else base
|
||||
val view = ConcordDirectInviteView(opened, catchUp, opened.isExpired(nowMs), newChannels.toList(), sentAt, isFollowed(opened.sender))
|
||||
val existing = byKey[key]
|
||||
if (existing == null ||
|
||||
opened.sentAt > existing.opened.sentAt ||
|
||||
(opened.sentAt == existing.opened.sentAt && opened.wrapId < existing.opened.wrapId)
|
||||
sentAt > existing.clampedSentAt ||
|
||||
(sentAt == existing.clampedSentAt && opened.wrapId < existing.opened.wrapId)
|
||||
) {
|
||||
byKey[key] = view
|
||||
}
|
||||
}
|
||||
return byKey.values.sortedWith(compareByDescending<ConcordDirectInviteView> { it.opened.sentAt }.thenBy { it.wrapId })
|
||||
return byKey.values.sortedWith(
|
||||
compareByDescending<ConcordDirectInviteView> { it.followedSender }
|
||||
.thenByDescending { it.clampedSentAt }
|
||||
.thenBy { it.wrapId },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+34
@@ -0,0 +1,34 @@
|
||||
/*
|
||||
* 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.model.concord
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
|
||||
/**
|
||||
* This account complied with an honored Kick (CORD-04 §6) and left [communityId] locally.
|
||||
* [communityName] is the folded name at the time (null when unnamed), for the user notice;
|
||||
* [kickedBy] is the kicker. A Kick is re-joinable: a new invite brings the member back.
|
||||
*/
|
||||
class ConcordKickNotice(
|
||||
val communityId: String,
|
||||
val communityName: String?,
|
||||
val kickedBy: HexKey,
|
||||
)
|
||||
+96
@@ -0,0 +1,96 @@
|
||||
/*
|
||||
* 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.model.concord
|
||||
|
||||
import com.vitorpamplona.amethyst.commons.actions.ConcordChannelPins
|
||||
import com.vitorpamplona.amethyst.commons.actions.ConcordPinning
|
||||
import com.vitorpamplona.amethyst.commons.util.KmpLock
|
||||
import com.vitorpamplona.amethyst.commons.util.withLock
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/**
|
||||
* Runs the delayed Pin List duties a PIN_MESSAGES holder owes keyless readers (CORD-04 §7) — the
|
||||
* deletion omission and the Edit refresh — from the account, not from an open channel screen, so a
|
||||
* debt is settled whether or not anyone is looking at the channel.
|
||||
*
|
||||
* Rate-limited the way the spec asks: each duty waits a short random delay
|
||||
* ([ConcordPinning.dutyDelayMs], 3–15 s) and the caller's settle re-reads before publishing, so
|
||||
* simultaneous curators collapse to one publisher and a burst of deletes or edits costs one write;
|
||||
* at most one duty per channel is in flight; and each distinct debt is attempted **once**, so a
|
||||
* write that keeps failing never spins. A new debt (another delete, a newer Edit) is a new attempt.
|
||||
*/
|
||||
class ConcordPinDutyScheduler(
|
||||
private val delayMs: () -> Long = { ConcordPinning.dutyDelayMs() },
|
||||
) {
|
||||
private val lock = KmpLock()
|
||||
|
||||
// Channel key -> the debt last attempted there.
|
||||
private val attempted = HashMap<String, String>()
|
||||
|
||||
// Channel key -> its duty in flight.
|
||||
private val inFlight = HashMap<String, Job>()
|
||||
|
||||
/**
|
||||
* Schedules [settle] in [scope] for the channel [key] when [debt] is non-null, was not attempted
|
||||
* yet, and no duty for [key] is in flight. Returns whether it scheduled one.
|
||||
*/
|
||||
fun schedule(
|
||||
scope: CoroutineScope,
|
||||
key: String,
|
||||
debt: String?,
|
||||
settle: suspend () -> Unit,
|
||||
): Boolean {
|
||||
if (debt == null) return false
|
||||
return lock.withLock {
|
||||
if (attempted[key] == debt || inFlight[key]?.isActive == true) return@withLock false
|
||||
attempted[key] = debt
|
||||
inFlight[key] =
|
||||
scope.launch {
|
||||
delay(delayMs())
|
||||
try {
|
||||
settle()
|
||||
} finally {
|
||||
lock.withLock { inFlight.remove(key) }
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
/** The identity of what [pins] owes (its erased entries and the Edits to attach), or null when nothing. */
|
||||
fun debtOf(pins: ConcordChannelPins?): String? {
|
||||
if (pins == null || !pins.owesRepublish) return null
|
||||
return (pins.killed.map { "d" + it.rumorId } + pins.pins.mapNotNull { p -> p.newerEdit?.let { "e" + it.rumorId } })
|
||||
.sorted()
|
||||
.joinToString("|")
|
||||
}
|
||||
|
||||
/** The scheduler key of one channel. */
|
||||
fun keyOf(
|
||||
communityId: String,
|
||||
channelIdHex: String,
|
||||
): String = "$communityId|$channelIdHex"
|
||||
}
|
||||
}
|
||||
+7
@@ -79,6 +79,9 @@ class ConcordSessionManager(
|
||||
*/
|
||||
val nextExpiry: StateFlow<Long?> = _nextExpiry
|
||||
|
||||
// Guards the compute-and-assign of [nextExpiry]. Declared before `init` for the same reason.
|
||||
private val expiryLock = KmpLock()
|
||||
|
||||
private val lock = KmpLock()
|
||||
private val stateWatchers = HashMap<HexKey, Job>() // communityId -> state collector
|
||||
|
||||
@@ -118,8 +121,12 @@ class ConcordSessionManager(
|
||||
}
|
||||
|
||||
private fun recomputeNextExpiry() {
|
||||
// Computed and assigned under one lock: two watchers racing could otherwise let a slower,
|
||||
// staler computation overwrite an earlier deadline, and the sweep would sleep past it.
|
||||
expiryLock.withLock {
|
||||
_nextExpiry.value = registry.sessions().mapNotNull { it.nextExpiry.value }.minOrNull()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sweeps every session for rumors expired at [now] (CORD-08 §3): drops their wraps from the
|
||||
|
||||
+4
-1
@@ -76,7 +76,10 @@ class ConcordSessionRegistry(
|
||||
for ((id, entry) in wanted) {
|
||||
val existing = sessions[id]
|
||||
if (existing == null || existing.entry.root != entry.root || existing.entry.rootEpoch != entry.rootEpoch) {
|
||||
sessions[id] = ConcordCommunitySession(entry, myPubKey, onRumor)
|
||||
// CORD-08 §3: the disappearing messages the old session tracked are already in the
|
||||
// store, and the new session never sees their wraps again — carry their deadlines
|
||||
// over, or nothing would ever purge them.
|
||||
sessions[id] = ConcordCommunitySession(entry, myPubKey, onRumor).also { fresh -> existing?.let { fresh.carryExpiring(it.trackedExpiring()) } }
|
||||
created += id
|
||||
} else {
|
||||
// Same epoch, but the Control Plane write key may have just arrived — a
|
||||
|
||||
+5
@@ -49,6 +49,11 @@ class EncryptionKeyCache {
|
||||
) = add(url, DecryptInformation(cipher, expectedMimeType))
|
||||
|
||||
fun get(url: String): DecryptInformation? = cache.get(url)
|
||||
|
||||
/** Forgets [url]'s key, e.g. when the message that carried it expired (CORD-08). */
|
||||
fun remove(url: String) {
|
||||
cache.remove(url)
|
||||
}
|
||||
}
|
||||
|
||||
class DecryptInformation(
|
||||
|
||||
+26
@@ -118,4 +118,30 @@ class ConcordInviteRegistryPublishTest {
|
||||
add(ConcordModeration.setInviteRegistry(inviter, cp, cid, emptyList(), editions, createdAt = 7L, owner = community.ownerPubKey))
|
||||
assertFalse(fold().isPublic)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aRegistryEditChainsOntoTheFloorAwareHeadAcrossARefounding() =
|
||||
runTest {
|
||||
// The prior epoch reached v2 of the owner's registry; the current epoch has not (yet)
|
||||
// re-wrapped it, so the honored head is the floor, not "no registry".
|
||||
val community = ConcordCommunityFactory.create(owner, "Nostrichs", createdAt = 1L, relays = listOf("wss://r.example"))
|
||||
val cp = community.controlPlane
|
||||
val cid = community.communityId
|
||||
val prior = ConcordActions.controlEditions(community.genesisWraps, cp).toMutableList()
|
||||
prior += ConcordActions.controlEditions(listOf(ConcordModeration.setInviteRegistry(owner, cp, cid, listOf(link1), prior, 2L, owner = community.ownerPubKey)), cp)
|
||||
prior += ConcordActions.controlEditions(listOf(ConcordModeration.setInviteRegistry(owner, cp, cid, listOf(link1, link2), prior, 3L, owner = community.ownerPubKey)), cp)
|
||||
val v2 = prior.last()
|
||||
assertEquals(2L, v2.version)
|
||||
val floors = ConcordCommunityState.authorizedHeads(prior, cid, community.ownerPubKey)
|
||||
|
||||
val current = ConcordActions.controlEditions(community.genesisWraps, cp)
|
||||
val naive = ConcordActions.controlEditions(listOf(ConcordModeration.setInviteRegistry(owner, cp, cid, listOf(link2), current, 4L, owner = community.ownerPubKey)), cp).single()
|
||||
assertEquals(1L, naive.version, "ignoring the floor forks a fresh v1 below the honored v2")
|
||||
|
||||
val chained = ConcordActions.controlEditions(listOf(ConcordModeration.setInviteRegistry(owner, cp, cid, listOf(link2), current, 4L, owner = community.ownerPubKey, floors = floors)), cp).single()
|
||||
assertEquals(3L, chained.version)
|
||||
assertEquals(v2.hashHex, chained.prevHash?.toHexKey())
|
||||
// And the fold with the same floors honors it.
|
||||
assertEquals(listOf(link2), ConcordCommunityState.fold(current + chained, cid, community.ownerPubKey, floors).registryOf(owner.pubKey))
|
||||
}
|
||||
}
|
||||
|
||||
+113
@@ -0,0 +1,113 @@
|
||||
/*
|
||||
* 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.actions
|
||||
|
||||
import com.vitorpamplona.amethyst.commons.model.concord.ConcordCommunitySession
|
||||
import com.vitorpamplona.amethyst.commons.model.nip92IMeta.appendMissingImetaUrls
|
||||
import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityFactory
|
||||
import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEntry
|
||||
import com.vitorpamplona.quartz.concord.cord03Channels.ChannelChat
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal
|
||||
import com.vitorpamplona.quartz.utils.ciphers.AESGCM
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertContentEquals
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertNotNull
|
||||
|
||||
/**
|
||||
* A pinned message carrying an encrypted attachment renders like the feed message it pins (CORD-04 §7
|
||||
* SHOULD): the rumor rebuilt from the verified proof keeps the `imeta` tags — so the attachment's
|
||||
* decryption key reaches the media pipeline even when this account never held the message — and an
|
||||
* attachment-only message still gets its URL as text, as the feed gives it.
|
||||
*/
|
||||
class ConcordPinnedMediaTest {
|
||||
private val owner = NostrSignerInternal(KeyPair())
|
||||
private val alice = NostrSignerInternal(KeyPair())
|
||||
|
||||
@Test
|
||||
fun aPinnedImageKeepsItsEncryptedAttachment() =
|
||||
runTest {
|
||||
val community = ConcordCommunityFactory.create(owner, "Nostrichs", createdAt = 1L, relays = listOf("wss://r.example"))
|
||||
val entry =
|
||||
ConcordCommunityListEntry(
|
||||
id = community.communityIdHex,
|
||||
owner = community.ownerPubKey,
|
||||
ownerSalt = community.ownerSalt.toHexKey(),
|
||||
root = community.communityRoot.toHexKey(),
|
||||
rootEpoch = community.rootEpoch,
|
||||
controlPk = community.controlPkHex,
|
||||
controlRoot = community.controlRoot.toHexKey(),
|
||||
relays = listOf("wss://r.example"),
|
||||
name = "Nostrichs",
|
||||
)
|
||||
val rumors = mutableListOf<Event>()
|
||||
val session = ConcordCommunitySession(entry, owner.pubKey) { _, _, rumor, _ -> rumors += rumor }
|
||||
community.genesisWraps.forEach { session.ingest(it) }
|
||||
// The genesis wraps are the whole plane; pin writes wait for a drained fold (CORD-04 §7).
|
||||
session.markControlDrained()
|
||||
val general = community.generalChannelIdHex
|
||||
val plane = assertNotNull(session.currentChannelPlane(general))
|
||||
|
||||
val url = "https://blossom.example/ciphertext.bin"
|
||||
val cipher = AESGCM(ByteArray(32) { 0x11 }, ByteArray(16) { 0x22 })
|
||||
val imeta = ChannelChat.encryptedImageImeta(url, "image/jpeg", "800x600", null, cipher, "aa".repeat(32))
|
||||
// An attachment-only message: empty words, the image only in its imeta (Armada allows it).
|
||||
session.ingest(ConcordActions.buildChannelImageMessage(alice, plane.key, general, plane.epoch, "", listOf(imeta), 10L))
|
||||
val message = rumors.last()
|
||||
|
||||
fun ctx(pins: ConcordChannelPins) =
|
||||
ConcordPinContext(
|
||||
actor = owner,
|
||||
controlPlane = session.controlPlaneKeys(),
|
||||
communityId = community.communityId,
|
||||
owner = community.ownerPubKey,
|
||||
current = session.controlEditions(),
|
||||
channelIdHex = general,
|
||||
channelIsPrivate = false,
|
||||
currentPlane = plane,
|
||||
pins = pins,
|
||||
authorized = true,
|
||||
)
|
||||
|
||||
val evidence = ConcordPinEvidence(rumors)
|
||||
val before = assertNotNull(session.readPins(general, evidence::isKilled, evidence::newestEdit))
|
||||
val write = ConcordPinning.pin(ctx(before), assertNotNull(session.pinSource(general, message.id)), 11L)
|
||||
session.ingest(assertNotNull(write.wrap))
|
||||
|
||||
val pinned = assertNotNull(session.readPins(general, evidence::isKilled, evidence::newestEdit)).pins.single()
|
||||
val rumor = pinned.toRumor()
|
||||
assertEquals(message.id, rumor.id)
|
||||
assertEquals(alice.pubKey, rumor.pubKey)
|
||||
|
||||
val attachment = ChannelChat.encryptedImagesOf(rumor).single()
|
||||
assertEquals(url, attachment.url)
|
||||
assertContentEquals(cipher.keyBytes, attachment.key)
|
||||
assertContentEquals(cipher.nonce, attachment.nonce)
|
||||
// The words carry the ciphertext URL (Armada's assembly), so the shared renderer shows it.
|
||||
assertEquals(url, rumor.content)
|
||||
// A client that sent only the imeta (empty words) still renders it: the feed's fallback.
|
||||
assertEquals(url, appendMissingImetaUrls("", rumor))
|
||||
}
|
||||
}
|
||||
+56
-1
@@ -76,9 +76,10 @@ class ConcordPinningTest {
|
||||
val community: NewConcordCommunity,
|
||||
entry: ConcordCommunityListEntry,
|
||||
me: HexKey,
|
||||
drained: Boolean = true,
|
||||
) {
|
||||
val rumors = mutableListOf<Event>()
|
||||
val session = ConcordCommunitySession(entry, me) { _, _, rumor, _ -> rumors += rumor }
|
||||
val session = ConcordCommunitySession(entry, me) { _, _, rumor, _ -> rumors += rumor }.also { if (drained) it.markControlDrained() }
|
||||
|
||||
fun pins(channelIdHex: HexKey) = ConcordPinEvidence(rumors).let { evidence -> assertNotNull(session.readPins(channelIdHex, evidence::isKilled, evidence::newestEdit)) }
|
||||
|
||||
@@ -139,6 +140,60 @@ class ConcordPinningTest {
|
||||
return h
|
||||
}
|
||||
|
||||
@Test
|
||||
fun noPinWriteIsBuiltBeforeTheControlPlaneHasDrained() =
|
||||
runTest {
|
||||
val h = harness()
|
||||
val general = h.community.generalChannelIdHex
|
||||
val message = h.post(alice, general, "ship it", 10L)
|
||||
assertEquals(ConcordPinOutcome.PUBLISHED, h.pin(owner, general, message, 11L).outcome)
|
||||
|
||||
// A second device that has folded only part of the plane: here the genesis without the pin.
|
||||
val partial = Harness(h.community, entryFor(h.community), owner.pubKey, drained = false)
|
||||
h.community.genesisWraps.forEach { partial.session.ingest(it) }
|
||||
val read = partial.pins(general)
|
||||
assertFalse(read.complete, "no head yet reads as not-yet-served, not as an empty list")
|
||||
assertNull(read.head)
|
||||
|
||||
// A replace-entire write from that read would erase the pin it never saw (§7).
|
||||
val refused = ConcordPinning.unpin(partial.ctx(owner, general), message.id, 12L)
|
||||
assertEquals(ConcordPinOutcome.NOT_FOLDED, refused.outcome)
|
||||
assertNull(refused.wrap)
|
||||
|
||||
// Once the plane is drained (the pin landed), writes proceed from the full list.
|
||||
h.session.controlPlaneWraps().forEach { partial.session.ingest(it) }
|
||||
partial.session.markControlDrained()
|
||||
assertTrue(partial.pins(general).complete)
|
||||
assertEquals(ConcordPinOutcome.PUBLISHED, ConcordPinning.unpin(partial.ctx(owner, general), message.id, 12L).outcome)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aPinThatLosesAConcurrentTieReappliesOnTopOfTheWinner() =
|
||||
runTest {
|
||||
val h = harness()
|
||||
val general = h.community.generalChannelIdHex
|
||||
val one = h.post(alice, general, "one", 10L)
|
||||
val two = h.post(alice, general, "two", 11L)
|
||||
|
||||
// Two curators read the same (empty) head and each write v1 at once.
|
||||
val ctx = h.ctx(owner, general)
|
||||
val a = ConcordPinning.pin(ctx, h.session.pinSource(general, one.id)!!, 12L)
|
||||
val b = ConcordPinning.pin(ctx, h.session.pinSource(general, two.id)!!, 12L)
|
||||
h.session.ingest(a.wrap!!)
|
||||
h.session.ingest(b.wrap!!)
|
||||
val folded = h.pins(general)
|
||||
assertTrue(folded.isPinned(one.id) xor folded.isPinned(two.id), "one edition wins the tie, the other's pin is gone")
|
||||
val loser = if (folded.isPinned(one.id)) two else one
|
||||
|
||||
// The re-heal: the loser runs its write again on the refolded head, chaining onto the winner.
|
||||
val heal = ConcordPinning.pin(h.ctx(owner, general), h.session.pinSource(general, loser.id)!!, 13L)
|
||||
assertEquals(ConcordPinOutcome.PUBLISHED, heal.outcome)
|
||||
h.session.ingest(heal.wrap!!)
|
||||
val healed = h.pins(general)
|
||||
assertTrue(healed.isPinned(one.id) && healed.isPinned(two.id))
|
||||
assertEquals(2L, healed.head!!.version)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aPinRoundTripsThroughTheControlPlaneAndUnpinRemovesIt() =
|
||||
runTest {
|
||||
|
||||
+237
@@ -0,0 +1,237 @@
|
||||
/*
|
||||
* 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.actions
|
||||
|
||||
import com.vitorpamplona.amethyst.commons.model.ConcordDirectInviteDraft
|
||||
import com.vitorpamplona.amethyst.commons.model.concord.ConcordCommunitySession
|
||||
import com.vitorpamplona.amethyst.commons.model.concord.ConcordIngestOutcome
|
||||
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.NewConcordCommunity
|
||||
import com.vitorpamplona.quartz.concord.cord02Community.PrivateChannelKey
|
||||
import com.vitorpamplona.quartz.concord.cord03Channels.ConcordChannelKeyring
|
||||
import com.vitorpamplona.quartz.concord.cord04Roles.ControlEdition
|
||||
import com.vitorpamplona.quartz.concord.cord05Invites.ConcordInviteVend
|
||||
import com.vitorpamplona.quartz.concord.cord06Rekey.ChannelRekeyOutcome
|
||||
import com.vitorpamplona.quartz.concord.cord06Rekey.ConcordChannelRekey
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray
|
||||
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertIs
|
||||
import kotlin.test.assertNotNull
|
||||
import kotlin.test.assertNull
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
/**
|
||||
* Private Channels end to end, headless (CORD-03 §1-2, CORD-04 §2, CORD-05 §6, CORD-06 §1-2): create
|
||||
* with an access Role, vend on grant through a Direct Invite limited to the gained channel, the
|
||||
* recipient's click-free adoption, rotate on revoke to the remaining entitled set, and each member's
|
||||
* receive (the kept adopts, the cut drops the key and records the cut). Plus privatise/publicise.
|
||||
*/
|
||||
class ConcordPrivateChannelsTest {
|
||||
private val owner = NostrSignerInternal(KeyPair())
|
||||
private val alice = NostrSignerInternal(KeyPair())
|
||||
private val bob = NostrSignerInternal(KeyPair())
|
||||
|
||||
private class World(
|
||||
val community: NewConcordCommunity,
|
||||
val editions: MutableList<ControlEdition>,
|
||||
) {
|
||||
fun add(wrap: Event) {
|
||||
editions += ConcordActions.controlEditions(listOf(wrap), community.controlPlane)
|
||||
}
|
||||
|
||||
fun state(): ConcordCommunityState = ConcordCommunityState.fold(editions, community.communityId, community.ownerPubKey)
|
||||
}
|
||||
|
||||
private suspend fun world(): World {
|
||||
val c = ConcordCommunityFactory.create(owner, "Nostrichs", createdAt = 1L, relays = listOf("wss://relay.example"))
|
||||
return World(c, ConcordActions.controlEditions(c.genesisWraps, c.controlPlane).toMutableList())
|
||||
}
|
||||
|
||||
private fun entryOf(
|
||||
c: NewConcordCommunity,
|
||||
channels: List<PrivateChannelKey> = emptyList(),
|
||||
) = ConcordCommunityListEntry(
|
||||
id = c.communityIdHex,
|
||||
owner = c.ownerPubKey,
|
||||
ownerSalt = c.ownerSalt.toHexKey(),
|
||||
root = c.communityRoot.toHexKey(),
|
||||
rootEpoch = c.rootEpoch,
|
||||
controlPk = c.controlPkHex,
|
||||
privateChannels = channels,
|
||||
relays = listOf("wss://relay.example"),
|
||||
name = "Nostrichs",
|
||||
addedAt = 1_000L,
|
||||
)
|
||||
|
||||
@Test
|
||||
fun aPrivateChannelIsVendedOnGrantAndRotatedOnRevoke() =
|
||||
runTest {
|
||||
val w = world()
|
||||
val c = w.community
|
||||
val cid = c.communityId
|
||||
|
||||
// 1. Create: an access Role at the bottom of the roster, the channel flagged private, a key at epoch 0.
|
||||
val build = assertNotNull(ConcordPrivateChannels.create(owner, c.controlPlane, cid, "mods", null, w.editions, w.state().authority, c.ownerPubKey, 2L))
|
||||
build.wraps.forEach { w.add(it) }
|
||||
val ch = build.channelIdHex
|
||||
assertEquals(0L, build.key.epoch)
|
||||
assertTrue(ch in w.state().privateChannelIds)
|
||||
val role = assertNotNull(w.state().roles[build.roleIdHex])
|
||||
assertEquals("channel", role.scope?.kind)
|
||||
assertEquals(ch, role.scope?.channelId)
|
||||
assertEquals("0", role.permissions)
|
||||
val ownerEntry = assertNotNull(ConcordChannelKeyring.withChannelKey(entryOf(c), build.key))
|
||||
|
||||
// 2. Grant alice and bob the access Role: both gained the channel.
|
||||
val beforeGrant = w.state().authority
|
||||
w.add(ConcordModeration.grant(owner, c.controlPlane, cid, alice.pubKey, listOf(build.roleIdHex), w.editions, 3L, owner = c.ownerPubKey))
|
||||
w.add(ConcordModeration.grant(owner, c.controlPlane, cid, bob.pubKey, listOf(build.roleIdHex), w.editions, 3L, owner = c.ownerPubKey))
|
||||
val granted = ConcordInviteVend.accessChanges(beforeGrant, w.state().authority, w.state().privateChannelIds).single()
|
||||
assertEquals(setOf(alice.pubKey, bob.pubKey), granted.gained)
|
||||
|
||||
// 3. Vend: a Direct Invite limited to the gained channel, from staff, adopted by alice without a click.
|
||||
val draft = assertIs<ConcordDirectInviteDraft.Ready>(ConcordActions.draftDirectInvite(ownerEntry, w.state(), owner.pubKey, alice.pubKey, onlyChannelIds = setOf(ch)))
|
||||
assertEquals(listOf(ch), draft.invite.channels.map { it.id })
|
||||
val aliceHeld = entryOf(c)
|
||||
assertEquals(
|
||||
ConcordInviteVend.CatchUpVerdict.ADOPT,
|
||||
ConcordInviteVend.judgeCatchUp(w.state().authority, w.state().privateChannelIds, alice.pubKey, owner.pubKey, draft.invite, aliceHeld),
|
||||
)
|
||||
val aliceEntry = assertNotNull(ConcordInviteVend.adoptCatchUp(aliceHeld, draft.invite))
|
||||
val bobEntry = assertNotNull(ConcordChannelKeyring.withChannelKey(entryOf(c), build.key))
|
||||
assertEquals(build.key.key, ConcordChannelKeyring.heldKey(aliceEntry, ch)?.key)
|
||||
|
||||
// 4. Revoke bob: he lost the channel, so the owner rotates it to the remaining entitled set.
|
||||
val beforeRevoke = w.state().authority
|
||||
w.add(ConcordModeration.grant(owner, c.controlPlane, cid, bob.pubKey, emptyList(), w.editions, 4L, owner = c.ownerPubKey))
|
||||
val revoked = ConcordInviteVend.accessChanges(beforeRevoke, w.state().authority, w.state().privateChannelIds).single()
|
||||
assertEquals(setOf(bob.pubKey), revoked.lost)
|
||||
val keep = ConcordPrivateChannels.keepSet(w.state().authority, ch, owner.pubKey)
|
||||
assertEquals(setOf(owner.pubKey, alice.pubKey), keep)
|
||||
assertTrue(ConcordPrivateChannels.canRotate(w.state().authority, owner.pubKey, revoked.lost))
|
||||
// A plain member can't rotate anyone out.
|
||||
assertFalse(ConcordPrivateChannels.canRotate(w.state().authority, alice.pubKey, setOf(bob.pubKey)))
|
||||
|
||||
val newKey = ConcordChannelRekey.mintKey()
|
||||
val held = assertNotNull(ConcordChannelKeyring.heldKey(ownerEntry, ch))
|
||||
val wraps = ConcordPrivateChannels.buildRotation(owner, c.communityRoot, held, newKey, keep, 5L, authority = null)
|
||||
|
||||
// 5. Receive: alice adopts epoch 1, bob is cut and the cut is recorded.
|
||||
val aliceOut = ConcordPrivateChannels.receive(aliceEntry, wraps, w.editions, w.state().authority, alice)
|
||||
val adopted = assertIs<ChannelRekeyOutcome.Adopted>(aliceOut[ch])
|
||||
assertEquals(1L, adopted.epoch)
|
||||
val aliceNext = assertNotNull(ConcordPrivateChannels.applyOutcome(aliceEntry, aliceOut, mapOf(ch to 0L)))
|
||||
assertEquals(newKey.toHexKey(), ConcordChannelKeyring.heldKey(aliceNext, ch)?.key)
|
||||
assertEquals(1L, ConcordChannelKeyring.heldKey(aliceNext, ch)?.epoch)
|
||||
|
||||
val bobOut = ConcordPrivateChannels.receive(bobEntry, wraps, w.editions, w.state().authority, bob)
|
||||
assertEquals(1L, assertIs<ChannelRekeyOutcome.Removed>(bobOut[ch]).epoch)
|
||||
val bobNext = assertNotNull(ConcordPrivateChannels.applyOutcome(bobEntry, bobOut, mapOf(ch to 0L)))
|
||||
assertNull(ConcordChannelKeyring.heldKey(bobNext, ch))
|
||||
assertEquals(mapOf(ch to 1L), ConcordChannelKeyring.cutsOf(bobNext))
|
||||
// The stale epoch-0 key can't come back through a bundle.
|
||||
assertTrue(ConcordInviteVend.catchUpChannelIds(bobNext, draft.invite).isEmpty())
|
||||
|
||||
// A result computed from a stale epoch never rolls the List back.
|
||||
assertNull(ConcordPrivateChannels.applyOutcome(aliceNext, aliceOut, mapOf(ch to 0L)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aRotationFromAMemberWithoutAuthorityIsIgnored() =
|
||||
runTest {
|
||||
val w = world()
|
||||
val c = w.community
|
||||
val build = assertNotNull(ConcordPrivateChannels.create(owner, c.controlPlane, c.communityId, "mods", null, w.editions, w.state().authority, c.ownerPubKey, 2L))
|
||||
build.wraps.forEach { w.add(it) }
|
||||
w.add(ConcordModeration.grant(owner, c.controlPlane, c.communityId, alice.pubKey, listOf(build.roleIdHex), w.editions, 3L, owner = c.ownerPubKey))
|
||||
w.add(ConcordModeration.grant(owner, c.controlPlane, c.communityId, bob.pubKey, listOf(build.roleIdHex), w.editions, 3L, owner = c.ownerPubKey))
|
||||
val aliceEntry = assertNotNull(ConcordChannelKeyring.withChannelKey(entryOf(c), build.key))
|
||||
|
||||
// Bob holds the key but no MANAGE_CHANNELS: holding a key is never authority (CORD-06 §3).
|
||||
val forged = ConcordPrivateChannels.buildRotation(bob, c.communityRoot, build.key, ConcordChannelRekey.mintKey(), setOf(bob.pubKey), 5L, authority = null)
|
||||
assertTrue(ConcordPrivateChannels.receive(aliceEntry, forged, w.editions, w.state().authority, alice).isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aSessionWatchesAndBuffersItsChannelRekeys() =
|
||||
runTest {
|
||||
val c = world().community
|
||||
val chId = ByteArray(32) { 0x5C }
|
||||
val key = PrivateChannelKey(chId.toHexKey(), "10".repeat(32), 3, "mods")
|
||||
val entry = entryOf(c, listOf(key))
|
||||
val session = ConcordCommunitySession(entry, alice.pubKey)
|
||||
|
||||
// The next LOOKAHEAD channel epochs past the held one, under the current root.
|
||||
val next = ConcordChannelRekey.address(c.communityRoot, chId, 4).publicKeyHex
|
||||
assertTrue(next in session.channelRekeyAddresses())
|
||||
assertTrue(ConcordChannelRekey.address(c.communityRoot, chId, 3 + ConcordChannelRekey.LOOKAHEAD.toLong()).publicKeyHex in session.channelRekeyAddresses())
|
||||
assertFalse(ConcordChannelRekey.address(c.communityRoot, chId, 3).publicKeyHex in session.channelRekeyAddresses())
|
||||
assertTrue(session.ownsPlane(next))
|
||||
// Also subscribed with the auxiliary planes.
|
||||
assertTrue(ConcordSubscriptionPlanner.auxiliaryPlaneSubs(listOf(entry)).any { it.pubKeyHex == next })
|
||||
|
||||
val wraps = ConcordPrivateChannels.buildRotation(owner, c.communityRoot, key, ConcordChannelRekey.mintKey(), setOf(alice.pubKey), 5L, null)
|
||||
assertEquals(ConcordIngestOutcome.STRUCTURAL, session.ingest(wraps.single()))
|
||||
assertEquals(ConcordIngestOutcome.NON_STRUCTURAL, session.ingest(wraps.single()))
|
||||
assertEquals(listOf(wraps.single().id), session.pendingChannelRekeyWraps().map { it.id })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun privatizeClimbsTheChannelEpochAndPublicizeFlipsTheFlagBack() =
|
||||
runTest {
|
||||
val w = world()
|
||||
val c = w.community
|
||||
val general = c.generalChannelIdHex
|
||||
val standing = assertNotNull(w.state().channels[general]).definition
|
||||
assertFalse(standing.private)
|
||||
|
||||
val build = assertNotNull(ConcordPrivateChannels.privatize(owner, c.controlPlane, entryOf(c), general, standing, "insiders", w.editions, w.state().authority, 2L))
|
||||
build.wraps.forEach { w.add(it) }
|
||||
// The first privatisation is epoch 1 (CORD-03 §2); a floor seen on the wire lifts it.
|
||||
assertEquals(1L, build.key.epoch)
|
||||
assertTrue(general in w.state().privateChannelIds)
|
||||
assertEquals("insiders", w.state().roles[build.roleIdHex]?.name)
|
||||
val later = assertNotNull(ConcordPrivateChannels.privatize(owner, c.controlPlane, entryOf(c), general, standing, null, w.editions, w.state().authority, 2L, observedFloor = 4))
|
||||
assertEquals(5L, later.key.epoch)
|
||||
// Already private: nothing to do.
|
||||
assertNull(ConcordPrivateChannels.privatize(owner, c.controlPlane, entryOf(c), general, w.state().channels[general]!!.definition, null, w.editions, w.state().authority, 2L))
|
||||
|
||||
val flip = assertNotNull(ConcordPrivateChannels.publicize(owner, c.controlPlane, c.communityId, general, w.state().channels[general]!!.definition, w.editions, c.ownerPubKey, 3L))
|
||||
w.add(flip)
|
||||
assertFalse(general in w.state().privateChannelIds)
|
||||
// The held private-era key keeps reading its history once the channel is public again.
|
||||
val heldEntry = assertNotNull(ConcordChannelKeyring.withChannelKey(entryOf(c), build.key))
|
||||
val planes = ConcordActions.historicalChannelPlanes(heldEntry, general, isPrivate = false)
|
||||
assertTrue(planes.any { it.epoch == 1L })
|
||||
// And the next privatisation climbs past it.
|
||||
assertEquals(2L, ConcordChannelKeyring.nextChannelEpoch(heldEntry, general))
|
||||
assertTrue(general.hexToByteArray().size == 32)
|
||||
}
|
||||
}
|
||||
+20
@@ -200,4 +200,24 @@ class ConcordCommunitySessionTest {
|
||||
community.genesisWraps.forEach { session.ingest(it) }
|
||||
assertTrue(session.state.value!!.dissolved)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun noWriteIsBuiltFromAFoldThatHasNotDrained() =
|
||||
runTest {
|
||||
val community = ConcordCommunityFactory.create(owner, "Nostrichs", createdAt = 1L, relays = listOf("wss://r.example"))
|
||||
val session = ConcordCommunitySession(entryFor(community), owner.pubKey)
|
||||
assertEquals(null, session.foldForWrite(), "nothing folded")
|
||||
|
||||
// The live subscription delivered part of the plane: readable, but not a base for a write.
|
||||
session.ingest(community.genesisWraps.first())
|
||||
assertTrue(session.state.value != null)
|
||||
assertFalse(session.controlDrained.value)
|
||||
assertEquals(null, session.foldForWrite())
|
||||
|
||||
// The sweep paged the whole plane in and flagged it: writes may chain onto this fold.
|
||||
community.genesisWraps.forEach { session.ingest(it) }
|
||||
session.markControlDrained()
|
||||
assertEquals(session.state.value, session.foldForWrite())
|
||||
assertTrue(session.controlFloors().isEmpty(), "no prior epoch held, no floor")
|
||||
}
|
||||
}
|
||||
|
||||
+221
-8
@@ -27,11 +27,20 @@ import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEntr
|
||||
import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityState
|
||||
import com.vitorpamplona.quartz.concord.cord02Community.NewConcordCommunity
|
||||
import com.vitorpamplona.quartz.concord.cord02Community.PrivateChannelKey
|
||||
import com.vitorpamplona.quartz.concord.cord04Roles.ChannelEntity
|
||||
import com.vitorpamplona.quartz.concord.cord05Invites.CommunityInvite
|
||||
import com.vitorpamplona.quartz.concord.cord05Invites.InviteChannel
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray
|
||||
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.SignerExceptions
|
||||
import com.vitorpamplona.quartz.nip57Zaps.PrivateZapEvent
|
||||
import com.vitorpamplona.quartz.nip57Zaps.ZapRequestEvent
|
||||
import com.vitorpamplona.quartz.nip59Giftwrap.rumors.Rumor
|
||||
import com.vitorpamplona.quartz.nip59Giftwrap.seals.SealEvent
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import kotlin.test.Test
|
||||
@@ -91,6 +100,13 @@ class ConcordDirectInviteInboxTest {
|
||||
|
||||
private fun stateOf(c: NewConcordCommunity): ConcordCommunityState = ConcordCommunityState.fold(ConcordActions.controlEditions(c.genesisWraps, c.controlPlane), c.communityId, c.ownerPubKey)
|
||||
|
||||
/** [c]'s fold with [vip] defined as a live Private Channel. */
|
||||
private suspend fun stateWithVip(c: NewConcordCommunity): ConcordCommunityState {
|
||||
val editions = ConcordActions.controlEditions(c.genesisWraps, c.controlPlane).toMutableList()
|
||||
editions += ConcordActions.controlEditions(listOf(ConcordModeration.defineChannel(owner, c.controlPlane, c.communityId, vip.hexToByteArray(), ChannelEntity(name = "vip", private = true), editions, createdAt = 2L, owner = c.ownerPubKey)), c.controlPlane)
|
||||
return ConcordCommunityState.fold(editions, c.communityId, c.ownerPubKey)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aValidWrapIsParkedWithItsVerifiedSenderAndDedupedByWrapId() =
|
||||
runTest {
|
||||
@@ -138,6 +154,30 @@ class ConcordDirectInviteInboxTest {
|
||||
assertSame(opened, inbox.offer(wrap))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun theDmPipelineRumorPathParksWithoutAnotherDecryptAndTheSweepSkipsIt() =
|
||||
runTest {
|
||||
val c = community()
|
||||
val counting = FlakySigner(me)
|
||||
val inbox = ConcordDirectInviteInbox(counting)
|
||||
val wrap = ConcordActions.buildDirectInvite(sender, me.pubKey, inviteFor(c))
|
||||
// What the NIP-17 pipeline did already: one decrypt per layer.
|
||||
val seal = assertIs<SealEvent>(wrap.unwrapOrNull(me))
|
||||
val rumor = seal.unsealRumorThrowing(me)
|
||||
val opened = assertNotNull(inbox.offerRumor(wrap.copyNoContent(), seal, rumor))
|
||||
assertEquals(sender.pubKey, opened.sender)
|
||||
assertEquals(0, counting.decrypts, "the inbox never decrypted again")
|
||||
// The sweep fetching the same wrap later costs nothing either.
|
||||
assertSame(opened, inbox.offer(wrap))
|
||||
assertEquals(0, counting.decrypts)
|
||||
|
||||
// A spoofed rumor (claimed author differs from the seal's) is refused on this path too.
|
||||
val spoofed = Rumor(rumor.id, stranger.pubKey, rumor.createdAt, rumor.kind, rumor.tags, rumor.content)
|
||||
val other = ConcordActions.buildDirectInvite(sender, me.pubKey, inviteFor(c))
|
||||
val otherSeal = assertIs<SealEvent>(other.unwrapOrNull(me))
|
||||
assertNull(inbox.offerRumor(other.copyNoContent(), otherSeal, spoofed))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun declineDiscardsAndTheWrapNeverResurfaces() =
|
||||
runTest {
|
||||
@@ -191,22 +231,184 @@ class ConcordDirectInviteInboxTest {
|
||||
assertFalse(byWrap.getValue(toNew.wrapId).catchUp)
|
||||
assertTrue(byWrap.getValue(toNew.wrapId).expired)
|
||||
assertFalse(byWrap.getValue(catchUp.wrapId).expired)
|
||||
assertEquals(listOf("vip"), byWrap.getValue(catchUp.wrapId).channelNames)
|
||||
assertEquals(listOf("vip"), byWrap.getValue(catchUp.wrapId).newChannelNames())
|
||||
// Named by the held fold when it knows the channel.
|
||||
assertEquals(listOf("VIP lounge"), byWrap.getValue(catchUp.wrapId).newChannelNames { if (it == vip) "VIP lounge" else null })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun visibleKeepsOneInvitePerCommunity() =
|
||||
fun aCatchUpThatAcceptWouldRefuseIsNotOffered() =
|
||||
runTest {
|
||||
val c = community()
|
||||
val editions = ConcordActions.controlEditions(c.genesisWraps, c.controlPlane).toMutableList()
|
||||
editions += ConcordActions.controlEditions(listOf(ConcordModeration.defineChannel(owner, c.controlPlane, c.communityId, vip.hexToByteArray(), ChannelEntity(name = "vip", private = true), editions, 2L, owner = c.ownerPubKey)), c.controlPlane)
|
||||
val state = ConcordCommunityState.fold(editions, c.communityId, c.ownerPubKey)
|
||||
val grant = listOf(InviteChannel(vip, "db".repeat(32), 0, "vip"))
|
||||
val inbox = ConcordDirectInviteInbox(me)
|
||||
// A plain member hands over a key: accept refuses it (not staff), so it is not a card.
|
||||
val fromMember = assertNotNull(inbox.offer(ConcordActions.buildDirectInvite(sender, me.pubKey, inviteFor(c, channels = grant))))
|
||||
val fromOwner = assertNotNull(inbox.offer(ConcordActions.buildDirectInvite(owner, me.pubKey, inviteFor(c, channels = grant))))
|
||||
val held = listOf(heldEntryOf(c))
|
||||
|
||||
val views = ConcordDirectInviteInbox.visible(inbox.pending.value.values, held, heldStateOf = { state })
|
||||
assertEquals(listOf(fromOwner.wrapId), views.map { it.wrapId })
|
||||
assertIs<DirectInviteAcceptPlan.CatchUp>(ConcordDirectInviteInbox.acceptPlan(fromOwner, held.single(), state, me.pubKey))
|
||||
assertEquals(DirectInviteAcceptPlan.NothingNew, ConcordDirectInviteInbox.acceptPlan(fromMember, held.single(), state, me.pubKey))
|
||||
|
||||
// Before the fold is in, the verdict is unknown: both stay (accept waits for the roster).
|
||||
assertEquals(2, ConcordDirectInviteInbox.visible(inbox.pending.value.values, held).size)
|
||||
// A dissolved community takes no keys at all.
|
||||
assertTrue(ConcordDirectInviteInbox.visible(inbox.pending.value.values, held, heldStateOf = { state.withDissolved(true) }).isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun visibleKeepsOneInvitePerCommunityAndSender() =
|
||||
runTest {
|
||||
val c = community()
|
||||
val inbox = ConcordDirectInviteInbox(me)
|
||||
val older = assertNotNull(inbox.offer(ConcordActions.buildDirectInvite(sender, me.pubKey, inviteFor(c), createdAt = 1_700_000_000L)))
|
||||
val newer = assertNotNull(inbox.offer(ConcordActions.buildDirectInvite(stranger, me.pubKey, inviteFor(c), createdAt = 1_700_000_100L)))
|
||||
assertEquals(2, inbox.pending.value.size)
|
||||
val newer = assertNotNull(inbox.offer(ConcordActions.buildDirectInvite(sender, me.pubKey, inviteFor(c), createdAt = 1_700_000_100L)))
|
||||
val fromStranger = assertNotNull(inbox.offer(ConcordActions.buildDirectInvite(stranger, me.pubKey, inviteFor(c), createdAt = 1_700_000_050L)))
|
||||
assertEquals(3, inbox.pending.value.size)
|
||||
val views = ConcordDirectInviteInbox.visible(inbox.pending.value.values, emptyList())
|
||||
assertEquals(listOf(newer.wrapId), views.map { it.wrapId })
|
||||
assertEquals(listOf(newer.wrapId, fromStranger.wrapId), views.map { it.wrapId })
|
||||
assertFalse(older.wrapId in views.map { it.wrapId })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aFutureDatedInviteNeitherHidesALegitOneNorOutranksIt() =
|
||||
runTest {
|
||||
val c = community()
|
||||
val inbox = ConcordDirectInviteInbox(me)
|
||||
val now = 1_700_000_000L
|
||||
val legit = assertNotNull(inbox.offer(ConcordActions.buildDirectInvite(sender, me.pubKey, inviteFor(c), createdAt = now - 10), nowSecs = now))
|
||||
// A stranger's invite dated a year ahead: it may show, but it cannot shadow the sender's.
|
||||
val future = assertNotNull(inbox.offer(ConcordActions.buildDirectInvite(stranger, me.pubKey, inviteFor(c), createdAt = now + 365 * 86_400L), nowSecs = now))
|
||||
val views = ConcordDirectInviteInbox.visible(inbox.pending.value.values, emptyList(), nowMs = now * 1000)
|
||||
assertEquals(setOf(legit.wrapId, future.wrapId), views.map { it.wrapId }.toSet())
|
||||
assertEquals(now, views.first { it.wrapId == future.wrapId }.clampedSentAt, "ranked as if sent now, not a year ahead")
|
||||
|
||||
// Nor does it drag the sweep cursor into the future (D6): `since` stays near now.
|
||||
assertTrue(inbox.newestWrapCreatedAt!! <= now + ConcordDirectInviteInbox.FUTURE_SKEW_SECS)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun anInviteSentBeforeTheUserLeftTheCommunityStaysBuried() =
|
||||
runTest {
|
||||
val c = community()
|
||||
val inbox = ConcordDirectInviteInbox(me)
|
||||
val now = 1_700_000_000L
|
||||
assertNotNull(inbox.offer(ConcordActions.buildDirectInvite(sender, me.pubKey, inviteFor(c), createdAt = now - 100), nowSecs = now))
|
||||
val leftAtMs = (now - 50) * 1000
|
||||
val removed = mapOf(c.communityIdHex to leftAtMs)
|
||||
assertTrue(ConcordDirectInviteInbox.visible(inbox.pending.value.values, emptyList(), nowMs = now * 1000, removedAt = removed).isEmpty())
|
||||
|
||||
// A fresh re-invite sent after leaving shows.
|
||||
val fresh = assertNotNull(inbox.offer(ConcordActions.buildDirectInvite(stranger, me.pubKey, inviteFor(c), createdAt = now - 10), nowSecs = now))
|
||||
assertEquals(listOf(fresh.wrapId), ConcordDirectInviteInbox.visible(inbox.pending.value.values, emptyList(), nowMs = now * 1000, removedAt = removed).map { it.wrapId })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun followedSendersRankFirstAndHiddenSendersAreNeverParked() =
|
||||
runTest {
|
||||
val c = community()
|
||||
val other = community()
|
||||
val inbox = ConcordDirectInviteInbox(me, isHidden = { it == stranger.pubKey }, isFollowed = { it == owner.pubKey })
|
||||
val now = 1_700_000_000L
|
||||
assertNull(inbox.offer(ConcordActions.buildDirectInvite(stranger, me.pubKey, inviteFor(c), createdAt = now), nowSecs = now), "a muted sender never parks")
|
||||
val newer = assertNotNull(inbox.offer(ConcordActions.buildDirectInvite(sender, me.pubKey, inviteFor(c), createdAt = now), nowSecs = now))
|
||||
val followed = assertNotNull(inbox.offer(ConcordActions.buildDirectInvite(owner, me.pubKey, inviteFor(other), createdAt = now - 1_000), nowSecs = now))
|
||||
val views = ConcordDirectInviteInbox.visible(inbox.pending.value.values, emptyList(), nowMs = now * 1000, isFollowed = { it == owner.pubKey })
|
||||
assertEquals(listOf(followed.wrapId, newer.wrapId), views.map { it.wrapId })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun theInboxIsBoundedAndShedsTheLowestRanked() =
|
||||
runTest {
|
||||
val c = community()
|
||||
val inbox = ConcordDirectInviteInbox(me, isFollowed = { it == owner.pubKey })
|
||||
val now = 1_700_000_000L
|
||||
val followed = assertNotNull(inbox.offer(ConcordActions.buildDirectInvite(owner, me.pubKey, inviteFor(c), createdAt = now - 10_000), nowSecs = now))
|
||||
repeat(ConcordDirectInviteInbox.MAX_PENDING) { i ->
|
||||
inbox.offer(ConcordActions.buildDirectInvite(sender, me.pubKey, inviteFor(c), createdAt = now - 5_000 + i), nowSecs = now)
|
||||
}
|
||||
assertEquals(ConcordDirectInviteInbox.MAX_PENDING, inbox.pending.value.size)
|
||||
assertTrue(followed.wrapId in inbox.pending.value, "the followed sender's older invite outranks strangers' newer ones")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aSignerThatCannotAnswerNowLeavesTheWrapToBeRetried() =
|
||||
runTest {
|
||||
val c = community()
|
||||
val flaky = FlakySigner(me)
|
||||
val inbox = ConcordDirectInviteInbox(flaky)
|
||||
val wrap = ConcordActions.buildDirectInvite(sender, me.pubKey, inviteFor(c))
|
||||
|
||||
flaky.failNext = true
|
||||
assertNull(inbox.offer(wrap), "the signer timed out: nothing parked")
|
||||
// Not written off: the next delivery opens it.
|
||||
assertNotNull(inbox.offer(wrap))
|
||||
|
||||
// A definitive failure (not for us) is written off: a later offer never decrypts again.
|
||||
val notOurs = ConcordActions.buildDirectInvite(sender, stranger.pubKey, inviteFor(c))
|
||||
assertNull(inbox.offer(notOurs))
|
||||
val before = flaky.decrypts
|
||||
assertNull(inbox.offer(notOurs))
|
||||
assertEquals(before, flaky.decrypts)
|
||||
}
|
||||
|
||||
/** Delegates to [inner], throwing a transient signer failure on the next decrypt when [failNext]. */
|
||||
private class FlakySigner(
|
||||
private val inner: NostrSignerInternal,
|
||||
) : NostrSigner(inner.pubKey) {
|
||||
var failNext = false
|
||||
var decrypts = 0
|
||||
|
||||
override fun isWriteable() = inner.isWriteable()
|
||||
|
||||
override suspend fun <T : Event> sign(
|
||||
createdAt: Long,
|
||||
kind: Int,
|
||||
tags: Array<Array<String>>,
|
||||
content: String,
|
||||
): T = inner.sign(createdAt, kind, tags, content)
|
||||
|
||||
override suspend fun nip04Encrypt(
|
||||
plaintext: String,
|
||||
toPublicKey: HexKey,
|
||||
) = inner.nip04Encrypt(plaintext, toPublicKey)
|
||||
|
||||
override suspend fun nip04Decrypt(
|
||||
ciphertext: String,
|
||||
fromPublicKey: HexKey,
|
||||
) = inner.nip04Decrypt(ciphertext, fromPublicKey)
|
||||
|
||||
override suspend fun nip44Encrypt(
|
||||
plaintext: String,
|
||||
toPublicKey: HexKey,
|
||||
) = inner.nip44Encrypt(plaintext, toPublicKey)
|
||||
|
||||
override suspend fun nip44Decrypt(
|
||||
ciphertext: String,
|
||||
fromPublicKey: HexKey,
|
||||
): String {
|
||||
decrypts++
|
||||
if (failNext) {
|
||||
failNext = false
|
||||
throw SignerExceptions.TimedOutException("signer busy")
|
||||
}
|
||||
return inner.nip44Decrypt(ciphertext, fromPublicKey)
|
||||
}
|
||||
|
||||
override suspend fun decryptZapEvent(event: ZapRequestEvent): PrivateZapEvent = inner.decryptZapEvent(event)
|
||||
|
||||
override suspend fun deriveKey(nonce: HexKey) = inner.deriveKey(nonce)
|
||||
|
||||
override suspend fun signPsbt(psbtHex: String) = inner.signPsbt(psbtHex)
|
||||
|
||||
override fun hasForegroundSupport() = inner.hasForegroundSupport()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun acceptRefusesAnExpiredInvite() =
|
||||
runTest {
|
||||
@@ -221,17 +423,23 @@ class ConcordDirectInviteInboxTest {
|
||||
runTest {
|
||||
val c = community()
|
||||
val held = heldEntryOf(c)
|
||||
val state = stateOf(c)
|
||||
val state = stateWithVip(c)
|
||||
val grant = listOf(InviteChannel(vip, "db".repeat(32), 0, "vip"))
|
||||
|
||||
// Same base, new key: a catch-up that keeps the held base and anchor.
|
||||
val catchUp = assertNotNull(ConcordActions.openDirectInvite(ConcordActions.buildDirectInvite(sender, me.pubKey, inviteFor(c, channels = grant)), me))
|
||||
// Same base, new key, from staff (the owner): a catch-up that keeps the held base and anchor.
|
||||
val catchUp = assertNotNull(ConcordActions.openDirectInvite(ConcordActions.buildDirectInvite(owner, me.pubKey, inviteFor(c, channels = grant)), me))
|
||||
val plan = assertIs<DirectInviteAcceptPlan.CatchUp>(ConcordDirectInviteInbox.acceptPlan(catchUp, held, state, me.pubKey))
|
||||
assertEquals(held.root, plan.entry.root)
|
||||
assertEquals(held.rootEpoch, plan.entry.rootEpoch)
|
||||
assertEquals(held.controlPk, plan.entry.controlPk)
|
||||
assertEquals("anchor", plan.entry.inviteRef)
|
||||
assertEquals(listOf(vip), plan.entry.privateChannels.map { it.channelId })
|
||||
assertEquals(listOf(vip), plan.channelIds)
|
||||
|
||||
// A plain keyholder can't plant a key, and a channel the fold doesn't know as Private isn't one.
|
||||
val fromMember = assertNotNull(ConcordActions.openDirectInvite(ConcordActions.buildDirectInvite(sender, me.pubKey, inviteFor(c, channels = grant)), me))
|
||||
assertEquals(DirectInviteAcceptPlan.NothingNew, ConcordDirectInviteInbox.acceptPlan(fromMember, held, state, me.pubKey))
|
||||
assertEquals(DirectInviteAcceptPlan.NothingNew, ConcordDirectInviteInbox.acceptPlan(catchUp, held, stateOf(c), me.pubKey))
|
||||
|
||||
// No fold yet: the ban verdict is unknown, so it waits.
|
||||
assertEquals(DirectInviteAcceptPlan.RosterNotLoaded, ConcordDirectInviteInbox.acceptPlan(catchUp, held, null, me.pubKey))
|
||||
@@ -240,6 +448,11 @@ class ConcordDirectInviteInboxTest {
|
||||
val holding = held.let { ConcordCommunityListEntry(it.id, it.owner, it.ownerSalt, it.root, it.rootEpoch, it.controlPk, privateChannels = listOf(PrivateChannelKey(vip, "db".repeat(32), 0, "vip")), relays = it.relays, name = it.name) }
|
||||
assertEquals(DirectInviteAcceptPlan.NothingNew, ConcordDirectInviteInbox.acceptPlan(catchUp, holding, state, me.pubKey))
|
||||
|
||||
// Even from staff, a bundle never REPLACES a held key — not at a higher, nor an absurd, epoch.
|
||||
// A held key moves only through a channel rekey, whose prevcommit proves continuity.
|
||||
val hijack = assertNotNull(ConcordActions.openDirectInvite(ConcordActions.buildDirectInvite(owner, me.pubKey, inviteFor(c, channels = listOf(InviteChannel(vip, "ee".repeat(32), 1_000_000_000L, "vip")))), me))
|
||||
assertEquals(DirectInviteAcceptPlan.NothingNew, ConcordDirectInviteInbox.acceptPlan(hijack, holding, state, me.pubKey))
|
||||
|
||||
// A different base for a held community is never adopted, keys or not.
|
||||
val baseMove = assertNotNull(ConcordActions.openDirectInvite(ConcordActions.buildDirectInvite(sender, me.pubKey, inviteFor(c, root = "99".repeat(32), channels = grant)), me))
|
||||
assertEquals(DirectInviteAcceptPlan.NothingNew, ConcordDirectInviteInbox.acceptPlan(baseMove, held, state, me.pubKey))
|
||||
|
||||
+100
-1
@@ -36,6 +36,7 @@ import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal
|
||||
import com.vitorpamplona.quartz.nip92IMeta.IMetaTagBuilder
|
||||
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||
import com.vitorpamplona.quartz.utils.ciphers.AESGCM
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import kotlin.test.Test
|
||||
@@ -114,7 +115,6 @@ class ConcordDisappearingSessionTest {
|
||||
ConcordActions.buildChannelInlineReply(owner, plane.key, general, plane.epoch, parent, "quote", at, timerSecs = timer),
|
||||
ConcordActions.buildChannelReply(owner, plane.key, general, plane.epoch, parent, "thread", at, timerSecs = timer),
|
||||
ConcordActions.buildChannelImageReply(owner, plane.key, general, plane.epoch, parent, "thread pic", imeta, at, timerSecs = timer),
|
||||
ConcordActions.buildChannelEdit(owner, plane.key, general, plane.epoch, parent, "edited", at, timerSecs = timer),
|
||||
ConcordActions.buildChannelReaction(owner, plane.key, general, plane.epoch, parent, "+", at, timerSecs = timer),
|
||||
)
|
||||
for (wrap in durable) {
|
||||
@@ -142,6 +142,32 @@ class ConcordDisappearingSessionTest {
|
||||
assertEquals(listOf("p"), off.tags.map { it[0] })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun anEditKeepsTheOriginalMessagesDeadlineNotNowPlusTimer() =
|
||||
runTest {
|
||||
val (community, session) = session(day)
|
||||
val general = community.generalChannelIdHex
|
||||
val plane = session.currentChannelPlane(general)!!
|
||||
val sentAt = 1_000_000L
|
||||
val original = ChannelChat.message(owner.pubKey, general, plane.epoch, "hi", sentAt, ConcordDisappearing.withExpiration(emptyArray(), sentAt + 7 * day))
|
||||
val editedAt = sentAt + 3 * day
|
||||
|
||||
// Default: the target's own deadline, verbatim, inside and outside.
|
||||
val edit = ConcordActions.buildChannelEdit(owner, plane.key, general, plane.epoch, original, "hi!", editedAt)
|
||||
assertEquals(sentAt + 7 * day, ConcordDisappearing.expirationOf(opened(edit, plane.key)))
|
||||
assertEquals((sentAt + 7 * day).toString(), wrapExpiration(edit))
|
||||
|
||||
// A message sent without a timer stays timer-free when edited, even though a timer is on now.
|
||||
val forever = ChannelChat.message(owner.pubKey, general, plane.epoch, "forever", sentAt)
|
||||
val editForever = ConcordActions.buildChannelEdit(owner, plane.key, general, plane.epoch, forever, "still forever", editedAt)
|
||||
assertNull(ConcordDisappearing.expirationOf(opened(editForever, plane.key)))
|
||||
assertNull(wrapExpiration(editForever))
|
||||
|
||||
// A smuggled expiration in extraTags never overrides the original's.
|
||||
val smuggled = ConcordActions.buildChannelEdit(owner, plane.key, general, plane.epoch, forever, "x", editedAt, arrayOf(arrayOf("expiration", "5")))
|
||||
assertNull(ConcordDisappearing.expirationOf(opened(smuggled, plane.key)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun anAlreadyExpiredRumorIsRefusedAndItsWrapPurged() =
|
||||
runTest {
|
||||
@@ -195,6 +221,79 @@ class ConcordDisappearingSessionTest {
|
||||
assertNull(session.nextExpiry.value)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun theSweepPopsOnlyWhatIsDueInDeadlineOrderAndCarriesAttachmentUrls() =
|
||||
runTest {
|
||||
val (community, session) = session(day)
|
||||
val general = community.generalChannelIdHex
|
||||
val plane = session.currentChannelPlane(general)!!
|
||||
val now = TimeUtils.now()
|
||||
val image = listOf(ChannelChat.encryptedImageImeta("https://blossom.example/blob", "image/png", null, null, AESGCM(), null))
|
||||
val late = ConcordActions.buildChannelMessage(owner, plane.key, general, plane.epoch, "late", now, timerSecs = 3 * day)
|
||||
val soon = ConcordActions.buildChannelImageMessage(owner, plane.key, general, plane.epoch, "soon", image, now, timerSecs = day)
|
||||
val mid = ConcordActions.buildChannelMessage(owner, plane.key, general, plane.epoch, "mid", now, timerSecs = 2 * day)
|
||||
listOf(late, soon, mid).forEach { session.ingest(it) }
|
||||
assertEquals(now + day, session.nextExpiry.value, "the head of the deadline order")
|
||||
|
||||
val first = session.sweepExpired(now + 2 * day)
|
||||
assertEquals(listOf(soon.id, mid.id), first.map { it.wrapId }, "due ones only, soonest first")
|
||||
// CORD-08 §3: the image's decryption key must go with the message.
|
||||
assertEquals(listOf("https://blossom.example/blob"), first.first().attachmentUrls)
|
||||
assertEquals(now + 3 * day, session.nextExpiry.value)
|
||||
assertEquals(listOf(late.id), session.sweepExpired(now + 3 * day).map { it.wrapId })
|
||||
assertNull(session.nextExpiry.value)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aSweptWrapDeliveredAgainIsNotOpenedAgain() =
|
||||
runTest {
|
||||
val captured = mutableListOf<Event>()
|
||||
val (community, session) = session(day, captured)
|
||||
val general = community.generalChannelIdHex
|
||||
val plane = session.currentChannelPlane(general)!!
|
||||
val stale = ConcordActions.buildChannelMessage(owner, plane.key, general, plane.epoch, "stale", TimeUtils.now() - 2 * day, timerSecs = day)
|
||||
session.ingest(stale)
|
||||
assertEquals(listOf(stale.id), session.sweepExpired().map { it.wrapId })
|
||||
|
||||
// A relay serving it again: claimed, dropped unopened — no new deadline, no re-sweep loop.
|
||||
assertEquals(ConcordIngestOutcome.NON_STRUCTURAL, session.ingest(stale))
|
||||
assertNull(session.nextExpiry.value)
|
||||
assertFalse(session.isBuffered(general, stale.id))
|
||||
assertTrue(captured.none { it.content == "stale" })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aRefoundingCarriesTheTrackedDeadlinesIntoTheNewSession() =
|
||||
runTest {
|
||||
val community = ConcordCommunityFactory.create(owner, "Nostrichs", createdAt = 1L, relays = listOf("wss://r.example"))
|
||||
val registry = ConcordSessionRegistry()
|
||||
val entry = entryFor(community)
|
||||
registry.sync(listOf(entry), owner.pubKey)
|
||||
val old = registry.sessionFor(community.communityIdHex)!!
|
||||
community.genesisWraps.forEach { old.ingest(it) }
|
||||
val plane = old.currentChannelPlane(community.generalChannelIdHex)!!
|
||||
val now = TimeUtils.now()
|
||||
val live = ConcordActions.buildChannelMessage(owner, plane.key, community.generalChannelIdHex, plane.epoch, "bye", now, timerSecs = day)
|
||||
old.ingest(live)
|
||||
|
||||
// The root rolls: the registry rebuilds the session, which never sees the old wrap again.
|
||||
val rolled =
|
||||
ConcordCommunityListEntry(
|
||||
id = entry.id,
|
||||
owner = entry.owner,
|
||||
ownerSalt = entry.ownerSalt,
|
||||
root = KeyPair().pubKey.toHexKey(),
|
||||
rootEpoch = entry.rootEpoch + 1,
|
||||
relays = entry.relays,
|
||||
name = entry.name,
|
||||
)
|
||||
registry.sync(listOf(rolled), owner.pubKey)
|
||||
val fresh = registry.sessionFor(community.communityIdHex)!!
|
||||
assertTrue(fresh !== old)
|
||||
assertEquals(now + day, fresh.nextExpiry.value)
|
||||
assertEquals(listOf(live.id), fresh.sweepExpired(now + day).map { it.wrapId })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun theManagerSchedulesOnTheEarliestDeadlineAndSweepsPerCommunity() =
|
||||
runTest {
|
||||
|
||||
+188
@@ -0,0 +1,188 @@
|
||||
/*
|
||||
* 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.model.concord
|
||||
|
||||
import com.vitorpamplona.amethyst.commons.actions.ConcordActions
|
||||
import com.vitorpamplona.amethyst.commons.actions.ConcordModeration
|
||||
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.NewConcordCommunity
|
||||
import com.vitorpamplona.quartz.concord.cord04Roles.AuthorityCitation
|
||||
import com.vitorpamplona.quartz.concord.cord04Roles.ConcordPermissions
|
||||
import com.vitorpamplona.quartz.concord.cord04Roles.RoleEntity
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertNotNull
|
||||
import kotlin.test.assertNull
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
/**
|
||||
* The Cooperative Kick (CORD-02 §5, CORD-04 §6) through the session fold: an honored Kick departs its
|
||||
* target in everyone's roster and tells the target's own client to leave; a Kick from someone who may
|
||||
* not kick is dropped; a Kick whose Grant has not folded yet parks until it does; and a re-join
|
||||
* (a newer Join, or a re-added entry) leaves the Kick behind.
|
||||
*/
|
||||
class ConcordKickTest {
|
||||
private val owner = NostrSignerInternal(KeyPair())
|
||||
private val mod = NostrSignerInternal(KeyPair())
|
||||
private val target = NostrSignerInternal(KeyPair())
|
||||
private val bystander = NostrSignerInternal(KeyPair())
|
||||
|
||||
private class World(
|
||||
val community: NewConcordCommunity,
|
||||
val controlWraps: MutableList<Event>,
|
||||
) {
|
||||
fun state(): ConcordCommunityState = ConcordCommunityState.fold(ConcordActions.controlEditions(controlWraps, community.controlPlane), community.communityId, community.ownerPubKey)
|
||||
}
|
||||
|
||||
/** A community whose owner defined a Mod role (position 5, KICK only) and granted it to [mod]. */
|
||||
private suspend fun world(): Pair<World, Event> {
|
||||
val c = ConcordCommunityFactory.create(owner, "Nostrichs", createdAt = 1L, relays = listOf("wss://relay.example"))
|
||||
val w = World(c, c.genesisWraps.toMutableList())
|
||||
val roleId = ByteArray(32) { 7 }
|
||||
val role = RoleEntity(name = "Mod", position = 5, permissions = ConcordPermissions.of(ConcordPermissions.KICK).toWire())
|
||||
w.controlWraps += ConcordModeration.defineRole(owner, c.controlPlane, c.communityId, roleId, role, ConcordActions.controlEditions(w.controlWraps, c.controlPlane), 2L, owner = c.ownerPubKey)
|
||||
val modGrant = ConcordModeration.grant(owner, c.controlPlane, c.communityId, mod.pubKey, listOf(roleId.toHexKey()), ConcordActions.controlEditions(w.controlWraps, c.controlPlane), 3L, owner = c.ownerPubKey)
|
||||
w.controlWraps += modGrant
|
||||
return w to modGrant
|
||||
}
|
||||
|
||||
private fun entryOf(
|
||||
c: NewConcordCommunity,
|
||||
addedAt: Long,
|
||||
) = ConcordCommunityListEntry(
|
||||
id = c.communityIdHex,
|
||||
owner = c.ownerPubKey,
|
||||
ownerSalt = c.ownerSalt.toHexKey(),
|
||||
root = c.communityRoot.toHexKey(),
|
||||
rootEpoch = c.rootEpoch,
|
||||
controlPk = c.controlPkHex,
|
||||
relays = listOf("wss://relay.example"),
|
||||
name = "Nostrichs",
|
||||
addedAt = addedAt,
|
||||
)
|
||||
|
||||
private fun session(
|
||||
c: NewConcordCommunity,
|
||||
me: String,
|
||||
wraps: List<Event>,
|
||||
addedAt: Long = 1_000L,
|
||||
): ConcordCommunitySession = ConcordCommunitySession(entryOf(c, addedAt), me) { _, _, _, _ -> }.also { s -> wraps.forEach { s.ingest(it) } }
|
||||
|
||||
private fun guestbook(c: NewConcordCommunity) = ConcordActions.guestbookPlane(c.communityRoot, c.communityId, c.rootEpoch)
|
||||
|
||||
private fun citationOf(
|
||||
w: World,
|
||||
actor: String,
|
||||
): AuthorityCitation? = w.state().authority.citationFor(actor)
|
||||
|
||||
@Test
|
||||
fun anHonoredKickDepartsTheTargetAndAsksTheirClientToLeave() =
|
||||
runTest {
|
||||
val (w, _) = world()
|
||||
val c = w.community
|
||||
val gb = guestbook(c)
|
||||
val join = ConcordActions.buildGuestbookJoin(target, gb, createdAt = 5L)
|
||||
val kick = ConcordActions.buildGuestbookKick(mod, gb, target.pubKey, citationOf(w, mod.pubKey), createdAt = 10L)
|
||||
|
||||
val ownerView = session(c, owner.pubKey, w.controlWraps + join + kick)
|
||||
val t = target.pubKey.lowercase()
|
||||
assertEquals(GuestbookAction.KICK, ownerView.guestbook.value[t]?.action)
|
||||
assertFalse(t in ownerView.members.value)
|
||||
assertEquals(GuestbookAction.KICK, ownerView.departedMembers()[t]?.action)
|
||||
assertFalse(t in ownerView.allMembers())
|
||||
assertNull(ownerView.kickedMe()) // the Kick names the target, not the owner
|
||||
|
||||
// The target's client finds the Kick against this membership and complies.
|
||||
val targetView = session(c, target.pubKey, w.controlWraps + join + kick, addedAt = 1_000L)
|
||||
val verdict = assertNotNull(targetView.kickedMe())
|
||||
assertEquals(mod.pubKey, verdict.author)
|
||||
|
||||
// Re-invited after the Kick: the entry's added_at postdates it, so it judges nothing.
|
||||
assertNull(session(c, target.pubKey, w.controlWraps + join + kick, addedAt = 11_000L).kickedMe())
|
||||
|
||||
// Re-joined: a newer self-signed Join supersedes the Kick.
|
||||
val rejoin = ConcordActions.buildGuestbookJoin(target, gb, createdAt = 20L)
|
||||
targetView.ingest(rejoin)
|
||||
assertNull(targetView.kickedMe())
|
||||
assertTrue(t in targetView.members.value)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aKickFromSomeoneWhoMayNotKickIsDropped() =
|
||||
runTest {
|
||||
val (w, _) = world()
|
||||
val c = w.community
|
||||
val gb = guestbook(c)
|
||||
val join = ConcordActions.buildGuestbookJoin(target, gb, createdAt = 5L)
|
||||
// A roleless member holds no KICK; nobody may kick the owner.
|
||||
val forged = ConcordActions.buildGuestbookKick(bystander, gb, target.pubKey, citationOf(w, bystander.pubKey), createdAt = 10L)
|
||||
val atOwner = ConcordActions.buildGuestbookKick(mod, gb, owner.pubKey, citationOf(w, mod.pubKey), createdAt = 10L)
|
||||
|
||||
val targetView = session(c, target.pubKey, w.controlWraps + join + forged + atOwner)
|
||||
assertEquals(GuestbookAction.JOIN, targetView.guestbook.value[target.pubKey.lowercase()]?.action)
|
||||
assertNull(targetView.kickedMe())
|
||||
assertNull(targetView.guestbook.value[owner.pubKey.lowercase()])
|
||||
assertTrue(targetView.departedMembers().isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aKickParksUntilItsCitedGrantFolds() =
|
||||
runTest {
|
||||
val (w, modGrant) = world()
|
||||
val c = w.community
|
||||
val gb = guestbook(c)
|
||||
val join = ConcordActions.buildGuestbookJoin(target, gb, createdAt = 5L)
|
||||
val kick = ConcordActions.buildGuestbookKick(mod, gb, target.pubKey, citationOf(w, mod.pubKey), createdAt = 10L)
|
||||
|
||||
// The Guestbook can lead the Control Plane: without the mod's Grant their Kick parks.
|
||||
val targetView = session(c, target.pubKey, (w.controlWraps - modGrant) + join + kick)
|
||||
assertNull(targetView.kickedMe())
|
||||
assertEquals(GuestbookAction.JOIN, targetView.guestbook.value[target.pubKey.lowercase()]?.action)
|
||||
|
||||
// The Grant folds: the same held Kick is now honored, with no Guestbook arrival.
|
||||
targetView.ingest(modGrant)
|
||||
assertNotNull(targetView.kickedMe())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aKickWithoutACitationFromANonOwnerParks() =
|
||||
runTest {
|
||||
val (w, _) = world()
|
||||
val c = w.community
|
||||
val gb = guestbook(c)
|
||||
val join = ConcordActions.buildGuestbookJoin(target, gb, createdAt = 5L)
|
||||
val uncited = ConcordActions.buildGuestbookKick(mod, gb, target.pubKey, citation = null, createdAt = 10L)
|
||||
assertNull(session(c, target.pubKey, w.controlWraps + join + uncited).kickedMe())
|
||||
|
||||
// The owner cites nothing and needs nothing.
|
||||
val byOwner = ConcordActions.buildGuestbookKick(owner, gb, target.pubKey, citation = null, createdAt = 10L)
|
||||
assertNotNull(session(c, target.pubKey, w.controlWraps + join + byOwner).kickedMe())
|
||||
}
|
||||
}
|
||||
+71
@@ -0,0 +1,71 @@
|
||||
/*
|
||||
* 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.model.concord
|
||||
|
||||
import com.vitorpamplona.amethyst.commons.actions.ConcordChannelPins
|
||||
import kotlinx.coroutines.test.advanceTimeBy
|
||||
import kotlinx.coroutines.test.advanceUntilIdle
|
||||
import kotlinx.coroutines.test.runCurrent
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertNull
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
/**
|
||||
* The account-level pin duty scheduler (CORD-04 §7): a duty waits its random delay, a channel runs
|
||||
* one duty at a time, and a debt is attempted once — so a burst of ticks costs one write and a failing
|
||||
* write never spins — while a new debt is a new attempt.
|
||||
*/
|
||||
class ConcordPinDutySchedulerTest {
|
||||
@Test
|
||||
fun oneAttemptPerDebtAndOneDutyPerChannel() =
|
||||
runTest {
|
||||
val scheduler = ConcordPinDutyScheduler(delayMs = { 5_000L })
|
||||
var runs = 0
|
||||
|
||||
assertTrue(scheduler.schedule(this, "c|a", "d1") { runs++ })
|
||||
assertFalse(scheduler.schedule(this, "c|a", "d1") { runs++ }, "the same debt twice")
|
||||
assertFalse(scheduler.schedule(this, "c|a", "d2") { runs++ }, "a second duty while one is in flight")
|
||||
assertTrue(scheduler.schedule(this, "c|b", "d1") { runs++ }, "another channel is independent")
|
||||
|
||||
advanceTimeBy(4_999)
|
||||
runCurrent()
|
||||
assertEquals(0, runs, "a duty waits its delay before settling")
|
||||
advanceUntilIdle()
|
||||
assertEquals(2, runs)
|
||||
|
||||
assertFalse(scheduler.schedule(this, "c|a", "d1") { runs++ }, "an attempted debt is never respun")
|
||||
assertTrue(scheduler.schedule(this, "c|a", "d2") { runs++ }, "a new debt is a new attempt")
|
||||
advanceUntilIdle()
|
||||
assertEquals(3, runs)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun nothingOwedSchedulesNothing() =
|
||||
runTest {
|
||||
val scheduler = ConcordPinDutyScheduler(delayMs = { 0L })
|
||||
assertFalse(scheduler.schedule(this, "c|a", null) { error("must not run") })
|
||||
assertNull(ConcordPinDutyScheduler.debtOf(null))
|
||||
assertNull(ConcordPinDutyScheduler.debtOf(ConcordChannelPins.none("aa".repeat(32))))
|
||||
}
|
||||
}
|
||||
+96
@@ -0,0 +1,96 @@
|
||||
/*
|
||||
* 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.model.concord
|
||||
|
||||
import com.vitorpamplona.amethyst.commons.actions.ConcordActions
|
||||
import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityFactory
|
||||
import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEntry
|
||||
import com.vitorpamplona.quartz.concord.cord03Channels.ConcordWebxdc
|
||||
import com.vitorpamplona.quartz.concord.envelope.ConcordStreamEnvelope
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertNotNull
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
/**
|
||||
* WebXDC signals (kind 3310) on a Chat Plane (F10): the session accepts them under the plane's strict
|
||||
* binding and holds them for a WebXDC host, but never hands them to the chat store — so they never
|
||||
* become feed rows, previews or unread messages — while ordinary messages on the same plane still do.
|
||||
*/
|
||||
class ConcordWebxdcSessionTest {
|
||||
private val owner = NostrSignerInternal(KeyPair())
|
||||
private val alice = NostrSignerInternal(KeyPair())
|
||||
private val topic = "A".repeat(26) + "234567".repeat(4) + "BC"
|
||||
|
||||
@Test
|
||||
fun webxdcSignalsAreHeldApartFromChatRows() =
|
||||
runTest {
|
||||
val community = ConcordCommunityFactory.create(owner, "Nostrichs", createdAt = 1L, relays = listOf("wss://r.example"))
|
||||
val entry =
|
||||
ConcordCommunityListEntry(
|
||||
id = community.communityIdHex,
|
||||
owner = community.ownerPubKey,
|
||||
ownerSalt = community.ownerSalt.toHexKey(),
|
||||
root = community.communityRoot.toHexKey(),
|
||||
rootEpoch = community.rootEpoch,
|
||||
controlPk = community.controlPkHex,
|
||||
controlRoot = community.controlRoot.toHexKey(),
|
||||
relays = listOf("wss://r.example"),
|
||||
name = "Nostrichs",
|
||||
)
|
||||
val stored = mutableListOf<Event>()
|
||||
val session = ConcordCommunitySession(entry, owner.pubKey) { _, _, rumor, _ -> stored += rumor }
|
||||
community.genesisWraps.forEach { session.ingest(it) }
|
||||
val general = community.generalChannelIdHex
|
||||
val plane = assertNotNull(session.currentChannelPlane(general))
|
||||
|
||||
val update = ConcordWebxdc.stateUpdate(alice.pubKey, general, plane.epoch, "uuid-1", "{\"move\":1}", createdAt = 10L)
|
||||
val ad = ConcordWebxdc.peerSignal(alice.pubKey, general, plane.epoch, topic, "node-addr", createdAt = 11L)
|
||||
// Bound to another epoch: the plane's strict binding still refuses it.
|
||||
val misbound = ConcordWebxdc.stateUpdate(alice.pubKey, general, plane.epoch + 1, "uuid-1", "{}", createdAt = 12L)
|
||||
for (rumor in listOf(update, ad, misbound)) {
|
||||
session.ingest(ConcordStreamEnvelope.wrap(rumor, plane.key, alice, encrypted = true, createdAt = rumor.createdAt))
|
||||
}
|
||||
val message = ConcordActions.buildChannelMessage(alice, plane.key, general, plane.epoch, "hello", 13L)
|
||||
session.ingest(message)
|
||||
|
||||
// Only the chat message reached the store (feeds, previews, unread counts read from there).
|
||||
assertEquals(listOf("hello"), stored.map { it.content })
|
||||
assertTrue(stored.none { it.kind == ConcordWebxdc.KIND })
|
||||
|
||||
// The signals are held for a WebXDC host, oldest first; the misbound one is not.
|
||||
val held = session.webxdcSignals(general)
|
||||
assertEquals(listOf(update.id, ad.id), held.map { it.id })
|
||||
assertEquals("uuid-1", ConcordWebxdc.sessionOf(held.first()))
|
||||
assertEquals("node-addr", ConcordWebxdc.parsePeerSignal(held.last().content)?.addr)
|
||||
assertEquals(2L, session.webxdcRevision.value)
|
||||
|
||||
// A duplicate delivery holds nothing new; the author counts as observed either way.
|
||||
session.ingest(ConcordStreamEnvelope.wrap(update, plane.key, alice, encrypted = true, createdAt = 10L))
|
||||
assertEquals(2, session.webxdcSignals(general).size)
|
||||
assertTrue(alice.pubKey.lowercase() in session.observedAuthors.value)
|
||||
}
|
||||
}
|
||||
@@ -619,6 +619,9 @@
|
||||
<string name="concord_pinned_unavailable">This channel's pins are sealed under a key you don't hold, so they can't be shown here, and pinning is paused until they can be read.</string>
|
||||
<string name="concord_pinned_budget">%1$d of %2$d pins · %3$d% of the size budget used</string>
|
||||
<string name="concord_pinned_open_hint">Tap a pin to jump to it</string>
|
||||
<string name="concord_pin_expiring_title">Pin a disappearing message?</string>
|
||||
<string name="concord_pin_expiring_body">This message is set to disappear. Pinning it keeps its words in the channel's pin list after the timer erases the message itself.</string>
|
||||
<string name="concord_pin_expiring_confirm">Pin anyway</string>
|
||||
<string name="concord_pin_failed_title">Pins</string>
|
||||
<string name="concord_pin_failed_unavailable">The pinned list is sealed under a key you don't hold. Changing it now would drop pins you can't see.</string>
|
||||
<string name="concord_pin_failed_too_many">This channel already has 25 pins. Unpin one first.</string>
|
||||
@@ -645,6 +648,15 @@
|
||||
<string name="concord_role_owner">Owner</string>
|
||||
<string name="concord_role_admin">Admin</string>
|
||||
<string name="concord_role_banned">Banned</string>
|
||||
<string name="concord_role_kicked">Kicked</string>
|
||||
<string name="concord_role_left">Left</string>
|
||||
<string name="concord_members_kick">Kick</string>
|
||||
<string name="concord_members_kick_title">Kick this member?</string>
|
||||
<string name="concord_members_kick_message">Their roles are removed and their app is asked to leave the community. A kick is cooperative: it does not change any keys, and they can rejoin with an invite. To cut off their access, ban or remove them instead.</string>
|
||||
<string name="concord_members_kick_failed">Could not kick this member.</string>
|
||||
<string name="concord_kicked_title">Removed from community</string>
|
||||
<string name="concord_kicked_message">A moderator removed you from %1$s. You can rejoin with a new invite.</string>
|
||||
<string name="concord_kicked_message_unnamed">A moderator removed you from a community. You can rejoin with a new invite.</string>
|
||||
<string name="concord_invite_card_join">Join community</string>
|
||||
<string name="concord_invite_card_subtitle">Concord community invite</string>
|
||||
<string name="concord_invite_naddr_label">Concord invite (open the full invite link to join)</string>
|
||||
@@ -3645,15 +3657,23 @@
|
||||
<item quantity="one">%1$d channel</item>
|
||||
<item quantity="other">%1$d channels</item>
|
||||
</plurals>
|
||||
<string name="concord_channel_access_role_label">Access role name</string>
|
||||
<string name="concord_channel_create">New channel</string>
|
||||
<string name="concord_channel_delete">Delete channel</string>
|
||||
<string name="concord_channel_delete_confirm">Delete</string>
|
||||
<string name="concord_channel_delete_message">Delete #%1$s? This can't be undone and the channel can't be recreated with the same id.</string>
|
||||
<string name="concord_channel_delete_title">Delete channel?</string>
|
||||
<string name="concord_channel_make_private">Make private</string>
|
||||
<string name="concord_channel_make_private_message">#%1$s gets its own key from now on, and the "%2$s" role decides who can read it. Nobody holds that role yet: grant it to the members who should have access. Messages already posted stay readable to everyone in the community.</string>
|
||||
<string name="concord_channel_make_public">Make public</string>
|
||||
<string name="concord_channel_make_public_message">Every member of the community will be able to read #%1$s from now on. Messages posted while it was private stay readable only to the members who held its key.</string>
|
||||
<string name="concord_channel_name_label">Channel name</string>
|
||||
<string name="concord_channel_no_messages">No messages yet</string>
|
||||
<string name="concord_channel_private_hint">Only members holding its access role can read it. Grant the role to let them in; revoking it rotates the channel key.</string>
|
||||
<string name="concord_channel_private_toggle">Private channel</string>
|
||||
<string name="concord_channel_rename">Rename channel</string>
|
||||
<string name="concord_channel_rename_save">Rename</string>
|
||||
<string name="concord_channel_rotate_key">Rotate key</string>
|
||||
<string name="concord_channels_empty">No channels yet.</string>
|
||||
<string name="concord_create_action">Create</string>
|
||||
<string name="concord_create_failed">The community could not be created: no relay accepted it, or your community list could not be updated. Check the relays and try again.</string>
|
||||
|
||||
+140
-5
@@ -43,6 +43,7 @@ import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Switch
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
@@ -75,15 +76,23 @@ import com.vitorpamplona.amethyst.commons.resources.Res
|
||||
import com.vitorpamplona.amethyst.commons.resources.app_name
|
||||
import com.vitorpamplona.amethyst.commons.resources.back
|
||||
import com.vitorpamplona.amethyst.commons.resources.cancel
|
||||
import com.vitorpamplona.amethyst.commons.resources.concord_channel_access_role_label
|
||||
import com.vitorpamplona.amethyst.commons.resources.concord_channel_create
|
||||
import com.vitorpamplona.amethyst.commons.resources.concord_channel_delete
|
||||
import com.vitorpamplona.amethyst.commons.resources.concord_channel_delete_confirm
|
||||
import com.vitorpamplona.amethyst.commons.resources.concord_channel_delete_message
|
||||
import com.vitorpamplona.amethyst.commons.resources.concord_channel_delete_title
|
||||
import com.vitorpamplona.amethyst.commons.resources.concord_channel_make_private
|
||||
import com.vitorpamplona.amethyst.commons.resources.concord_channel_make_private_message
|
||||
import com.vitorpamplona.amethyst.commons.resources.concord_channel_make_public
|
||||
import com.vitorpamplona.amethyst.commons.resources.concord_channel_make_public_message
|
||||
import com.vitorpamplona.amethyst.commons.resources.concord_channel_name_label
|
||||
import com.vitorpamplona.amethyst.commons.resources.concord_channel_no_messages
|
||||
import com.vitorpamplona.amethyst.commons.resources.concord_channel_private_hint
|
||||
import com.vitorpamplona.amethyst.commons.resources.concord_channel_private_toggle
|
||||
import com.vitorpamplona.amethyst.commons.resources.concord_channel_rename
|
||||
import com.vitorpamplona.amethyst.commons.resources.concord_channel_rename_save
|
||||
import com.vitorpamplona.amethyst.commons.resources.concord_channel_rotate_key
|
||||
import com.vitorpamplona.amethyst.commons.resources.concord_channels_empty
|
||||
import com.vitorpamplona.amethyst.commons.resources.concord_direct_invite_action
|
||||
import com.vitorpamplona.amethyst.commons.resources.concord_edit_title
|
||||
@@ -118,6 +127,7 @@ import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.qrcode.QrCodeDrawer
|
||||
import com.vitorpamplona.amethyst.commons.ui.stringRes
|
||||
import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel
|
||||
import com.vitorpamplona.quartz.concord.cord03Channels.ConcordChannelId
|
||||
import com.vitorpamplona.quartz.concord.cord03Channels.ConcordChannelKeyring
|
||||
import com.vitorpamplona.quartz.concord.cord04Roles.ConcordPermissions
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||
@@ -236,11 +246,11 @@ fun ConcordChannelListScreen(
|
||||
initialName = editor.initialName,
|
||||
isCreate = editor.channelIdHex == null,
|
||||
onDismiss = { channelEditor = null },
|
||||
onConfirm = { newName ->
|
||||
onConfirm = { newName, makePrivate, accessRoleName ->
|
||||
channelEditor = null
|
||||
scope.launch {
|
||||
if (editor.channelIdHex == null) {
|
||||
account.concord.createConcordChannel(communityId, newName)
|
||||
account.concord.createConcordChannel(communityId, newName, makePrivate, accessRoleName)
|
||||
} else {
|
||||
account.concord.renameConcordChannel(communityId, editor.channelIdHex, newName)
|
||||
}
|
||||
@@ -249,6 +259,26 @@ fun ConcordChannelListScreen(
|
||||
)
|
||||
}
|
||||
|
||||
// Privatise / publicise a channel (CORD-03 §2): both are MANAGE_CHANNELS edits, and both say
|
||||
// plainly what they can't do — a conversion protects the future only.
|
||||
var channelToConvert by remember { mutableStateOf<ConcordChannelConversion?>(null) }
|
||||
channelToConvert?.let { target ->
|
||||
ConcordChannelConvertDialog(
|
||||
target = target,
|
||||
onDismiss = { channelToConvert = null },
|
||||
onConfirm = { accessRoleName ->
|
||||
channelToConvert = null
|
||||
scope.launch {
|
||||
if (target.toPrivate) {
|
||||
account.concord.privatizeConcordChannel(communityId, target.channelIdHex, accessRoleName)
|
||||
} else {
|
||||
account.concord.publicizeConcordChannel(communityId, target.channelIdHex)
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
channelToDelete?.let { target ->
|
||||
val id = target.channelIdHex ?: return@let
|
||||
AlertDialog(
|
||||
@@ -444,6 +474,8 @@ fun ConcordChannelListScreen(
|
||||
.keys
|
||||
.sorted()
|
||||
}
|
||||
// A rotation needs the current key (CORD-06): only a holder can rotate.
|
||||
val holdsKey = def.private && session?.entry?.let { ConcordChannelKeyring.heldKey(it, entry.key) } != null
|
||||
ConcordChannelListRow(
|
||||
communityId = communityId,
|
||||
channelKey = entry.key,
|
||||
@@ -455,6 +487,9 @@ fun ConcordChannelListScreen(
|
||||
onClick = { nav.nav(Route.Concord(communityId, entry.key)) },
|
||||
onRename = { channelEditor = ConcordChannelEditor(channelIdHex = entry.key, initialName = name) },
|
||||
onDelete = { channelToDelete = ConcordChannelEditor(channelIdHex = entry.key, initialName = name) },
|
||||
onTogglePrivate = { channelToConvert = ConcordChannelConversion(entry.key, name, toPrivate = !def.private) },
|
||||
isPrivate = def.private,
|
||||
onRotateKey = if (holdsKey) ({ scope.launch { account.concord.rekeyConcordChannel(communityId, entry.key) } }) else null,
|
||||
)
|
||||
HorizontalDivider(thickness = 0.25.dp, color = MaterialTheme.colorScheme.outlineVariant)
|
||||
}
|
||||
@@ -482,6 +517,9 @@ private fun ConcordChannelListRow(
|
||||
onClick: () -> Unit,
|
||||
onRename: () -> Unit,
|
||||
onDelete: () -> Unit,
|
||||
onTogglePrivate: () -> Unit,
|
||||
isPrivate: Boolean,
|
||||
onRotateKey: (() -> Unit)?,
|
||||
) {
|
||||
val account = accountViewModel.account
|
||||
// getOrCreate (not getIfExists): a channel folded on the Control Plane may have no message note
|
||||
@@ -545,6 +583,9 @@ private fun ConcordChannelListRow(
|
||||
ConcordChannelRowMenu(
|
||||
onRename = onRename,
|
||||
onDelete = onDelete,
|
||||
onTogglePrivate = onTogglePrivate,
|
||||
isPrivate = isPrivate,
|
||||
onRotateKey = onRotateKey,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -673,11 +714,62 @@ private data class ConcordChannelEditor(
|
||||
val initialName: String,
|
||||
)
|
||||
|
||||
/** The per-channel-row overflow menu (rename / delete), shown only to channel managers. */
|
||||
/** A pending privatise ([toPrivate]) or publicise of [channelIdHex]. */
|
||||
private data class ConcordChannelConversion(
|
||||
val channelIdHex: String,
|
||||
val name: String,
|
||||
val toPrivate: Boolean,
|
||||
)
|
||||
|
||||
/** Confirms a Private/Public conversion; privatising also names the new access Role. */
|
||||
@Composable
|
||||
private fun ConcordChannelConvertDialog(
|
||||
target: ConcordChannelConversion,
|
||||
onDismiss: () -> Unit,
|
||||
onConfirm: (String?) -> Unit,
|
||||
) {
|
||||
var roleName by remember { mutableStateOf(target.name) }
|
||||
val action = if (target.toPrivate) Res.string.concord_channel_make_private else Res.string.concord_channel_make_public
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text(stringRes(action)) },
|
||||
text = {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
if (target.toPrivate) {
|
||||
Text(stringRes(Res.string.concord_channel_make_private_message, target.name, roleName.ifBlank { target.name }))
|
||||
OutlinedTextField(
|
||||
value = roleName,
|
||||
onValueChange = { roleName = it },
|
||||
singleLine = true,
|
||||
label = { Text(stringRes(Res.string.concord_channel_access_role_label)) },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
} else {
|
||||
Text(stringRes(Res.string.concord_channel_make_public_message, target.name))
|
||||
}
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(onClick = { onConfirm(roleName.trim().ifBlank { null }) }) {
|
||||
Text(stringRes(action))
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = onDismiss) {
|
||||
Text(stringRes(Res.string.cancel))
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/** The per-channel-row overflow menu (rename / privacy / rotate / delete), shown only to channel managers. */
|
||||
@Composable
|
||||
private fun ConcordChannelRowMenu(
|
||||
onRename: () -> Unit,
|
||||
onDelete: () -> Unit,
|
||||
onTogglePrivate: () -> Unit,
|
||||
isPrivate: Boolean,
|
||||
onRotateKey: (() -> Unit)?,
|
||||
) {
|
||||
var expanded by remember { mutableStateOf(false) }
|
||||
Box {
|
||||
@@ -696,6 +788,22 @@ private fun ConcordChannelRowMenu(
|
||||
onRename()
|
||||
},
|
||||
)
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringRes(if (isPrivate) Res.string.concord_channel_make_public else Res.string.concord_channel_make_private)) },
|
||||
onClick = {
|
||||
expanded = false
|
||||
onTogglePrivate()
|
||||
},
|
||||
)
|
||||
onRotateKey?.let { rotate ->
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringRes(Res.string.concord_channel_rotate_key)) },
|
||||
onClick = {
|
||||
expanded = false
|
||||
rotate()
|
||||
},
|
||||
)
|
||||
}
|
||||
DropdownMenuItem(
|
||||
text = {
|
||||
Text(
|
||||
@@ -718,9 +826,11 @@ private fun ConcordChannelEditDialog(
|
||||
initialName: String,
|
||||
isCreate: Boolean,
|
||||
onDismiss: () -> Unit,
|
||||
onConfirm: (String) -> Unit,
|
||||
onConfirm: (name: String, makePrivate: Boolean, accessRoleName: String?) -> Unit,
|
||||
) {
|
||||
var name by remember { mutableStateOf(initialName) }
|
||||
var makePrivate by remember { mutableStateOf(false) }
|
||||
var roleName by remember { mutableStateOf("") }
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = {
|
||||
@@ -735,6 +845,7 @@ private fun ConcordChannelEditDialog(
|
||||
)
|
||||
},
|
||||
text = {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
OutlinedTextField(
|
||||
value = name,
|
||||
onValueChange = { name = it },
|
||||
@@ -742,11 +853,35 @@ private fun ConcordChannelEditDialog(
|
||||
label = { Text(stringRes(Res.string.concord_channel_name_label)) },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
// A new channel may be Private (CORD-03): its own key, and an access Role — the
|
||||
// Roles scoped to a channel ARE its access list (CORD-04 §2).
|
||||
if (isCreate) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(stringRes(Res.string.concord_channel_private_toggle), modifier = Modifier.weight(1f))
|
||||
Switch(checked = makePrivate, onCheckedChange = { makePrivate = it })
|
||||
}
|
||||
if (makePrivate) {
|
||||
Text(
|
||||
stringRes(Res.string.concord_channel_private_hint),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = roleName,
|
||||
onValueChange = { roleName = it },
|
||||
singleLine = true,
|
||||
placeholder = { Text(name.trim()) },
|
||||
label = { Text(stringRes(Res.string.concord_channel_access_role_label)) },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(
|
||||
enabled = name.isNotBlank(),
|
||||
onClick = { if (name.isNotBlank()) onConfirm(name.trim()) },
|
||||
onClick = { if (name.isNotBlank()) onConfirm(name.trim(), makePrivate, roleName.trim().ifBlank { null }) },
|
||||
) {
|
||||
Text(
|
||||
stringRes(
|
||||
|
||||
+52
-15
@@ -21,11 +21,14 @@
|
||||
package com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.publicChannels.concord
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyListScope
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ElevatedCard
|
||||
@@ -84,6 +87,8 @@ import com.vitorpamplona.amethyst.commons.ui.note.creators.userSuggestions.UserS
|
||||
import com.vitorpamplona.amethyst.commons.ui.stringRes
|
||||
import com.vitorpamplona.amethyst.commons.ui.theme.SuggestionListDefaultHeightChat
|
||||
import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel
|
||||
import com.vitorpamplona.quartz.utils.Log
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/**
|
||||
@@ -178,29 +183,48 @@ private fun sendResultMessage(result: ConcordDirectInviteSendResult) =
|
||||
}
|
||||
|
||||
/**
|
||||
* The Direct Invites waiting for this account (CORD-05 §6), as cards with Accept / Decline — shown
|
||||
* at the top of the Concord communities list. Renders nothing when there are none.
|
||||
* Sweeps the inbox relays for Direct Invites once when the hub opens (wraps the DM pipeline sees
|
||||
* arrive on their own). Call it once per screen, outside any lazy list: inside a lazy item it would
|
||||
* re-run every time the item scrolled back into view.
|
||||
*/
|
||||
@Composable
|
||||
fun RefreshConcordDirectInvites(accountViewModel: AccountViewModel) {
|
||||
val concord = accountViewModel.account.concord
|
||||
LaunchedEffect(concord) {
|
||||
try {
|
||||
concord.refreshConcordDirectInvites()
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
Log.w("ConcordDirectInvites", "Direct Invite sweep failed", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The Direct Invites waiting for this account (CORD-05 §6), as lazy items with Accept / Decline —
|
||||
* shown at the top of the Concord communities list. Adds nothing when there are none.
|
||||
*
|
||||
* Opening the hub sweeps the inbox relays once; wraps the DM pipeline sees arrive on their own.
|
||||
* The preview is the bundle's own name and a robohash of the community id — **no** icon fetch, no
|
||||
* relay connection to the community, no Join happens before the user taps Accept. The sender is
|
||||
* shown by whatever name the cache already has, without fetching their profile.
|
||||
*/
|
||||
@Composable
|
||||
fun ConcordPendingDirectInvites(
|
||||
fun LazyListScope.concordPendingDirectInvites(
|
||||
invites: List<ConcordDirectInviteView>,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val concord = accountViewModel.account.concord
|
||||
LaunchedEffect(concord) { runCatching { concord.refreshConcordDirectInvites() } }
|
||||
|
||||
val invites by concord.pendingConcordDirectInvites.collectAsStateWithLifecycle()
|
||||
if (invites.isEmpty()) return
|
||||
|
||||
Column(modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 8.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Text(stringRes(Res.string.concord_direct_invites_title), style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.Bold)
|
||||
invites.forEach { invite ->
|
||||
item(key = "concord-direct-invites-title") {
|
||||
Text(
|
||||
stringRes(Res.string.concord_direct_invites_title),
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
fontWeight = FontWeight.Bold,
|
||||
modifier = Modifier.padding(start = 12.dp, end = 12.dp, top = 8.dp),
|
||||
)
|
||||
}
|
||||
items(invites, key = { "concord-direct-invite-" + it.wrapId }) { invite ->
|
||||
Box(Modifier.padding(horizontal = 12.dp, vertical = 4.dp)) {
|
||||
ConcordDirectInviteCard(invite, accountViewModel, nav)
|
||||
}
|
||||
}
|
||||
@@ -220,7 +244,20 @@ private fun ConcordDirectInviteCard(
|
||||
val subtitle =
|
||||
when {
|
||||
invite.expired -> stringRes(Res.string.concord_direct_invite_expired)
|
||||
invite.catchUp -> stringRes(Res.string.concord_direct_invite_catch_up, invite.channelNames.joinToString(", ") { "#$it" })
|
||||
invite.catchUp -> {
|
||||
// Only the channels it newly adds, named as the held community folds them.
|
||||
val names =
|
||||
remember(invite) {
|
||||
val folded =
|
||||
accountViewModel.account.concordSessions
|
||||
.sessionFor(invite.communityId)
|
||||
?.state
|
||||
?.value
|
||||
?.channels
|
||||
invite.newChannelNames { id -> folded?.get(id)?.definition?.name }
|
||||
}
|
||||
stringRes(Res.string.concord_direct_invite_catch_up, names.joinToString(", ") { "#$it" })
|
||||
}
|
||||
else -> stringRes(Res.string.concord_direct_invite_from, senderName)
|
||||
}
|
||||
|
||||
|
||||
+14
-6
@@ -80,7 +80,8 @@ import com.vitorpamplona.amethyst.commons.ui.platform.AppBottomBar
|
||||
import com.vitorpamplona.amethyst.commons.ui.platform.rememberConcordImageModel
|
||||
import com.vitorpamplona.amethyst.commons.ui.pluralStringRes
|
||||
import com.vitorpamplona.amethyst.commons.ui.screen.LocalDisplaySettings
|
||||
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.publicChannels.concord.ConcordPendingDirectInvites
|
||||
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.publicChannels.concord.RefreshConcordDirectInvites
|
||||
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.publicChannels.concord.concordPendingDirectInvites
|
||||
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.publicChannels.concord.datasource.ConcordChannelSubscription
|
||||
import com.vitorpamplona.amethyst.commons.ui.stringRes
|
||||
import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel
|
||||
@@ -120,6 +121,11 @@ fun ConcordHomeScreen(
|
||||
// the stock relays for users who actually use Concord.
|
||||
LaunchedEffect(Unit) { accountViewModel.importConcordCommunities() }
|
||||
|
||||
// Direct Invites (CORD-05 §6): one inbox sweep per visit, kept out of the lazy list so it does
|
||||
// not re-run each time the invites scroll back into view.
|
||||
RefreshConcordDirectInvites(accountViewModel)
|
||||
val invites by account.concord.pendingConcordDirectInvites.collectAsStateWithLifecycle()
|
||||
|
||||
// Per-community expansion, cycled on tap: absent = CLOSED → UNREAD (peek only the channels with
|
||||
// new messages) → OPEN (all channels) → CLOSED. Multi-open, so several can be expanded at once.
|
||||
// rememberSaveable so the chevron states survive opening a channel and coming back to the hub.
|
||||
@@ -161,10 +167,11 @@ fun ConcordHomeScreen(
|
||||
) { padding ->
|
||||
if (communities.isEmpty()) {
|
||||
// Direct Invites (CORD-05 §6) are how a first community usually arrives, so they show
|
||||
// above the empty state rather than being hidden by it.
|
||||
Column(Modifier.fillMaxSize().padding(padding)) {
|
||||
ConcordPendingDirectInvites(accountViewModel, nav)
|
||||
Box(Modifier.fillMaxWidth().weight(1f), contentAlignment = Alignment.Center) {
|
||||
// above the empty state rather than being hidden by it — in a lazy list, so many scroll.
|
||||
LazyColumn(Modifier.fillMaxSize().padding(padding)) {
|
||||
concordPendingDirectInvites(invites, accountViewModel, nav)
|
||||
item(key = "concord-home-empty") {
|
||||
Box(Modifier.fillParentMaxWidth().fillParentMaxHeight(if (invites.isEmpty()) 1f else 0.5f), contentAlignment = Alignment.Center) {
|
||||
Text(
|
||||
stringRes(Res.string.concord_home_empty),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
@@ -173,6 +180,7 @@ fun ConcordHomeScreen(
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
return@Scaffold
|
||||
}
|
||||
|
||||
@@ -195,7 +203,7 @@ fun ConcordHomeScreen(
|
||||
|
||||
LazyColumn(Modifier.fillMaxSize().padding(padding)) {
|
||||
// Pending Direct Invites (CORD-05 §6), parked until the user accepts or declines.
|
||||
item(key = "concord-direct-invites") { ConcordPendingDirectInvites(accountViewModel, nav) }
|
||||
concordPendingDirectInvites(invites, accountViewModel, nav)
|
||||
|
||||
sorted.forEach { entry ->
|
||||
val state =
|
||||
|
||||
+84
-9
@@ -66,6 +66,9 @@ import com.vitorpamplona.amethyst.commons.resources.concord_members_ban
|
||||
import com.vitorpamplona.amethyst.commons.resources.concord_members_ban_message
|
||||
import com.vitorpamplona.amethyst.commons.resources.concord_members_ban_title
|
||||
import com.vitorpamplona.amethyst.commons.resources.concord_members_empty
|
||||
import com.vitorpamplona.amethyst.commons.resources.concord_members_kick
|
||||
import com.vitorpamplona.amethyst.commons.resources.concord_members_kick_message
|
||||
import com.vitorpamplona.amethyst.commons.resources.concord_members_kick_title
|
||||
import com.vitorpamplona.amethyst.commons.resources.concord_members_make_admin
|
||||
import com.vitorpamplona.amethyst.commons.resources.concord_members_remove
|
||||
import com.vitorpamplona.amethyst.commons.resources.concord_members_remove_admin
|
||||
@@ -82,6 +85,8 @@ import com.vitorpamplona.amethyst.commons.resources.concord_members_title
|
||||
import com.vitorpamplona.amethyst.commons.resources.concord_members_unban
|
||||
import com.vitorpamplona.amethyst.commons.resources.concord_role_admin
|
||||
import com.vitorpamplona.amethyst.commons.resources.concord_role_banned
|
||||
import com.vitorpamplona.amethyst.commons.resources.concord_role_kicked
|
||||
import com.vitorpamplona.amethyst.commons.resources.concord_role_left
|
||||
import com.vitorpamplona.amethyst.commons.resources.concord_role_owner
|
||||
import com.vitorpamplona.amethyst.commons.resources.more_options
|
||||
import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav
|
||||
@@ -91,6 +96,8 @@ import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.publicChannel
|
||||
import com.vitorpamplona.amethyst.commons.ui.stringRes
|
||||
import com.vitorpamplona.amethyst.commons.ui.theme.Size35dp
|
||||
import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel
|
||||
import com.vitorpamplona.quartz.concord.cord02Community.GuestbookAction
|
||||
import com.vitorpamplona.quartz.concord.cord02Community.GuestbookEntry
|
||||
import com.vitorpamplona.quartz.concord.cord04Roles.ConcordPermissions
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
@@ -134,12 +141,16 @@ fun ConcordMembersScreen(
|
||||
// roster collapses to just the owner + privileged roles.
|
||||
val guestbookMembers by (session?.members ?: remember { MutableStateFlow(emptySet<HexKey>()) }).collectAsStateWithLifecycle()
|
||||
val observedAuthors by (session?.observedAuthors ?: remember { MutableStateFlow(emptySet<HexKey>()) }).collectAsStateWithLifecycle()
|
||||
// The coalesced Guestbook: a member whose latest motion is a Leave or an honored Kick (and who has
|
||||
// not posted since) shows as departed (CORD-02 §5, CORD-04 §6).
|
||||
val guestbook by (session?.guestbook ?: remember { MutableStateFlow(emptyMap<HexKey, GuestbookEntry>()) }).collectAsStateWithLifecycle()
|
||||
|
||||
val myPubKey = account.signer.pubKey
|
||||
val roster =
|
||||
remember(state, guestbookMembers, observedAuthors) {
|
||||
remember(state, guestbookMembers, observedAuthors, guestbook) {
|
||||
val s = state ?: return@remember emptyList<RosterEntry>()
|
||||
val authority = s.authority
|
||||
val departed = session?.departedMembers().orEmpty()
|
||||
val pubkeys =
|
||||
(listOf(s.ownerPubKey) + authority.roleHolders() + authority.bannedMembers() + guestbookMembers + observedAuthors)
|
||||
.map { it.lowercase() }
|
||||
@@ -154,8 +165,8 @@ fun ConcordMembersScreen(
|
||||
.minByOrNull { r -> r.position }
|
||||
?.name
|
||||
?.takeIf { n -> n.isNotBlank() }
|
||||
RosterEntry(it, ConcordMembership.of(authority, it), roleName, authority.rolesOf(it))
|
||||
}.sortedWith(compareBy({ it.membership.sortRank() }, { it.pubkey }))
|
||||
RosterEntry(it, ConcordMembership.of(authority, it), roleName, authority.rolesOf(it), departed[it]?.action)
|
||||
}.sortedWith(compareBy({ it.sortRank() }, { it.pubkey }))
|
||||
}
|
||||
|
||||
val iAmOwner = state?.authority?.isOwner(myPubKey) == true
|
||||
@@ -164,6 +175,7 @@ fun ConcordMembersScreen(
|
||||
// which IS ban-aware — a thin margin for the escalation in docs/concord-soft-ban-audit.md.
|
||||
val iCanBan = state?.let { it.authority.isOwner(myPubKey) || it.authority.hasPermission(myPubKey, ConcordPermissions.BAN) } == true
|
||||
val iCanManageRoles = state?.authority?.hasPermission(myPubKey, ConcordPermissions.MANAGE_ROLES) == true
|
||||
val iCanKick = state?.let { it.authority.isOwner(myPubKey) || it.authority.hasPermission(myPubKey, ConcordPermissions.KICK) } == true
|
||||
|
||||
// The roles this viewer may actually hand out. The fold drops a grant whose granter does
|
||||
// not *strictly* outrank every assigned role, so offering a role at or above our own
|
||||
@@ -224,6 +236,8 @@ fun ConcordMembersScreen(
|
||||
canBanTarget =
|
||||
iAmOwner ||
|
||||
state?.authority?.canActOn(myPubKey, entry.pubkey, ConcordPermissions.BAN) == true,
|
||||
// A Kick needs KICK and a strict outrank of the target (CORD-04 §6), the same rank rule.
|
||||
canKickTarget = iCanKick && state?.authority?.canActOn(myPubKey, entry.pubkey, ConcordPermissions.KICK) == true,
|
||||
viewerCanManageRoles = iCanManageRoles,
|
||||
// canActOn folds the whole rank rule for us: we hold MANAGE_ROLES, we're not
|
||||
// banned, the target isn't the owner (unremovable), and we strictly outrank
|
||||
@@ -248,6 +262,7 @@ private fun ConcordMemberRow(
|
||||
viewerIsOwner: Boolean,
|
||||
viewerCanBan: Boolean,
|
||||
canBanTarget: Boolean,
|
||||
canKickTarget: Boolean,
|
||||
viewerCanManageRoles: Boolean,
|
||||
canManageRolesOnTarget: Boolean,
|
||||
assignableRoles: List<AssignableRole>,
|
||||
@@ -266,6 +281,8 @@ private fun ConcordMemberRow(
|
||||
val canBan = viewerCanBan && canBanTarget && !isOwnerTarget && !isSelf
|
||||
// Hard removal (CORD-06 Refounding) rotates the community key; same authority as ban.
|
||||
val canRemove = viewerCanBan && canBanTarget && !isOwnerTarget && !isSelf
|
||||
// A Kick is the cooperative removal (CORD-04 §6): pointless against a banned or already-kicked member.
|
||||
val canKick = canKickTarget && !isOwnerTarget && !isSelf && !isBanned && entry.departure != GuestbookAction.KICK
|
||||
// Shown to any MANAGE_ROLES holder, but disabled with a reason when this particular
|
||||
// member (or every defined role) is out of our reach — a grant we don't outrank
|
||||
// publishes fine and is then dropped by every client's fold, so a silently no-op
|
||||
@@ -277,7 +294,7 @@ private fun ConcordMemberRow(
|
||||
assignableRoles.isEmpty() -> stringRes(Res.string.concord_members_roles_none_assignable)
|
||||
else -> null
|
||||
}
|
||||
val hasMenu = canToggleAdmin || canBan || canRemove || viewerCanManageRoles
|
||||
val hasMenu = canToggleAdmin || canBan || canRemove || canKick || viewerCanManageRoles
|
||||
|
||||
var editRoles by remember { mutableStateOf(false) }
|
||||
if (editRoles) {
|
||||
@@ -292,6 +309,17 @@ private fun ConcordMemberRow(
|
||||
)
|
||||
}
|
||||
|
||||
var confirmKick by remember { mutableStateOf(false) }
|
||||
if (confirmKick) {
|
||||
ConcordKickMemberDialog(
|
||||
onConfirm = {
|
||||
accountViewModel.kickConcordMember(communityId, entry.pubkey)
|
||||
confirmKick = false
|
||||
},
|
||||
onDismiss = { confirmKick = false },
|
||||
)
|
||||
}
|
||||
|
||||
// A ban is reversible here, but not for the banned member: clients such as Armada drop the
|
||||
// community from a banned member's list on sight, so a mis-tap still costs them the community.
|
||||
var confirmBan by remember { mutableStateOf(false) }
|
||||
@@ -335,7 +363,7 @@ private fun ConcordMemberRow(
|
||||
Text(entry.pubkey.take(8), fontWeight = FontWeight.SemiBold, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
}
|
||||
}
|
||||
MemberBadge(entry.membership, entry.roleName)
|
||||
MemberBadge(entry.membership, entry.roleName, entry.departure)
|
||||
if (hasMenu) {
|
||||
var expanded by remember { mutableStateOf(false) }
|
||||
// One Box for button + menu: an expanded DropdownMenu emits a node, and as a direct child
|
||||
@@ -375,6 +403,15 @@ private fun ConcordMemberRow(
|
||||
},
|
||||
)
|
||||
}
|
||||
if (canKick) {
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringRes(Res.string.concord_members_kick)) },
|
||||
onClick = {
|
||||
confirmKick = true
|
||||
expanded = false
|
||||
},
|
||||
)
|
||||
}
|
||||
if (canBan) {
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringRes(if (isBanned) Res.string.concord_members_unban else Res.string.concord_members_ban)) },
|
||||
@@ -405,15 +442,18 @@ private fun ConcordMemberRow(
|
||||
}
|
||||
}
|
||||
|
||||
/** A small pill labelling the member's standing (owner / role name / banned; plain members render nothing). */
|
||||
/** A small pill labelling the member's standing (owner / role name / banned / kicked / left; plain members render nothing). */
|
||||
@Composable
|
||||
private fun MemberBadge(
|
||||
membership: ConcordMembership,
|
||||
roleName: String?,
|
||||
departure: GuestbookAction?,
|
||||
) {
|
||||
val label =
|
||||
when {
|
||||
membership == ConcordMembership.BANNED -> stringRes(Res.string.concord_role_banned)
|
||||
departure == GuestbookAction.KICK -> stringRes(Res.string.concord_role_kicked)
|
||||
departure == GuestbookAction.LEAVE -> stringRes(Res.string.concord_role_left)
|
||||
membership == ConcordMembership.OWNER -> stringRes(Res.string.concord_role_owner)
|
||||
// Show the actual granted role ("Admin", "Moderator", or a custom role) rather than a
|
||||
// one-size-fits-all badge; fall back to the generic "Admin" label if a role-holder's
|
||||
@@ -422,8 +462,18 @@ private fun MemberBadge(
|
||||
membership == ConcordMembership.ADMIN -> stringRes(Res.string.concord_role_admin)
|
||||
else -> return
|
||||
}
|
||||
val container = if (membership == ConcordMembership.BANNED) MaterialTheme.colorScheme.errorContainer else MaterialTheme.colorScheme.primaryContainer
|
||||
val content = if (membership == ConcordMembership.BANNED) MaterialTheme.colorScheme.onErrorContainer else MaterialTheme.colorScheme.onPrimaryContainer
|
||||
val container =
|
||||
when {
|
||||
membership == ConcordMembership.BANNED -> MaterialTheme.colorScheme.errorContainer
|
||||
departure != null -> MaterialTheme.colorScheme.surfaceVariant
|
||||
else -> MaterialTheme.colorScheme.primaryContainer
|
||||
}
|
||||
val content =
|
||||
when {
|
||||
membership == ConcordMembership.BANNED -> MaterialTheme.colorScheme.onErrorContainer
|
||||
departure != null -> MaterialTheme.colorScheme.onSurfaceVariant
|
||||
else -> MaterialTheme.colorScheme.onPrimaryContainer
|
||||
}
|
||||
Surface(shape = RoundedCornerShape(6.dp), color = container) {
|
||||
Text(
|
||||
text = label,
|
||||
@@ -492,6 +542,27 @@ private fun ConcordRolesDialog(
|
||||
)
|
||||
}
|
||||
|
||||
/** Confirms a Kick — spells out that it is cooperative and re-joinable (CORD-04 §6). */
|
||||
@Composable
|
||||
private fun ConcordKickMemberDialog(
|
||||
onConfirm: () -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text(stringRes(Res.string.concord_members_kick_title)) },
|
||||
text = { Text(stringRes(Res.string.concord_members_kick_message)) },
|
||||
confirmButton = {
|
||||
TextButton(onClick = onConfirm) {
|
||||
Text(stringRes(Res.string.concord_members_kick), color = MaterialTheme.colorScheme.error)
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = onDismiss) { Text(stringRes(Res.string.cancel)) }
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/** Confirms a hard removal — spells out that it rotates the community key (CORD-06). */
|
||||
@Composable
|
||||
private fun ConcordConfirmMemberActionDialog(
|
||||
@@ -523,6 +594,8 @@ private class RosterEntry(
|
||||
val roleName: String?,
|
||||
/** Every role id the member currently holds — the preselection for the role picker. */
|
||||
val roleIds: Set<String>,
|
||||
/** Their winning Leave or honored Kick when the Guestbook shows them departed, else null. */
|
||||
val departure: GuestbookAction?,
|
||||
)
|
||||
|
||||
/** One role the viewer is allowed to hand out, ordered by [position] (lower ranks higher). */
|
||||
@@ -532,7 +605,9 @@ private class AssignableRole(
|
||||
val position: Long,
|
||||
)
|
||||
|
||||
/** Owner first, then admins, then plain members, then banned last. */
|
||||
/** Owner first, then admins, then plain members, then departed (left/kicked) members, then banned last. */
|
||||
private fun RosterEntry.sortRank(): Int = if (departure != null && membership != ConcordMembership.BANNED && membership != ConcordMembership.OWNER) 35 else membership.sortRank() * 10
|
||||
|
||||
private fun ConcordMembership.sortRank(): Int =
|
||||
when (this) {
|
||||
ConcordMembership.OWNER -> 0
|
||||
|
||||
+140
-41
@@ -31,6 +31,7 @@ import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Badge
|
||||
import androidx.compose.material3.BadgedBox
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
@@ -39,11 +40,12 @@ import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.ModalBottomSheet
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.rememberModalBottomSheetState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.State
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.produceState
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
@@ -51,6 +53,7 @@ import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.vitorpamplona.amethyst.commons.actions.ConcordChannelPins
|
||||
import com.vitorpamplona.amethyst.commons.actions.ConcordPinnedMessage
|
||||
import com.vitorpamplona.amethyst.commons.actions.ConcordPinning
|
||||
@@ -58,8 +61,14 @@ import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbol
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
|
||||
import com.vitorpamplona.amethyst.commons.model.cache.LocalCache
|
||||
import com.vitorpamplona.amethyst.commons.model.nip92IMeta.appendMissingImetaUrls
|
||||
import com.vitorpamplona.amethyst.commons.model.toImmutableListOfLists
|
||||
import com.vitorpamplona.amethyst.commons.relayClient.user.observeUserInfo
|
||||
import com.vitorpamplona.amethyst.commons.resources.Res
|
||||
import com.vitorpamplona.amethyst.commons.resources.cancel
|
||||
import com.vitorpamplona.amethyst.commons.resources.concord_pin_expiring_body
|
||||
import com.vitorpamplona.amethyst.commons.resources.concord_pin_expiring_confirm
|
||||
import com.vitorpamplona.amethyst.commons.resources.concord_pin_expiring_title
|
||||
import com.vitorpamplona.amethyst.commons.resources.concord_pinned_budget
|
||||
import com.vitorpamplona.amethyst.commons.resources.concord_pinned_empty
|
||||
import com.vitorpamplona.amethyst.commons.resources.concord_pinned_open_hint
|
||||
@@ -68,6 +77,8 @@ import com.vitorpamplona.amethyst.commons.resources.concord_pinned_unavailable
|
||||
import com.vitorpamplona.amethyst.commons.resources.message_edited
|
||||
import com.vitorpamplona.amethyst.commons.resources.relay_group_pinned_content_description
|
||||
import com.vitorpamplona.amethyst.commons.resources.relay_group_unpin_message
|
||||
import com.vitorpamplona.amethyst.commons.ui.components.TranslatableRichTextViewer
|
||||
import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav
|
||||
import com.vitorpamplona.amethyst.commons.ui.note.timeAgoNoDot
|
||||
import com.vitorpamplona.amethyst.commons.ui.stringRes
|
||||
import com.vitorpamplona.amethyst.commons.ui.theme.placeholderText
|
||||
@@ -76,8 +87,13 @@ import com.vitorpamplona.quartz.concord.cord03Channels.ConcordChatEditEvent
|
||||
import com.vitorpamplona.quartz.concord.cord04Roles.pins.ConcordPins
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip09Deletions.DeletionRequestEvent
|
||||
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import kotlinx.coroutines.flow.conflate
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.filter
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.merge
|
||||
@@ -86,9 +102,15 @@ import org.jetbrains.compose.resources.StringResource
|
||||
|
||||
/**
|
||||
* [channelId]'s verified pins (CORD-04 §7), re-read whenever the Control Plane seats a new Pin List
|
||||
* head, the fold changes, or a delete / Edit lands in the cache (a held delete hides its entry at
|
||||
* once; a held newer Edit marks it edited). Null until the community has folded the channel.
|
||||
* head, the fold changes or finishes draining, a delete / Edit naming a pinned message lands in the
|
||||
* cache (a held delete hides its entry at once; a held newer Edit marks it edited), or a pinned
|
||||
* message's disappearing-message deadline passes (CORD-08 §3). Null until the community has folded
|
||||
* the channel.
|
||||
*
|
||||
* The session is looked up again on every session-set change: it may not exist at first
|
||||
* composition, and a Refounding replaces it — a captured one would read the dead epoch forever.
|
||||
*/
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
@Composable
|
||||
fun rememberConcordChannelPins(
|
||||
communityId: String,
|
||||
@@ -97,56 +119,100 @@ fun rememberConcordChannelPins(
|
||||
): State<ConcordChannelPins?> {
|
||||
val account = accountViewModel.account
|
||||
return produceState<ConcordChannelPins?>(null, account, communityId, channelId) {
|
||||
val session = account.concordSessions.sessionFor(communityId) ?: return@produceState
|
||||
account.concordSessions.revision
|
||||
.map { account.concordSessions.sessionFor(communityId) }
|
||||
.distinctUntilChanged { a, b -> a === b }
|
||||
.collectLatest { session ->
|
||||
if (session == null) {
|
||||
value = null
|
||||
return@collectLatest
|
||||
}
|
||||
// Only deletes and Edits that name a message this list carries can change the read.
|
||||
val evidence =
|
||||
account.cache.live.newEventBundles.filter { notes ->
|
||||
notes.any { it.event is DeletionRequestEvent || it.event is ConcordChatEditEvent }
|
||||
val carried = value?.rumorIds ?: return@filter true
|
||||
notes.any { note ->
|
||||
val event = note.event
|
||||
(event is DeletionRequestEvent || event is ConcordChatEditEvent) &&
|
||||
event.tags.any { it.size >= 2 && it[0] == "e" && it[1] in carried }
|
||||
}
|
||||
}
|
||||
merge(session.pinHeads.map { }, session.state.map { }, session.controlDrained.map { }, evidence.map { })
|
||||
.conflate()
|
||||
.collectLatest {
|
||||
// Re-read, then sleep until the next pinned message expires: an expired one
|
||||
// leaves the list, and nothing else would trigger that re-read. A new trigger
|
||||
// cancels the wait.
|
||||
while (true) {
|
||||
val pins = withContext(Dispatchers.Default) { account.concord.concordChannelPins(communityId, channelId) }
|
||||
value = pins
|
||||
val now = TimeUtils.now()
|
||||
val next = pins?.nextExpiry(now) ?: break
|
||||
delay((next - now) * 1000 + 250)
|
||||
}
|
||||
}
|
||||
merge(session.pinHeads.map { }, session.state.map { }, evidence.map { }).collect {
|
||||
value = withContext(Dispatchers.Default) { account.concord.concordChannelPins(communityId, channelId) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The deletion omission and the Edit refresh a PIN_MESSAGES holder owes keyless readers (§7), run
|
||||
* the way the spec asks: after a short random wait, re-read, and publish only if still owed — so
|
||||
* simultaneous curators collapse to one publisher and a burst of edits costs one write. One attempt
|
||||
* per distinct debt, so a failure never spins.
|
||||
* [pins] as a reader should see them: entries by a banned author (CORD-04 §4 — every client declines
|
||||
* to show their posts) or by someone this account mutes or blocks are left out.
|
||||
*/
|
||||
@Composable
|
||||
fun ConcordPinDuties(
|
||||
fun rememberVisibleConcordPins(
|
||||
communityId: String,
|
||||
channelId: String,
|
||||
pins: ConcordChannelPins?,
|
||||
pins: ConcordChannelPins,
|
||||
accountViewModel: AccountViewModel,
|
||||
) {
|
||||
val debt =
|
||||
remember(pins) {
|
||||
pins
|
||||
?.takeIf { it.owesRepublish }
|
||||
?.let { p -> (p.killed.map { "d" + it.rumorId } + p.pins.mapNotNull { it.newerEdit?.let { e -> "e" + e.rumorId } }).sorted().joinToString("|") }
|
||||
} ?: return
|
||||
val attempted = remember(communityId, channelId) { HashSet<String>() }
|
||||
LaunchedEffect(communityId, channelId, debt) {
|
||||
if (debt in attempted || !accountViewModel.account.concord.canPinConcord(communityId)) return@LaunchedEffect
|
||||
delay(ConcordPinning.dutyDelayMs())
|
||||
attempted.add(debt)
|
||||
accountViewModel.launchSigner { accountViewModel.account.concord.settleConcordPins(communityId, channelId) }
|
||||
): List<ConcordPinnedMessage> {
|
||||
val account = accountViewModel.account
|
||||
val revision by account.concordSessions.revision.collectAsStateWithLifecycle()
|
||||
val hidden by account.hiddenUsers.flow.collectAsStateWithLifecycle()
|
||||
return remember(pins, revision, hidden) {
|
||||
val authority =
|
||||
account.concordSessions
|
||||
.sessionFor(communityId)
|
||||
?.state
|
||||
?.value
|
||||
?.authority
|
||||
ConcordPinning.visible(pins, isBanned = { authority?.isBanned(it) == true }, isHidden = { account.isHidden(it) })
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The Pin action on a disappearing message (one carrying a CORD-08 `expiration`) asks first: the pin
|
||||
* carries the message's words in its proof, so it keeps them readable after the timer erased the
|
||||
* message everywhere else.
|
||||
*/
|
||||
@Composable
|
||||
fun ConcordExpiringPinDialog(
|
||||
onConfirm: () -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text(stringRes(Res.string.concord_pin_expiring_title)) },
|
||||
text = { Text(stringRes(Res.string.concord_pin_expiring_body)) },
|
||||
confirmButton = { TextButton(onClick = onConfirm) { Text(stringRes(Res.string.concord_pin_expiring_confirm)) } },
|
||||
dismissButton = { TextButton(onClick = onDismiss) { Text(stringRes(Res.string.cancel)) } },
|
||||
)
|
||||
}
|
||||
|
||||
/** The channel header's pinned-messages entry point: a pin with a count badge. Hidden when there is nothing to show. */
|
||||
@Composable
|
||||
fun ConcordPinnedButton(
|
||||
communityId: String,
|
||||
pins: ConcordChannelPins?,
|
||||
accountViewModel: AccountViewModel,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
if (pins == null || (pins.count == 0 && !pins.sealedUnavailable)) return
|
||||
if (pins == null) return
|
||||
val count = rememberVisibleConcordPins(communityId, pins, accountViewModel).size
|
||||
if (count == 0 && !pins.sealedUnavailable) return
|
||||
IconButton(onClick = onClick) {
|
||||
BadgedBox(
|
||||
badge = {
|
||||
if (pins.count > 0) Badge { Text(pins.count.toString()) }
|
||||
if (count > 0) Badge { Text(count.toString()) }
|
||||
},
|
||||
) {
|
||||
Icon(symbol = MaterialSymbols.PushPin, contentDescription = stringRes(Res.string.relay_group_pinned_content_description))
|
||||
@@ -156,8 +222,9 @@ fun ConcordPinnedButton(
|
||||
|
||||
/**
|
||||
* The pinned-messages sheet: each verified pin with its author, time and words (marked edited when
|
||||
* revised), an "unavailable" notice when the list is sealed under a key this account never held,
|
||||
* a jump to the message when it resolves locally, and Unpin for those who may write pins.
|
||||
* revised) rendered like the chat feed renders the message — links, mentions and its `imeta`
|
||||
* attachments included — an "unavailable" notice when the list is sealed under a key this account
|
||||
* never held, a jump to the message when it resolves locally, and Unpin for those who may write pins.
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
@@ -166,11 +233,16 @@ fun ConcordPinnedMessagesSheet(
|
||||
channelId: String,
|
||||
pins: ConcordChannelPins,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
onJumpToMessage: (HexKey) -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
val canPin = remember(pins) { accountViewModel.account.concord.canPinConcord(communityId) }
|
||||
val session = remember(communityId) { accountViewModel.account.concordSessions.sessionFor(communityId) }
|
||||
val revision by accountViewModel.account.concordSessions.revision
|
||||
.collectAsStateWithLifecycle()
|
||||
val session = remember(communityId, revision) { accountViewModel.account.concordSessions.sessionFor(communityId) }
|
||||
// Banned authors and muted/blocked users stay out of the sheet, as they do from the feed.
|
||||
val shown = rememberVisibleConcordPins(communityId, pins, accountViewModel)
|
||||
|
||||
ModalBottomSheet(
|
||||
onDismissRequest = onDismiss,
|
||||
@@ -198,7 +270,7 @@ fun ConcordPinnedMessagesSheet(
|
||||
modifier = Modifier.padding(horizontal = 16.dp),
|
||||
)
|
||||
}
|
||||
if (pins.count > 0) {
|
||||
if (shown.isNotEmpty()) {
|
||||
Text(
|
||||
text = stringRes(Res.string.concord_pinned_open_hint),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
@@ -210,16 +282,18 @@ fun ConcordPinnedMessagesSheet(
|
||||
|
||||
if (pins.sealedUnavailable) {
|
||||
PinNotice(Res.string.concord_pinned_unavailable, MaterialSymbols.Lock)
|
||||
} else if (pins.count == 0) {
|
||||
} else if (shown.isEmpty() && pins.complete) {
|
||||
// Only a drained fold may say "no pins"; before that the list may simply not be served yet.
|
||||
PinNotice(Res.string.concord_pinned_empty, MaterialSymbols.PushPin)
|
||||
}
|
||||
|
||||
LazyColumn {
|
||||
items(pins.pins, key = { it.rumorId }) { pinned ->
|
||||
items(shown, key = { it.rumorId }) { pinned ->
|
||||
val jumpable = remember(pinned.rumorId, session) { session?.holdsRumor(pinned.rumorId) == true }
|
||||
PinnedRow(
|
||||
pinned = pinned,
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav,
|
||||
onClick =
|
||||
if (jumpable) {
|
||||
{
|
||||
@@ -257,6 +331,7 @@ private fun PinNotice(
|
||||
private fun PinnedRow(
|
||||
pinned: ConcordPinnedMessage,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
onClick: (() -> Unit)?,
|
||||
onUnpin: (() -> Unit)?,
|
||||
) {
|
||||
@@ -292,12 +367,7 @@ private fun PinnedRow(
|
||||
)
|
||||
}
|
||||
}
|
||||
Text(
|
||||
text = pinned.content,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
maxLines = 6,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
PinnedContent(pinned, accountViewModel, nav)
|
||||
}
|
||||
if (onUnpin != null) {
|
||||
IconButton(onClick = onUnpin) {
|
||||
@@ -307,6 +377,35 @@ private fun PinnedRow(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The pinned message's words and attachments through the chat feed's own pipeline: the rumor rebuilt
|
||||
* from the proof (its encrypted attachments' keys registered), an attachment-only message given its
|
||||
* `imeta` URLs as text exactly as the feed does, and the shared rich-text viewer rendering the media.
|
||||
*/
|
||||
@Composable
|
||||
private fun PinnedContent(
|
||||
pinned: ConcordPinnedMessage,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
) {
|
||||
val rumor = remember(pinned) { accountViewModel.account.concord.concordPinnedRumor(pinned) }
|
||||
val tags = remember(rumor) { rumor.tags.toImmutableListOfLists() }
|
||||
val content = remember(rumor) { appendMissingImetaUrls(rumor.content, rumor) }
|
||||
val background = MaterialTheme.colorScheme.surface
|
||||
val backgroundColor = remember(background) { mutableStateOf(background) }
|
||||
TranslatableRichTextViewer(
|
||||
content = content,
|
||||
canPreview = true,
|
||||
quotesLeft = 0,
|
||||
tags = tags,
|
||||
backgroundColor = backgroundColor,
|
||||
id = pinned.rumorId,
|
||||
authorPubKey = pinned.author,
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav,
|
||||
)
|
||||
}
|
||||
|
||||
/** [hex]'s best display name, reactively, falling back to a short hex. */
|
||||
@Composable
|
||||
private fun rememberPinAuthorName(
|
||||
|
||||
+27
@@ -75,8 +75,13 @@ import com.vitorpamplona.amethyst.commons.resources.cashu_failed_redemption
|
||||
import com.vitorpamplona.amethyst.commons.resources.cashu_failed_redemption_explainer_error_msg
|
||||
import com.vitorpamplona.amethyst.commons.resources.cashu_successful_redemption
|
||||
import com.vitorpamplona.amethyst.commons.resources.cashu_successful_redemption_explainer
|
||||
import com.vitorpamplona.amethyst.commons.resources.concord_kicked_message
|
||||
import com.vitorpamplona.amethyst.commons.resources.concord_kicked_message_unnamed
|
||||
import com.vitorpamplona.amethyst.commons.resources.concord_kicked_title
|
||||
import com.vitorpamplona.amethyst.commons.resources.concord_members_kick_failed
|
||||
import com.vitorpamplona.amethyst.commons.resources.concord_members_roles_failed
|
||||
import com.vitorpamplona.amethyst.commons.resources.concord_members_roles_title
|
||||
import com.vitorpamplona.amethyst.commons.resources.concord_members_title
|
||||
import com.vitorpamplona.amethyst.commons.resources.concord_pin_failed_generic
|
||||
import com.vitorpamplona.amethyst.commons.resources.concord_pin_failed_message
|
||||
import com.vitorpamplona.amethyst.commons.resources.concord_pin_failed_title
|
||||
@@ -288,6 +293,18 @@ class AccountViewModel(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// An honored Kick made us leave a Concord community (CORD-04 §6); the removal is otherwise silent.
|
||||
viewModelScope.launch {
|
||||
account.concord.concordKicks.collect { notice ->
|
||||
val name = notice.communityName?.takeIf { it.isNotBlank() }
|
||||
if (name != null) {
|
||||
toastManager.toast(Res.string.concord_kicked_title, Res.string.concord_kicked_message, name)
|
||||
} else {
|
||||
toastManager.toast(Res.string.concord_kicked_title, Res.string.concord_kicked_message_unnamed)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -668,6 +685,16 @@ class AccountViewModel(
|
||||
if (ban) account.concord.banConcordMember(communityId, member) else account.concord.unbanConcordMember(communityId, member)
|
||||
}
|
||||
|
||||
/** Kick [member] from [communityId] (CORD-04 §6: strip their roles, then the Guestbook directive). */
|
||||
fun kickConcordMember(
|
||||
communityId: String,
|
||||
member: HexKey,
|
||||
) = launchSigner {
|
||||
if (!account.concord.kickConcordMember(communityId, member)) {
|
||||
toastManager.toast(Res.string.concord_members_title, Res.string.concord_members_kick_failed)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove [member] from [communityId] absolutely (CORD-06 Refounding): rotate the
|
||||
* community key so the member's key stops working for anything sent afterwards.
|
||||
|
||||
@@ -70,7 +70,7 @@ Ranked security > interop > feature inside each group.
|
||||
| I9 | 06 §3 | Rotations carry no `vac` | **fixed** — rotations carry `vac` on every chunk (`ConcordRotationAuthority.citationFor`, owner none); receivers require `ConcordReceive.isHonoredRotation` (BAN + `citationSatisfied`, Armada semantics) in the app drain and CLI `rekey`; a rotator whose chunks cite different Grants is dropped |
|
||||
| I10 | 06 | 120 base blobs per chunk can overflow NIP-44; Armada budgets 99 @104 B / 90 @136 B | **fixed** — `ConcordRekey.chunkBlobs` budgets the rumor JSON at 40,960 bytes (Armada `REKEY_RUMOR_MAX_BYTES`) plus the 120 count cap; test pins the seal (wrap plaintext) ≤ 65,535 with 136-byte blobs |
|
||||
| I11 | 05 §3 | Invite links carry more than 3 bootstrap relays; Armada's decoder throws | **fixed** — `encodeFragment` truncates non-stock lists to 3 (stock set stays a flag); `decodeFragment` refuses count > 3 |
|
||||
| I12 | 06 §3 | No race convergence (lowest new root), not idempotent on retry | **fixed** — `findNewRoot` converges on the lowest authorized root (`accept` filter before `converge`); sessions watch the current epoch's own rekey address and `drainConcordRekeys` heals down-only (`ConcordReceive.withHealedRoot`), keeping the losing root as a same-epoch held root (only the lowest per epoch is folded: `canonicalHeldRoots`); retries reuse reserved keys (`ConcordRefounding.reserveKeys`; in-memory in the app, persisted in amy's store). Not done: the CLI has no heal step; re-issuing a losing branch's channel keys (no private channels yet, F7) |
|
||||
| I12 | 06 §3 | No race convergence (lowest new root), not idempotent on retry | **fixed** — `findNewRoot` converges on the lowest authorized root (`accept` filter before `converge`); sessions watch the current epoch's own rekey address and `drainConcordRekeys` heals down-only (`ConcordReceive.withHealedRoot`), keeping the losing root as a same-epoch held root (only the lowest per epoch is folded: `canonicalHeldRoots`); retries reuse reserved keys (`ConcordRefounding.reserveKeys`; in-memory in the app, persisted in amy's store). Not done: the CLI has no heal step; re-issuing a losing branch's channel keys on the winning chain (F7 rotates private channels inside a Refounding under the prior root, which both branches can open, but a losing refounder does not yet re-issue its channel keys after the heal) |
|
||||
| I13 | 03 §3 | Binding check not strict (duplicates accepted, `"04"`/`"+4"` parse) | **fixed** — exactly one `channel` and one `epoch` tag, epoch compared as its canonical decimal string (Armada `uniqueTag`/`checkChannelBinding`); builders drop binding tags smuggled in `extraTags` |
|
||||
| I14 | 03 §2 | Channel deletion not terminal across the chain; no 64-byte name cap | **fixed** — any gated channel edition with `deleted:true` retires the channel for good (Armada `everDeleted`); the channel gate refuses an empty or >64-byte name so the fold falls back to the previous candidate, and `defineChannel`/create/rename refuse to mint one |
|
||||
| I15 | 02 §4 | No `ms` tag on chat rumors | **fixed** — every `ChannelChat` rumor carries `["ms", 0..999]` after the binding; malformed/duplicated `ms` drops the rumor; `channelMessages` and edit recency order by `created_at*1000+ms` (the shared feed still sorts by `created_at`; open PR #7 may drop `ms`) |
|
||||
@@ -82,19 +82,59 @@ Ranked security > interop > feature inside each group.
|
||||
|
||||
| # | Spec | Finding | Status |
|
||||
|---|---|---|---|
|
||||
| F1 | 04 §7 | Pins | **fixed** — commons `ConcordPinning` reads each Channel's Pin List off the session fold (`pinHeads`, gated on PIN_MESSAGES + `vac`, with the fold's floors), opens the sealed form with the held key of its epoch (`sealedUnavailable` kept distinct from empty), verifies entries through a per-entry-identity cache, hides entries killed by the author's held kind 5 and marks entries behind a newer held Edit as edited; pin/unpin reopen the message's original wrap (session rumor→wrap index) and write the next edition over the head read, in the channel's folded form (a private-era sealed list is never re-formed public), withheld when unreadable, refused past 25 entries / 32,768 bytes; deleting your own pinned message publishes the omission at once, and an open channel runs the delayed (3–15 s) re-read-then-publish duty for other holders' omissions and the Edit refresh. App: Pin/Unpin in the message sheet, header pin badge + pinned sheet (author, time, edited, unavailable, jump), budget line; `amy concord pins/pin/unpin`. Also fixed `DeletionIndex.DeletionRequest.compareTo` (compared the pubkey with itself, so any author's kind 5 matched on JVM/Android). Open SHOULDs: the duties run only while the channel screen is open (no background scheduler); a Rotator does not republish under the new key after a private-channel rekey, and a Banlist revert is not re-healed; compaction does not omit a deleted Channel's Pin List; pinned attachments render as text only |
|
||||
| F1 | 04 §7 | Pins | **fixed** — commons `ConcordPinning` reads each Channel's Pin List off the session fold (`pinHeads`, gated on PIN_MESSAGES + `vac`, with the fold's floors), opens the sealed form with the held key of its epoch (`sealedUnavailable` kept distinct from empty), verifies entries through a per-entry-identity cache, hides entries killed by the author's held kind 5 and marks entries behind a newer held Edit as edited; pin/unpin reopen the message's original wrap (session rumor→wrap index) and write the next edition over the head read, in the channel's folded form (a private-era sealed list is never re-formed public), withheld when unreadable, refused past 25 entries / 32,768 bytes; deleting your own pinned message publishes the omission at once, and an open channel runs the delayed (3–15 s) re-read-then-publish duty for other holders' omissions and the Edit refresh. App: Pin/Unpin in the message sheet, header pin badge + pinned sheet (author, time, edited, unavailable, jump), budget line; `amy concord pins/pin/unpin`. Also fixed `DeletionIndex.DeletionRequest.compareTo` (compared the pubkey with itself, so any author's kind 5 matched on JVM/Android). Open SHOULDs: a Rotator does not republish under the new key after a private-channel rekey, and a Banlist revert is not re-healed; compaction does not omit a deleted Channel's Pin List. **Fixed since** (features batch): the delayed duties run from the account, not the screen — `ConcordPinDutyScheduler` + `AccountConcordActions.scheduleConcordPinDuties`, fired on every Concord revision tick and whenever a delete or an Edit lands (sampled 1 s), for every community where we may write pins and every Channel with a Pin List head; each duty waits the 3–15 s random delay, one per channel in flight, one attempt per distinct debt, and `settleConcordPins` re-reads before publishing (the screen's `ConcordPinDuties` composable is gone); pinned messages render through the feed's pipeline — the rumor rebuilt from the proof (`ConcordPinnedMessage.toRumor`, keeping the original's `imeta` tags) registers its encrypted attachments' keys and goes through `appendMissingImetaUrls` + the shared `TranslatableRichTextViewer`, so images (and links, mentions) show in the pinned sheet even for a message this account never held |
|
||||
| F2 | 08 | Disappearing Messages (sender tags, reader refusal/hiding/purge, 1740 notice, settings UI) | **fixed** — every durable Chat rumor (9/1111/7/3302, image variants) signs `created_at + timer` from the send-time fold and its wrap repeats it (`ConcordStreamEnvelope.wrap(outerTags)`, random `p` kept; never on 5/1740/typing); expired rumors refused at ingest (`openChannelRumor`, session, rumor sink), hidden in feed/preview/unread (`Account.isAcceptable`), and purged from LocalCache + wrap note + session buffer by a sweep scheduled on the earliest deadline (`ConcordSessionManager.nextExpiry`); typed `ConcordTimerNoticeEvent` posted per held channel after a timer change and rendered as a system row only for MANAGE_METADATA authors; timer picker in the edit screen + composer indicator; `amy concord timer`, `send` tags, `read` filters |
|
||||
| F3 | 07 | A/V calls: only key derivation, the 27235 grant and 23313 presence builders exist; no broker/SFU client, no media E2EE. Needs a LiveKit client whose license must be checked first | open — out of scope for this pass |
|
||||
| F4 | 07 | Broker token has no nonce (same-second requests collide in the broker's replay set); presence fold doesn't take latest-per-author | open → chat-plane batch (quartz only) |
|
||||
| F3 | 07 | A/V calls: only key derivation, the 27235 grant (now with Armada's nonce, F4) and the 23313 presence fold exist; no broker/SFU client, no media E2EE | open — **deferred** (maintainer decision, 2026-09-29). Two blockers found: (1) `io.livekit:livekit-android` 2.29.0 pulls runtime `javax.sip:android-jain-sip-ri` 1.3.0-91, whose `android.javax.sip.*` API classes carry the Sun/BEA "use is subject to license terms" header under the proprietary JSIP spec license (no linking exception; STOP under our licensing rule); it can't simply be excluded because LiveKit's `PeerConnectionTransport` munges SDP through its `SdpFactory` on every publish. (2) Interop: Armada keys LiveKit E2EE with `keySize: 256` (AES-256-GCM per CORD-07 §3), but every native LiveKit/webrtc-sdk frame cryptor derives 128 bits (`frame_crypto_transformer.h` `DeriveKeys(..., 128)`), so an Android client can't decode Armada frames. Matching settings otherwise: HKDF-SHA256 (salt `LKFrameEncryptionKey`, info 128 zero bytes), per-identity key = quartz `voiceSenderKey`, `sharedKey=false`, ratchet window 0, failure tolerance -1, keyring 16, trailer `IV(12) ‖ [12, keyIndex]`. Paths when resumed: CORD-07 allows the 128-bit native key, or a patched WebRTC honouring 256; own LiveKit signaling (Apache-2.0 protocol) instead of livekit-android avoids jain-sip; desktop via webrtc-java 0.19 encoded-frame transforms (WARN: statically linked LGPL FFmpeg). Armada's default broker `https://armada.buzz` answers the capability probe and mints LiveKit HS256 JWTs (6 h TTL, 128-bit identity, SFU `wss://av.armada.buzz`) |
|
||||
| F4 | 07 | Broker token has no nonce (same-second requests collide in the broker's replay set); presence fold doesn't take latest-per-author | **fixed** — `ConcordBrokerToken.buildAuthEvent` signs a fresh `["nonce", <64 hex>]` after `u`/`method` (Armada `signAvGrant`'s tag, order and 32-byte width), so two members' same-second grants never share an id; `VoicePresenceInfo` carries the CORD-02 §4 `ms` basis + rumor id, `parse` drops a bad verb, an identity-less `joined` or a malformed `ms`, and `VoicePresence.fold`/`latestPerAuthor` take each author's latest presence (ms, lower rumor id on a tie, as Armada `foldVoicePresence`) before staleness and the one-claimant identity check; `joined`/`left` stamp `ms`. No app caller yet (the voice UI is not built) |
|
||||
| F5 | 05 §5 | Invite Registry (vsk 8) not published or folded | **fixed** — `ConcordInviteRegistry` (builder, strict-array decode, `nextLinks` pruning expired/tombstoned links) + `ConcordCommunityState.inviteRegistries`/`liveInviteLinks`/`isPublic`/`hasForeignLiveLinks`/`banRequiresRefounding`/`retiringWouldPrivatize` (gated on CREATE_INVITE, coordinate bound to author); mint/revoke publish the registry (app + amy); a Private ban Refounds, a Public one is the Banlist alone; retiring the last live link runs a privatizing Refounding (`privatizeConcordCommunity`; amy reports it and adds `refound --privatize`); Public/Private shown in the server view and warned in the revoke dialog. Deviation from Armada, following the spec: a ban Refounds iff the community is Private without the targets' registries (Armada rotates whenever no *foreign* link exists, and only warns on privatizing revokes) |
|
||||
| F6 | 05 §6 | Direct invites: wire format only, no send/receive | **fixed** — wrap backdates seal/wrap ≤2 days, carries NIP-40 `expiration` = `expires_at`, `ConcordDirectInvite.open` returns the seal-verified sender and refuses rumor/seal pubkey mismatch, bad seal sig, non-3313 rumors, §1 bounds and bad owner proof; send (`sendConcordDirectInvite` / `amy concord invite --to`) vends only the private channels the recipient's channel-scoped roles grant (`ConcordInviteVend`, Armada `vendableChannels`) to their 10050 → NIP-65 read → stock relays; headless `ConcordDirectInviteInbox` (sweep via `directInvitesFilter` + the NIP-17 seal handler) dedupes by wrap id, skips expired wraps, parks invites, remembers declines; accept shares the link join path, refuses past `expires_at`, and for a held community only adopts new private-channel keys on the same root/epoch/control_pk (`catchUpChannelIds`); UI card + "Invite by npub"; `amy concord invites/accept/decline`. Not done: Armada's auto-adopt of staff-sent catch-ups (`judgeCatchUp`) and `channel_cuts` (not modeled here) |
|
||||
| F7 | 06 §1-2 | Channel-scope rekeys; private-channel keys in invites | open (depends on S2) |
|
||||
| F6 | 05 §6 | Direct invites: wire format only, no send/receive | **fixed** — wrap backdates seal/wrap ≤2 days, carries NIP-40 `expiration` = `expires_at`, `ConcordDirectInvite.open` returns the seal-verified sender and refuses rumor/seal pubkey mismatch, bad seal sig, non-3313 rumors, §1 bounds and bad owner proof; send (`sendConcordDirectInvite` / `amy concord invite --to`) vends only the private channels the recipient's channel-scoped roles grant (`ConcordInviteVend`, Armada `vendableChannels`) to their 10050 → NIP-65 read → stock relays; headless `ConcordDirectInviteInbox` (sweep via `directInvitesFilter` + the NIP-17 seal handler) dedupes by wrap id, skips expired wraps, parks invites, remembers declines; accept shares the link join path, refuses past `expires_at`, and for a held community only adopts new private-channel keys on the same root/epoch/control_pk (`catchUpChannelIds`); UI card + "Invite by npub"; `amy concord invites/accept/decline`. F7 follow-ups done: staff-sent catch-ups auto-adopt (`judgeCatchUp`), `channel_cuts` is modeled, and a catch-up now only ADDS a missing key from a staff sender for a live Private Channel (never replaces a held key), re-applied inside the List write |
|
||||
| F7 | 06 §1-2 | Channel-scope rekeys; private-channel keys in invites | **fixed** — quartz `ConcordChannelRekey`: root-keyed `concord/rekey-pseudonym` address, 72-byte scope-bound blobs, chunked 3303 with `prevcommit` over the held channel key and `vac` on every chunk; the receive walk adopts only complete, honored (owner / MANAGE_CHANNELS / BAN + synced `vac`) rotations off the held key (multi-epoch, racing rotators → lowest key) and treats "no blob" as a cut only from a rotator who outranks us, published after our join. `ConcordChannelKeyring` rotates keys in place, reads older keys from `seed`/peer `priors` (never writes intermediate keys, CORD-02 §8) and models Armada's `channel_cuts` floor (extension, round-tripped). `ConcordInviteVend.entitledMembers`/`accessChanges`/`judgeCatchUp`. Commons `ConcordPrivateChannels` + app verbs: create Private channel (key at channel epoch 0 + bit-less access Role, as Armada), privatise (next channel epoch, floored by probing the rekey addresses) / publicise, `rekeyConcordChannel`, vend on grant (Direct Invite limited to the gained channels) and rotate on revoke/ban, the Refounding rotates every held private channel under the prior root, sessions subscribe/AUTH/buffer the channel-rekey window and the revision tick drains it; staff catch-ups auto-adopt. Links carry no channel keys (F6's `vendableChannels`, audience link). UI: Private toggle + access-role name on create, Make private/public + Rotate key per channel. `amy concord channel create/privatize/publicize/rekey`, `rekey`/`grant`/`refound` follow channel keys. Not done: republishing a rotated channel's sealed Pin List under the new key (a SHOULD); history across our own rotations after restart (intermediate keys stay out of the List by spec, and the key walk from `seed` is not implemented); a role *scope edit* in the UI does not trigger reconcile (only grants/revokes/bans do) |
|
||||
| F8 | 06 §2, 02 §8 | Walk forward from `seed`; we still keep intermediate roots in a `held_roots` List extension the spec says doesn't belong there | open |
|
||||
| F9 | 04 §6 | Kick (kind 3309) | open |
|
||||
| F10 | 03 | WebXDC (kind 3310) | open |
|
||||
| F9 | 04 §6 | Kick (kind 3309) | **fixed** — quartz `Guestbook.kick` writes the examples' shape (`ms`, `p`, `vac`), `Guestbook.parse` reads Kicks too (target, author, `ms` basis, rumor id, citation; malformed `ms`/target dropped; a 3306 "kick" verb is not a Kick), `Guestbook.canKick` honors one only from a KICK holder who strictly outranks the target with a synced `vac` (block-until-synced), and `Guestbook.coalesce` is the CORD-02 §5 fold (latest per npub by ms, lower rumor id on a tie, >1 h future dropped, banned authors dropped) — the old projection was createdAt-only and ignored Kicks. Commons: the session coalesces against its roster and re-coalesces on every control fold (a parked Kick lands when its Grant folds), exposes `guestbook`, `departedMembers()` (Leave/Kick not followed by newer observed activity — observation counts forward only) and `kickedMe()` (honored Kick postdating the entry's `added_at`); `allMembers()` drops the departed; guestbook seals must be encrypted. `kickConcordMember` strips roles first (MANAGE_ROLES + outrank, best-effort, as Armada) then publishes the directive; the revision tick's `drainConcordKicks` leaves the community locally (List tombstone, like Leave; network-silent) and emits a `ConcordKickNotice` the app toasts. Re-join works: `follow` bumps `added_at` past the tombstone, which also puts the old Kick behind the new membership. UI: Kick (KICK + outrank, confirm dialog) next to Ban in the members roster; departed members show a Kicked/Left badge. `amy concord kick COMMUNITY USER`. Not done: Armada's double-read debounce before self-removal, the dissolution ordering rule for Kicks (we refuse to *write* one on a dissolved community but do not date-check received ones), and Guestbook snapshots (kind 3312) |
|
||||
| F10 | 03 | WebXDC (kind 3310) | **fixed (plumbing only)** — 3310 was refused by the Chat gate and silently dropped. quartz `ConcordWebxdc` builds/parses what Armada puts on the kind (the spec leaves the payload opaque, examples §2.6): app **state updates** (`["i", <session>]`, `["alt","Webxdc update"]`, optional `info`/`document`/`summary`, JSON content) and realtime **peer signals** (`{"op":"ad","topic":<52-char base32>,"addr":…}` / `{"op":"left","topic":…}`, addr ≤ 2048, as Vector bounds it), both under the usual `channel`/`epoch`/`ms` binding; `ChannelChat.PLANE_KINDS` = `CHAT_KINDS` + 3310 and `acceptOpened(…, kinds)`, so 3310 stays out of `CHAT_KINDS` (never a row, never pinnable, still refused by `EventCache.consumeConcordRumor`). The session opens its planes with `PLANE_KINDS` and routes 3310 into a bounded per-channel buffer (`webxdcSignals(channel)`, `webxdcRevision`; 2000 per channel, CORD-08-expired ones filtered) instead of the chat store, so feeds, previews and unread counts never see it; its author still counts as observed. Amethyst has no WebXDC host. **Full support** (Armada `useConcordAppSync` + `XdcAttachment`, interoperating with Vector) would take: rendering an `.xdc` attachment (`application/x-webxdc` imeta carrying a `webxdc` session/topic id, or a topic derived from URL + message id) as an app card; a sandboxed WebView runtime exposing the webxdc JS API (`sendUpdate`/`setUpdateListener` → durable 3310 state updates for the session, read back by `#i`, with the CORD-08 `expiration`; `joinRealtimeChannel` → realtime); and for realtime an iroh gossip transport (Vector's frame format with the 36-byte `seq‖sender` trailer, topic = base32(sha256(…))) advertised/withdrawn by 3310 peer signals and folded latest-per-author (ties to `left`, >1 h future refused) — there is no relay fallback by design. The `:napplet` WebView sandbox is the natural host; iroh has no Kotlin implementation (our `:quic` is plain QUIC/HTTP3) |
|
||||
|
||||
## Audit 2 (2026-09-29) — batch A (non-Refounding)
|
||||
|
||||
Root cause shared by P1/P13/R7: nothing told a session its Control Plane had finished its
|
||||
initial drain. Now `ConcordCommunitySession.controlDrained` is set by
|
||||
`syncConcordControlPlanes` once a relay pages the whole current plane (`DRAINED`) and the
|
||||
swept wraps are ingested; `foldForWrite()` is the fold writers may build on (null before).
|
||||
Batch B can reuse it for the ban/privatize decisions.
|
||||
|
||||
| # | Status |
|
||||
|---|---|
|
||||
| P1 | **fixed** — `ConcordChannelPins.complete`; `ConcordPinning.refusal` → `NOT_FOLDED` until drained; the sheet never says "no pins" before the drain |
|
||||
| P2 | **fixed** — `buildChannelEdit(expiration = expirationOf(target))`: an Edit carries the original's deadline verbatim (none if it has none); a smuggled `expiration` in `extraTags` is dropped (Armada `useTransport.ts`; the spec's "computed from the rumor's own created_at" reads otherwise — noted) |
|
||||
| P3 | **fixed** — pin writes `publishAndConfirm` (`NOT_CONFIRMED` otherwise) and re-apply the same op atop a concurrent winner, ≤2 retries |
|
||||
| P4 | **fixed** — `rememberConcordChannelPins`, the pinned sheet and `ConcordTimerIndicator` re-resolve the session on `concordSessions.revision` |
|
||||
| P5 | **fixed** — the pins re-read at the soonest pinned message's NIP-40 deadline (`ConcordChannelPins.nextExpiry`) |
|
||||
| P6 | **fixed** — list reads memoized by head rumor id (`ConcordPinVerifier.readList`); `PinListRead.sealedForm` (no second parse); evidence trigger filtered to deletes/Edits naming a pinned rumor; action-sheet pin state via `produceState` on `Dispatchers.Default`. The per-channel verifier cache stays the session-wide 512-entry one |
|
||||
| P7 | **fixed** — deadlines kept sorted (binary-searched insert; no PriorityQueue in common code); sweeps pop only what is due; the account sweep waits 2 s past a deadline to coalesce |
|
||||
| P8 | **fixed** — bounded (4096) set of swept wrap ids; re-deliveries dropped before opening |
|
||||
| P9 | **fixed** — `ConcordSessionRegistry.sync` carries `trackedExpiring()` into the rebuilt session |
|
||||
| P10 | **fixed** — sheet and badge leave out banned authors and muted/blocked users (`ConcordPinning.visible`) |
|
||||
| P11 | **fixed** — confirm dialog before pinning a message carrying `expiration`; `amy concord pin` refuses (`expiring_message`) without `--force` |
|
||||
| P12 | **fixed** — `amy concord pins` hides expired pinned messages |
|
||||
| P13 | **fixed** — `editConcordMetadata` / `setConcordMessageExpiration` return false until drained (the owner too) and chain onto the floor-aware head |
|
||||
| P14 | **fixed** — `ExpiredConcordRumor.attachmentUrls`; the sweep evicts them from `encryptionKeyCache` |
|
||||
| recomputeNextExpiry | **fixed** — computed and assigned under a lock |
|
||||
| D3 | **fixed** — `SealEvent.unsealRumorThrowing` + `ConcordDirectInvite.openRumor`/`offerRumor`: the NIP-17 seal handler decrypts once; k=3313 wraps that never open are marked seen (GiftWrap and Seal handlers), so the hub's sweep skips them. Chosen over a persisted cursor: the DM pipeline sees every invite wrap first in the same process, so the sweep re-decrypts nothing it already handled; a cold start still re-sweeps from `since = null` once per process |
|
||||
| D4 | **fixed** — ≤256 parked (followed senders outrank strangers, then newer), hidden senders never park and are filtered, followed senders listed first, invites rendered as lazy items (the empty state scrolls) |
|
||||
| D5 | **fixed** — an invite whose clamped `sentAt` is at or before the Community List tombstone's `removed_at` stays hidden (`ConcordChannelListState.removedAt`) |
|
||||
| D6 | **fixed** — the cursor advances to `min(created_at, now + 15 min)` |
|
||||
| D7 | **fixed** — dedupe per (community, sender), ranked by `sentAt` clamped to now |
|
||||
| D8 | **fixed** — written off only on a definitive outcome; `openOrRetry` rethrows a transient signer failure (timeout, not approved, backgrounded, not found) and the inbox leaves the wrap for a retry |
|
||||
| D10 | **fixed** — declines capped at 4096 (app + amy); `restoreDeclined` is `suspend` under the inbox mutex; the sweep runs once per hub visit outside the lazy list and rethrows cancellation; catch-up cards list only newly adopted channels, by folded name. Also (F7 note): `visible()` hides a catch-up `acceptPlan` would refuse against the held fold |
|
||||
| R3 | **fixed** — a readable Invite List is authoritative in `nextLinks` (no resurrected links); registry edits are `publishAndConfirm`ed |
|
||||
| R7 | **fixed** — `publishConcordInviteRegistry` skips until drained and chains onto the floor-aware head (`ConcordModeration.setInviteRegistry(floors = session.controlFloors())`) |
|
||||
| R8 | **fixed** — no registry edit on a dissolved community; `retiringWouldPrivatize` is false once dissolved, so a revoke there reports `REVOKED` |
|
||||
| R9 | **fixed** — after a drain, this account's registry is republished pruned when it lists an elapsed/unbacked link (once per community and epoch per process) |
|
||||
| R11 | **fixed** — `invite_links_locator` memoized per (community, author) in `AuthorityResolver` |
|
||||
|
||||
## Spec issues to raise upstream
|
||||
|
||||
- CORD-07 §3 mandates AES-256-GCM frames, but LiveKit's native SDKs (Android, Swift, Rust, C++) only derive 128-bit frame keys (`livekit-client` documents keySize 128 as "the only value supported by non-web SDKs"), so a conforming native client can't interoperate with a 256-bit web client. The spec should allow (or require) the 128-bit derived key, or pin the KDF output length explicitly.
|
||||
|
||||
- CORD-06 §1 counts rekey capacity in blobs ("up to 120 participants per event"), but 120 base
|
||||
blobs overflow NIP-44's 65,535-byte plaintext once wrapped; the spec should state the byte budget
|
||||
(Armada uses a 40,960-byte rumor ceiling).
|
||||
|
||||
+12
@@ -143,6 +143,18 @@ class ConcordListResidue(
|
||||
return ConcordListResidue(extras, tombstones.filterNot { it === prior } + next, unparsedEntries)
|
||||
}
|
||||
|
||||
/** The latest `removed_at` (unix ms) per community this residue tombstones. */
|
||||
fun removals(): Map<String, Long> {
|
||||
val out = HashMap<String, Long>()
|
||||
for (t in tombstones) {
|
||||
val id = (t["community_id"] as? JsonPrimitive)?.contentOrNull ?: continue
|
||||
val at = (t["removed_at"] as? JsonPrimitive)?.longOrNull ?: continue
|
||||
val prev = out[id]
|
||||
if (prev == null || at > prev) out[id] = at
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/** The latest `removed_at` this residue holds for [communityId], or null when it was never left. */
|
||||
fun removedAt(communityId: String): Long? =
|
||||
tombstones
|
||||
|
||||
+6
-1
@@ -67,6 +67,9 @@ data class ConcordCommunityState(
|
||||
*/
|
||||
val inviteRegistries: Map<HexKey, List<HexKey>> = emptyMap(),
|
||||
) {
|
||||
/** The ids of the live (non-deleted) Private Channels (CORD-03), lowercase hex. */
|
||||
val privateChannelIds: Set<HexKey> by lazy { channels.filterValues { it.definition.private }.keys.mapTo(HashSet()) { it.lowercase() } }
|
||||
|
||||
/** The aggregate active-set of live public links: every honored registry's link signers (CORD-05 §5). */
|
||||
val liveInviteLinks: Set<HexKey> by lazy { inviteRegistries.values.flatMapTo(HashSet()) { it } }
|
||||
|
||||
@@ -114,9 +117,11 @@ data class ConcordCommunityState(
|
||||
/**
|
||||
* Whether retiring [linkSigners] would flip the community Private (CORD-05 §2): it is Public
|
||||
* now and no live link would remain. Retiring the last live link is a Refounding (CORD-06).
|
||||
* Never for a [dissolved] community: death wins every race (CORD-02 §9), so there is nothing
|
||||
* left to Refound and a revoke there is only a revoke.
|
||||
*/
|
||||
fun retiringWouldPrivatize(linkSigners: Collection<HexKey>): Boolean {
|
||||
if (!isPublic) return false
|
||||
if (dissolved || !isPublic) return false
|
||||
val retiring = linkSigners.mapTo(HashSet()) { it.lowercase() }
|
||||
return liveInviteLinks.all { it in retiring }
|
||||
}
|
||||
|
||||
+136
-11
@@ -20,31 +20,58 @@
|
||||
*/
|
||||
package com.vitorpamplona.quartz.concord.cord02Community
|
||||
|
||||
import com.vitorpamplona.quartz.concord.cord03Channels.tags.MsTag
|
||||
import com.vitorpamplona.quartz.concord.cord04Roles.AuthorityCitation
|
||||
import com.vitorpamplona.quartz.concord.cord04Roles.AuthorityResolver
|
||||
import com.vitorpamplona.quartz.concord.cord04Roles.ConcordPermissions
|
||||
import com.vitorpamplona.quartz.concord.cord04Roles.control.tags.VacTag
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.core.firstTagValue
|
||||
import com.vitorpamplona.quartz.nip01Core.core.isValid
|
||||
import com.vitorpamplona.quartz.nip59Giftwrap.rumors.RumorAssembler
|
||||
|
||||
/** A self-signed membership motion on the Guestbook Plane. */
|
||||
/**
|
||||
* A membership motion on the Guestbook Plane: the self-signed Join and Leave verbs of kind 3306,
|
||||
* and an authorized [KICK] (kind 3309), which is never a 3306 verb.
|
||||
*/
|
||||
enum class GuestbookAction(
|
||||
val wire: String,
|
||||
) {
|
||||
JOIN("join"),
|
||||
LEAVE("leave"),
|
||||
|
||||
/** An authorized Kick (kind 3309): the target is departed, and asked to leave (CORD-04 §6). */
|
||||
KICK("kick"),
|
||||
;
|
||||
|
||||
companion object {
|
||||
fun of(wire: String) = entries.firstOrNull { it.wire == wire }
|
||||
/** The kind-3306 verb [wire] names — only `join` and `leave` ride that kind. */
|
||||
fun of(wire: String) =
|
||||
when (wire) {
|
||||
JOIN.wire -> JOIN
|
||||
LEAVE.wire -> LEAVE
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** A parsed Guestbook join/leave: who ([member]), what ([action]), and invite attribution. */
|
||||
/**
|
||||
* A parsed Guestbook motion: whose state it sets ([member] — the target, for a Kick), what
|
||||
* ([action]), who signed it ([author] — the member themself for a Join/Leave, the kicker for a
|
||||
* Kick), and invite attribution (Joins only). [ms] is the CORD-02 §4 ordering basis
|
||||
* (`created_at * 1000 + ms`) and [rumorId] the equal-time tiebreak; [citation] is a Kick's `vac`.
|
||||
*/
|
||||
class GuestbookEntry(
|
||||
val member: HexKey,
|
||||
val action: GuestbookAction,
|
||||
val createdAt: Long,
|
||||
val inviteCreator: HexKey?,
|
||||
val inviteLabel: String?,
|
||||
val author: HexKey = member,
|
||||
val ms: Long = createdAt * 1000,
|
||||
val rumorId: HexKey = "",
|
||||
val citation: AuthorityCitation? = null,
|
||||
)
|
||||
|
||||
/**
|
||||
@@ -66,6 +93,9 @@ object Guestbook {
|
||||
const val TAG_INVITE = "invite"
|
||||
const val TAG_P = "p"
|
||||
|
||||
/** An entry dated further than this ahead of the receiver's clock is dropped outright (CORD-02 §5). */
|
||||
const val MAX_FUTURE_MS = 60 * 60 * 1000L
|
||||
|
||||
/** A self-signed join (kind 3306), optionally attributing the invite used. */
|
||||
fun join(
|
||||
memberPubKey: HexKey,
|
||||
@@ -99,31 +129,126 @@ object Guestbook {
|
||||
}
|
||||
|
||||
/**
|
||||
* An authorized Kick (kind 3309) directing [target] to leave. A Kick is only
|
||||
* honored by clients when its signer holds [com.vitorpamplona.quartz.concord
|
||||
* .cord04Roles.ConcordPermissions.KICK] and outranks the target — a Kick from
|
||||
* a non-KICK holder is dropped (CORD-04 §The Three Removals).
|
||||
* An authorized Kick (kind 3309) directing [target] to leave, citing the Grant the actor acts
|
||||
* under ([citation], the `vac` — null only for the owner, who cites nothing). Tags in the
|
||||
* examples' order: `ms`, `p`, `vac`. A Kick is only honored by clients when its signer holds
|
||||
* [ConcordPermissions.KICK] and strictly outranks the target ([canKick]) — a Kick from a
|
||||
* non-KICK holder is dropped (CORD-04 §6).
|
||||
*/
|
||||
fun kick(
|
||||
actorPubKey: HexKey,
|
||||
target: HexKey,
|
||||
createdAt: Long,
|
||||
): Event = RumorAssembler.assembleRumor(actorPubKey, createdAt, KIND_KICK, arrayOf(arrayOf(TAG_P, target)), "")
|
||||
subMs: Int = MsTag.remainderFor(createdAt),
|
||||
citation: AuthorityCitation? = null,
|
||||
): Event {
|
||||
val tags = ArrayList<Array<String>>(3)
|
||||
tags.add(MsTag.assemble(subMs))
|
||||
tags.add(arrayOf(TAG_P, target))
|
||||
if (citation != null) tags.add(VacTag.assemble(citation))
|
||||
return RumorAssembler.assembleRumor(actorPubKey, createdAt, KIND_KICK, tags.toTypedArray(), "")
|
||||
}
|
||||
|
||||
/** Parses a Guestbook join/leave rumor, or null if it is not one. */
|
||||
/**
|
||||
* Parses a Guestbook Join/Leave (kind 3306) or Kick (kind 3309), or null if it is neither or is
|
||||
* malformed: an unknown verb, a Kick naming no valid 64-hex target, or a malformed/duplicated
|
||||
* `ms` tag (dropped, never interpreted — CORD-02 §5).
|
||||
*/
|
||||
fun parse(rumor: Event): GuestbookEntry? {
|
||||
if (rumor.kind != KIND_JOIN_LEAVE) return null
|
||||
val ms = MsTag.orderingMs(rumor.createdAt, rumor.tags)
|
||||
when (rumor.kind) {
|
||||
KIND_JOIN_LEAVE -> {
|
||||
val action = GuestbookAction.of(rumor.content) ?: return null
|
||||
val invite = rumor.tags.firstOrNull { it.size >= 2 && it[0] == TAG_INVITE }
|
||||
if (ms == null) return null
|
||||
val invite = if (action == GuestbookAction.JOIN) rumor.tags.firstOrNull { it.size >= 2 && it[0] == TAG_INVITE } else null
|
||||
return GuestbookEntry(
|
||||
member = rumor.pubKey,
|
||||
action = action,
|
||||
createdAt = rumor.createdAt,
|
||||
inviteCreator = invite?.getOrNull(1),
|
||||
inviteLabel = invite?.getOrNull(2),
|
||||
author = rumor.pubKey,
|
||||
ms = ms,
|
||||
rumorId = rumor.id,
|
||||
)
|
||||
}
|
||||
KIND_KICK -> {
|
||||
val target = kickTarget(rumor)?.lowercase()?.takeIf { it.isValid() } ?: return null
|
||||
if (ms == null) return null
|
||||
return GuestbookEntry(
|
||||
member = target,
|
||||
action = GuestbookAction.KICK,
|
||||
createdAt = rumor.createdAt,
|
||||
inviteCreator = null,
|
||||
inviteLabel = null,
|
||||
author = rumor.pubKey,
|
||||
ms = ms,
|
||||
rumorId = rumor.id,
|
||||
citation = rumor.tags.firstOrNull { VacTag.isTag(it) }?.let { VacTag.parse(it) },
|
||||
)
|
||||
}
|
||||
else -> return null
|
||||
}
|
||||
}
|
||||
|
||||
/** The kick target's pubkey from a kind-3309 rumor, or null. */
|
||||
fun kickTarget(rumor: Event): HexKey? = if (rumor.kind == KIND_KICK) rumor.tags.firstTagValue(TAG_P) else null
|
||||
|
||||
/**
|
||||
* Whether the Kick [entry] is honored against the folded [authority] (CORD-04 §5/§6): its
|
||||
* author holds [ConcordPermissions.KICK], is not banned, and strictly outranks the target (the
|
||||
* owner is never a valid target), judged against the **current** roster — and its `vac` is
|
||||
* synced (block-until-synced: a citation we cannot yet match parks the Kick, here drops it
|
||||
* until a later fold).
|
||||
*/
|
||||
fun canKick(
|
||||
authority: AuthorityResolver,
|
||||
entry: GuestbookEntry,
|
||||
): Boolean =
|
||||
entry.action == GuestbookAction.KICK &&
|
||||
authority.canActOn(entry.author, entry.member, ConcordPermissions.KICK) &&
|
||||
authority.citationSatisfied(entry.author, entry.citation)
|
||||
|
||||
/** True when [next] supersedes [prev]: later on the ms basis, or the lower rumor id on a tie. */
|
||||
private fun supersedes(
|
||||
prev: GuestbookEntry?,
|
||||
next: GuestbookEntry,
|
||||
): Boolean = prev == null || next.ms > prev.ms || (next.ms == prev.ms && next.rumorId < prev.rumorId)
|
||||
|
||||
/**
|
||||
* Coalesces [entries] flat (CORD-02 §5): one final state per npub (lowercase), where their
|
||||
* latest Join, Leave or honored Kick wins — later ms, ties to the lower rumor id.
|
||||
* - an entry more than [MAX_FUTURE_MS] ahead of [nowMs] is dropped;
|
||||
* - a [banned] author's entries, Kicks included, are dropped (CORD-04 §4);
|
||||
* - a Kick counts only when [canKick] honors it (a Kick from a non-KICK holder is dropped).
|
||||
*/
|
||||
fun coalesce(
|
||||
entries: Collection<GuestbookEntry>,
|
||||
nowMs: Long,
|
||||
canKick: (GuestbookEntry) -> Boolean,
|
||||
banned: Set<HexKey> = emptySet(),
|
||||
): Map<HexKey, GuestbookEntry> {
|
||||
val out = HashMap<HexKey, GuestbookEntry>()
|
||||
for (entry in entries) {
|
||||
if (entry.ms > nowMs + MAX_FUTURE_MS) continue
|
||||
if (entry.author.lowercase() in banned) continue
|
||||
if (entry.action == GuestbookAction.KICK && !canKick(entry)) continue
|
||||
val key = entry.member.lowercase()
|
||||
if (supersedes(out[key], entry)) out[key] = entry
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/** [coalesce] with Kicks judged by [canKick] against [authority], and its banlist applied. */
|
||||
fun coalesce(
|
||||
entries: Collection<GuestbookEntry>,
|
||||
nowMs: Long,
|
||||
authority: AuthorityResolver?,
|
||||
): Map<HexKey, GuestbookEntry> =
|
||||
coalesce(
|
||||
entries,
|
||||
nowMs,
|
||||
canKick = { authority != null && canKick(authority, it) },
|
||||
banned = authority?.bannedMembers().orEmpty(),
|
||||
)
|
||||
}
|
||||
|
||||
+12
-2
@@ -435,11 +435,20 @@ object ChannelChat {
|
||||
/** True when [kind] may ride a Chat Plane ([CHAT_KINDS]). */
|
||||
fun isChatKind(kind: Int): Boolean = kind in CHAT_KINDS
|
||||
|
||||
/**
|
||||
* Every rumor kind a Chat Plane carries (CORD-02 Appendix B): the chat kinds plus the WebXDC
|
||||
* signal ([ConcordWebxdc], kind 3310), which rides the plane under the same binding but is never
|
||||
* a chat row — so it is kept out of [CHAT_KINDS], and only a caller that routes it apart (the
|
||||
* session's WebXDC buffer) opens a plane with this set.
|
||||
*/
|
||||
val PLANE_KINDS: Set<Int> = CHAT_KINDS + ConcordWebxdc.KIND
|
||||
|
||||
/**
|
||||
* The Chat Plane ingest gate for a wrap already opened under [channelId]'s key at [epoch]:
|
||||
* returns its rumor only when every Chat rule holds, else null (drop it).
|
||||
* - the seal is the encrypted kind 20013 (CORD-02 §5: a plaintext 20014 seal is Control-only);
|
||||
* - the rumor kind is a Chat kind ([CHAT_KINDS]), never another plane's;
|
||||
* - the rumor kind is one of [kinds] — by default the Chat kinds ([CHAT_KINDS]), never another
|
||||
* plane's; [PLANE_KINDS] also admits the WebXDC signal for a caller that routes it apart;
|
||||
* - the binding is strict: exactly one `channel` and one `epoch`, equal to the plane's
|
||||
* ([isBoundTo], CORD-03 §3);
|
||||
* - its `ms` tag, if any, is well formed (CORD-02 §4/§5 — a malformed one is dropped, never
|
||||
@@ -449,10 +458,11 @@ object ChannelChat {
|
||||
opened: OpenedStreamEvent,
|
||||
channelId: HexKey,
|
||||
epoch: Long,
|
||||
kinds: Set<Int> = CHAT_KINDS,
|
||||
): Event? {
|
||||
if (opened.sealKind != ConcordStreamEnvelope.KIND_SEAL_ENCRYPTED) return null
|
||||
val rumor = opened.rumor
|
||||
if (!isChatKind(rumor.kind)) return null
|
||||
if (rumor.kind !in kinds) return null
|
||||
if (!isBoundTo(rumor, channelId, epoch)) return null
|
||||
if (orderingMs(rumor) == null) return null
|
||||
return rumor
|
||||
|
||||
+239
@@ -0,0 +1,239 @@
|
||||
/*
|
||||
* 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.concord.cord03Channels
|
||||
|
||||
import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEntry
|
||||
import com.vitorpamplona.quartz.concord.cord02Community.ConcordEntryResidue
|
||||
import com.vitorpamplona.quartz.concord.cord02Community.PrivateChannelKey
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import kotlinx.serialization.json.JsonArray
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import kotlinx.serialization.json.contentOrNull
|
||||
import kotlinx.serialization.json.longOrNull
|
||||
|
||||
/** A Private Channel key held for an older channel epoch — it still reads its own era's history. */
|
||||
class HistoricalChannelKey(
|
||||
val key: HexKey,
|
||||
val epoch: Long,
|
||||
)
|
||||
|
||||
/**
|
||||
* The Private Channel keys a Community List entry holds, and how a rotation rewrites them
|
||||
* (CORD-03 §1-2, CORD-06 §2, CORD-02 §8).
|
||||
*
|
||||
* - **Current keys** are the entry's `channels` (`privateChannels`): exactly one per channel, the
|
||||
* newest epoch held. A rotation replaces it in place, keeping any unknown keys another client
|
||||
* wrote inside the channel object (the round-trip rule, CORD-02 §6/§8).
|
||||
* - **Older keys** are never written by this client: CORD-02 §8 keeps intermediate keys out of the
|
||||
* List ("a client's own optimisation … it does not belong in the List"). They are still *read*
|
||||
* wherever they already are — the entry's `seed` snapshot (the earliest epoch held, the backfill
|
||||
* anchor) and the reference client's `priors` extension inside a channel object — so history
|
||||
* written before a rotation stays readable.
|
||||
* - **Cuts** are the reference client's `channel_cuts` extension on the entry: per channel, the
|
||||
* channel epoch whose rotation cut this member out. A floor, never rolled back: a key below it is
|
||||
* refused, so a stale bundle or catch-up cannot quietly restore revoked access. Kept as the raw
|
||||
* extension (`[{ "id", "epoch" }]`) with every other field in each object preserved.
|
||||
*/
|
||||
object ConcordChannelKeyring {
|
||||
const val CHANNEL_CUTS = "channel_cuts"
|
||||
const val PRIORS = "priors"
|
||||
|
||||
private val HEX64 = Regex("^[0-9a-fA-F]{64}$")
|
||||
|
||||
/** The current key held for [channelIdHex], or null (a keyless listing is not a key). */
|
||||
fun heldKey(
|
||||
entry: ConcordCommunityListEntry,
|
||||
channelIdHex: HexKey,
|
||||
): PrivateChannelKey? = entry.privateChannels.firstOrNull { it.channelId.equals(channelIdHex, ignoreCase = true) && HEX64.matches(it.key) }
|
||||
|
||||
// ---- cuts ------------------------------------------------------------------
|
||||
|
||||
/** The `channel_cuts` floors on [entry], channel id (lowercase) → cut epoch (max wins). */
|
||||
fun cutsOf(entry: ConcordCommunityListEntry): Map<HexKey, Long> {
|
||||
val raw = entry.residue.entryExtras[CHANNEL_CUTS] as? JsonArray ?: return emptyMap()
|
||||
val out = HashMap<HexKey, Long>()
|
||||
for (element in raw) {
|
||||
val obj = element as? JsonObject ?: continue
|
||||
val id = (obj["id"] as? JsonPrimitive)?.contentOrNull?.lowercase() ?: continue
|
||||
val epoch = (obj["epoch"] as? JsonPrimitive)?.longOrNull ?: continue
|
||||
if (epoch > (out[id] ?: Long.MIN_VALUE)) out[id] = epoch
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/** True when a key for [channelIdHex] at [epoch] sits below a recorded cut and must be refused. */
|
||||
fun isCutOff(
|
||||
entry: ConcordCommunityListEntry,
|
||||
channelIdHex: HexKey,
|
||||
epoch: Long,
|
||||
): Boolean = cutsOf(entry)[channelIdHex.lowercase()]?.let { epoch < it } ?: false
|
||||
|
||||
/**
|
||||
* [entry] with a cut for [channelIdHex] at [epoch] merged into `channel_cuts` (max wins — a
|
||||
* removal never rolls back). Other channels' cut objects, and unknown keys inside the replaced
|
||||
* one, ride through untouched.
|
||||
*/
|
||||
fun withCut(
|
||||
entry: ConcordCommunityListEntry,
|
||||
channelIdHex: HexKey,
|
||||
epoch: Long,
|
||||
): ConcordCommunityListEntry {
|
||||
val id = channelIdHex.lowercase()
|
||||
val existing = (entry.residue.entryExtras[CHANNEL_CUTS] as? JsonArray)?.toList() ?: emptyList()
|
||||
if ((cutsOf(entry)[id] ?: Long.MIN_VALUE) >= epoch) return entry
|
||||
val mine = existing.filter { (it as? JsonObject)?.let { o -> (o["id"] as? JsonPrimitive)?.contentOrNull?.lowercase() == id } == true }
|
||||
val base = (mine.firstOrNull() as? JsonObject) ?: JsonObject(emptyMap())
|
||||
val next = JsonObject(base + mapOf("id" to JsonPrimitive(id), "epoch" to JsonPrimitive(epoch)))
|
||||
val cuts = JsonArray(existing.filterNot { it in mine } + next)
|
||||
val extras = JsonObject(entry.residue.entryExtras + (CHANNEL_CUTS to cuts))
|
||||
return entry.copyChannels(entry.privateChannels, ConcordEntryResidue(extras, entry.residue.seed, entry.residue.currentExtras))
|
||||
}
|
||||
|
||||
// ---- current keys ----------------------------------------------------------
|
||||
|
||||
/**
|
||||
* [entry] holding [key] as the current key for its channel — replacing a lower epoch, keeping
|
||||
* the replaced object's unknown fields — or null when it would not move anything forward: a key
|
||||
* at or below the held epoch, or one below a recorded cut.
|
||||
*/
|
||||
fun withChannelKey(
|
||||
entry: ConcordCommunityListEntry,
|
||||
key: PrivateChannelKey,
|
||||
): ConcordCommunityListEntry? {
|
||||
if (!HEX64.matches(key.key) || !HEX64.matches(key.channelId)) return null
|
||||
if (isCutOff(entry, key.channelId, key.epoch)) return null
|
||||
val held = entry.privateChannels.firstOrNull { it.channelId.equals(key.channelId, ignoreCase = true) }
|
||||
if (held != null && HEX64.matches(held.key) && held.epoch >= key.epoch) return null
|
||||
val next =
|
||||
PrivateChannelKey(
|
||||
channelId = key.channelId.lowercase(),
|
||||
key = key.key.lowercase(),
|
||||
epoch = key.epoch,
|
||||
name = key.name.ifBlank { held?.name ?: "" },
|
||||
extras = held?.extras ?: key.extras,
|
||||
)
|
||||
return entry.copyChannels(entry.privateChannels.filterNot { it.channelId.equals(key.channelId, ignoreCase = true) } + next, entry.residue)
|
||||
}
|
||||
|
||||
/** [entry] with [channelIdHex]'s key moved to [newKeyHex] at [newEpoch] (a rotation it launched or adopted). */
|
||||
fun withRotatedKey(
|
||||
entry: ConcordCommunityListEntry,
|
||||
channelIdHex: HexKey,
|
||||
newKeyHex: HexKey,
|
||||
newEpoch: Long,
|
||||
): ConcordCommunityListEntry? {
|
||||
val held = heldKey(entry, channelIdHex) ?: return null
|
||||
return withChannelKey(entry, PrivateChannelKey(held.channelId, newKeyHex, newEpoch, held.name, held.extras))
|
||||
}
|
||||
|
||||
/**
|
||||
* [entry] after a rotation to [cutEpoch] cut this member from [channelIdHex] (CORD-06 §2): the
|
||||
* key leaves `channels` and the cut is recorded, so no older key can come back.
|
||||
*/
|
||||
fun withoutChannel(
|
||||
entry: ConcordCommunityListEntry,
|
||||
channelIdHex: HexKey,
|
||||
cutEpoch: Long,
|
||||
): ConcordCommunityListEntry {
|
||||
val dropped = entry.copyChannels(entry.privateChannels.filterNot { it.channelId.equals(channelIdHex, ignoreCase = true) }, entry.residue)
|
||||
return withCut(dropped, channelIdHex, cutEpoch)
|
||||
}
|
||||
|
||||
// ---- older keys --------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Every older key this entry still carries for [channelIdHex] — the `seed` snapshot's and any
|
||||
* `priors` a peer wrote — excluding the current one, newest first. Each reads its own epoch's
|
||||
* history.
|
||||
*/
|
||||
fun historicalKeys(
|
||||
entry: ConcordCommunityListEntry,
|
||||
channelIdHex: HexKey,
|
||||
): List<HistoricalChannelKey> {
|
||||
val id = channelIdHex.lowercase()
|
||||
val current = heldKey(entry, id)
|
||||
val out = LinkedHashMap<Pair<Long, String>, HistoricalChannelKey>()
|
||||
|
||||
fun add(
|
||||
key: String?,
|
||||
epoch: Long?,
|
||||
) {
|
||||
if (key == null || epoch == null || !HEX64.matches(key)) return
|
||||
val k = key.lowercase()
|
||||
if (current != null && current.key.equals(k, ignoreCase = true) && current.epoch == epoch) return
|
||||
out.getOrPut(epoch to k) { HistoricalChannelKey(k, epoch) }
|
||||
}
|
||||
current?.extras?.get(PRIORS)?.let { priors ->
|
||||
for (p in (priors as? JsonArray).orEmpty()) {
|
||||
val obj = p as? JsonObject ?: continue
|
||||
add((obj["key"] as? JsonPrimitive)?.contentOrNull, (obj["epoch"] as? JsonPrimitive)?.longOrNull)
|
||||
}
|
||||
}
|
||||
val seedChannels = entry.residue.seed?.get("channels") as? JsonArray
|
||||
for (c in seedChannels.orEmpty()) {
|
||||
val obj = c as? JsonObject ?: continue
|
||||
if ((obj["id"] as? JsonPrimitive)?.contentOrNull?.lowercase() != id) continue
|
||||
add((obj["key"] as? JsonPrimitive)?.contentOrNull, (obj["epoch"] as? JsonPrimitive)?.longOrNull)
|
||||
}
|
||||
return out.values.sortedByDescending { it.epoch }
|
||||
}
|
||||
|
||||
/**
|
||||
* The channel epoch a privatisation must mint at (CORD-03 §2): one past the highest generation
|
||||
* this entry knows of — the held key, any older key, a recorded cut — and [observedFloor] (the
|
||||
* highest rotation epoch seen on the wire, for a privatiser who never held earlier
|
||||
* generations). Monotonic, so a stale key is always a lower epoch; 1 for a never-private channel.
|
||||
*/
|
||||
fun nextChannelEpoch(
|
||||
entry: ConcordCommunityListEntry,
|
||||
channelIdHex: HexKey,
|
||||
observedFloor: Long = 0,
|
||||
): Long {
|
||||
val id = channelIdHex.lowercase()
|
||||
var highest = observedFloor
|
||||
entry.privateChannels.filter { it.channelId.equals(id, ignoreCase = true) }.forEach { highest = maxOf(highest, it.epoch) }
|
||||
historicalKeys(entry, id).forEach { highest = maxOf(highest, it.epoch) }
|
||||
cutsOf(entry)[id]?.let { highest = maxOf(highest, it) }
|
||||
return highest + 1
|
||||
}
|
||||
|
||||
private fun ConcordCommunityListEntry.copyChannels(
|
||||
privateChannels: List<PrivateChannelKey>,
|
||||
residue: ConcordEntryResidue,
|
||||
) = ConcordCommunityListEntry(
|
||||
id = id,
|
||||
owner = owner,
|
||||
ownerSalt = ownerSalt,
|
||||
root = root,
|
||||
rootEpoch = rootEpoch,
|
||||
controlPk = controlPk,
|
||||
controlRoot = controlRoot,
|
||||
heldRoots = heldRoots,
|
||||
privateChannels = privateChannels,
|
||||
relays = relays,
|
||||
name = name,
|
||||
addedAt = addedAt,
|
||||
inviteRef = inviteRef,
|
||||
excludedAtEpoch = excludedAtEpoch,
|
||||
residue = residue,
|
||||
)
|
||||
}
|
||||
+163
@@ -0,0 +1,163 @@
|
||||
/*
|
||||
* 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.concord.cord03Channels
|
||||
|
||||
import com.vitorpamplona.quartz.concord.cord03Channels.tags.ChannelTag
|
||||
import com.vitorpamplona.quartz.concord.cord03Channels.tags.EpochTag
|
||||
import com.vitorpamplona.quartz.concord.cord03Channels.tags.MsTag
|
||||
import com.vitorpamplona.quartz.concord.cord04Roles.ConcordJson
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.core.firstTagValue
|
||||
import com.vitorpamplona.quartz.nip59Giftwrap.rumors.RumorAssembler
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import kotlinx.serialization.json.buildJsonObject
|
||||
import kotlinx.serialization.json.put
|
||||
|
||||
/**
|
||||
* A WebXDC realtime peer signal: [topic] is the app's gossip topic (52 base32 characters), and
|
||||
* [addr] the advertised, opaque node address — null for a departure (`"left"`).
|
||||
*/
|
||||
class WebxdcPeerSignal(
|
||||
val topic: String,
|
||||
val addr: String?,
|
||||
) {
|
||||
val isAdvert: Boolean get() = addr != null
|
||||
}
|
||||
|
||||
/**
|
||||
* WebXDC on the Chat Plane (kind 3310, CORD-02 Appendix B; examples §2.6). The CORDs register the
|
||||
* kind — a Chat rumor with the usual `channel`/`epoch`/`ms` binding — but do not pin its payload: the
|
||||
* content is app-level and opaque to the protocol. A 3310 is **never a chat message**: it is not a
|
||||
* feed row, not a preview, not unread; a client with no WebXDC host just holds it for one.
|
||||
*
|
||||
* What rides it in practice is the reference client's (Armada, interoperating with Vector), which
|
||||
* this object builds and parses so a future host has the plumbing:
|
||||
* - an app **state update** for one app session: `["i", <session>]` and `["alt", "Webxdc update"]`
|
||||
* (plus optional `info`, `document`, `summary`), the content being the JSON payload;
|
||||
* - a realtime **peer signal**, untagged beyond the binding: content
|
||||
* `{"op":"ad","topic":<52-char base32>,"addr":<node address>}` to advertise a gossip endpoint, or
|
||||
* `{"op":"left","topic":…}` to withdraw it.
|
||||
* Amethyst has no WebXDC runtime; see the conformance review (F10) for what full support would take.
|
||||
*/
|
||||
object ConcordWebxdc {
|
||||
const val KIND = 3310
|
||||
|
||||
const val TAG_SESSION = "i"
|
||||
const val TAG_ALT = "alt"
|
||||
const val ALT_UPDATE = "Webxdc update"
|
||||
const val TAG_INFO = "info"
|
||||
const val TAG_DOCUMENT = "document"
|
||||
const val TAG_SUMMARY = "summary"
|
||||
|
||||
/** A topic id is 32 bytes, so its RFC 4648 base32 form (no padding) is always this long. */
|
||||
const val TOPIC_ID_CHARS = 52
|
||||
|
||||
/** The longest advertised node address honored (Vector's cap); anything longer is not one. */
|
||||
const val MAX_NODE_ADDR_CHARS = 2048
|
||||
|
||||
private const val OP_AD = "ad"
|
||||
private const val OP_LEFT = "left"
|
||||
|
||||
/** True when [rumor] is a WebXDC signal. */
|
||||
fun isWebxdc(rumor: Event): Boolean = rumor.kind == KIND
|
||||
|
||||
/** An app state update for app session [session] on [channelId]/[epoch]; [payload] is the app's JSON. */
|
||||
fun stateUpdate(
|
||||
authorPubKey: HexKey,
|
||||
channelId: HexKey,
|
||||
epoch: Long,
|
||||
session: String,
|
||||
payload: String,
|
||||
createdAt: Long,
|
||||
info: String? = null,
|
||||
document: String? = null,
|
||||
summary: String? = null,
|
||||
ms: Int = MsTag.remainderFor(createdAt),
|
||||
): Event {
|
||||
val tags = binding(channelId, epoch, ms)
|
||||
tags.add(arrayOf(TAG_SESSION, session))
|
||||
tags.add(arrayOf(TAG_ALT, ALT_UPDATE))
|
||||
if (info != null) tags.add(arrayOf(TAG_INFO, info))
|
||||
if (document != null) tags.add(arrayOf(TAG_DOCUMENT, document))
|
||||
if (summary != null) tags.add(arrayOf(TAG_SUMMARY, summary))
|
||||
return RumorAssembler.assembleRumor(authorPubKey, createdAt, KIND, tags.toTypedArray(), payload)
|
||||
}
|
||||
|
||||
/**
|
||||
* A realtime peer signal on [channelId]/[epoch]: an advert of [nodeAddr] for [topic], or, when
|
||||
* [nodeAddr] is null, the departure from it.
|
||||
*/
|
||||
fun peerSignal(
|
||||
authorPubKey: HexKey,
|
||||
channelId: HexKey,
|
||||
epoch: Long,
|
||||
topic: String,
|
||||
nodeAddr: String?,
|
||||
createdAt: Long,
|
||||
ms: Int = MsTag.remainderFor(createdAt),
|
||||
): Event = RumorAssembler.assembleRumor(authorPubKey, createdAt, KIND, binding(channelId, epoch, ms).toTypedArray(), peerSignalContent(topic, nodeAddr))
|
||||
|
||||
/** The peer-signal body, key order `op`, `topic`, `addr` as the reference client writes it. */
|
||||
fun peerSignalContent(
|
||||
topic: String,
|
||||
nodeAddr: String?,
|
||||
): String =
|
||||
buildJsonObject {
|
||||
put("op", if (nodeAddr == null) OP_LEFT else OP_AD)
|
||||
put("topic", topic)
|
||||
if (nodeAddr != null) put("addr", nodeAddr)
|
||||
}.toString()
|
||||
|
||||
/** The app session a state update belongs to, or null (a peer signal carries none). */
|
||||
fun sessionOf(rumor: Event): String? = if (rumor.kind == KIND) rumor.tags.firstTagValue(TAG_SESSION) else null
|
||||
|
||||
/** Exactly [TOPIC_ID_CHARS] uppercase RFC 4648 base32 characters (Vector's receive-side check). */
|
||||
fun isTopicId(value: String?): Boolean = value != null && value.length == TOPIC_ID_CHARS && value.all { it in 'A'..'Z' || it in '2'..'7' }
|
||||
|
||||
/**
|
||||
* Parses an untrusted peer-signal body: null unless it is `{"op":"ad","topic","addr"}` with a
|
||||
* valid topic and a non-empty address of at most [MAX_NODE_ADDR_CHARS], or `{"op":"left","topic"}`.
|
||||
*/
|
||||
fun parsePeerSignal(content: String): WebxdcPeerSignal? {
|
||||
val obj = runCatching { ConcordJson.instance.parseToJsonElement(content) }.getOrNull() as? JsonObject ?: return null
|
||||
val topic = (obj["topic"] as? JsonPrimitive)?.takeIf { it.isString }?.content ?: return null
|
||||
if (!isTopicId(topic)) return null
|
||||
val op = (obj["op"] as? JsonPrimitive)?.takeIf { it.isString }?.content
|
||||
return when (op) {
|
||||
OP_LEFT -> WebxdcPeerSignal(topic, null)
|
||||
OP_AD -> {
|
||||
val addr = (obj["addr"] as? JsonPrimitive)?.takeIf { it.isString }?.content ?: return null
|
||||
if (addr.isEmpty() || addr.length > MAX_NODE_ADDR_CHARS) return null
|
||||
WebxdcPeerSignal(topic, addr)
|
||||
}
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
/** The binding every Chat rumor commits, in the examples' order: channel, epoch, ms. */
|
||||
private fun binding(
|
||||
channelId: HexKey,
|
||||
epoch: Long,
|
||||
ms: Int,
|
||||
): ArrayList<Array<String>> = arrayListOf(ChannelTag.assemble(channelId), EpochTag.assemble(epoch), MsTag.assemble(ms))
|
||||
}
|
||||
+24
-1
@@ -26,6 +26,7 @@ import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray
|
||||
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArrayOrNull
|
||||
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
|
||||
import com.vitorpamplona.quartz.utils.Log
|
||||
import com.vitorpamplona.quartz.utils.cache.ConcurrentLruCache
|
||||
|
||||
/**
|
||||
* Resolves the owner-rooted authority state of a Concord community from its
|
||||
@@ -84,6 +85,9 @@ data class AuthorityResolver private constructor(
|
||||
|
||||
fun isOwner(pubKey: String): Boolean = pubKey.lowercase() == ownerLower
|
||||
|
||||
/** The owner's pubkey (lowercase hex), proven by the `community_id` rather than any fold. */
|
||||
fun owner(): String = ownerLower
|
||||
|
||||
fun isBanned(pubKey: String): Boolean = pubKey.lowercase() in banned
|
||||
|
||||
/** The role ids a member currently holds (empty for the owner and for plain members). */
|
||||
@@ -277,6 +281,25 @@ data class AuthorityResolver private constructor(
|
||||
return g.takeIf { coordinate == edition.entityIdHex }
|
||||
}
|
||||
|
||||
/**
|
||||
* `invite_links_locator(community, author)` as hex, memoized: it is an HKDF per call, and the
|
||||
* well-formedness gate asks it for every registry edition on every refold of every community.
|
||||
* The derivation is a pure function of its inputs, so a cached value can never go stale.
|
||||
*/
|
||||
private val inviteLinksCoordinates = ConcurrentLruCache<String, String>(1024)
|
||||
|
||||
internal fun inviteLinksCoordinateHex(
|
||||
communityId: ByteArray,
|
||||
communityIdHex: String,
|
||||
author: String,
|
||||
): String {
|
||||
val key = communityIdHex + author.lowercase()
|
||||
inviteLinksCoordinates.get(key)?.let { return it }
|
||||
val coordinate = ConcordKeyDerivation.inviteLinksCoordinate(communityId, author.hexToByteArray()).toHexKey()
|
||||
inviteLinksCoordinates.put(key, coordinate)
|
||||
return coordinate
|
||||
}
|
||||
|
||||
/** See [isWellFormed]. */
|
||||
private fun wellFormed(
|
||||
edition: ControlEdition,
|
||||
@@ -294,7 +317,7 @@ data class AuthorityResolver private constructor(
|
||||
// CORD-05 §5: the coordinate binds to the author, so each creator owns exactly their own
|
||||
// list; the content must be a JSON array (a malformed one falls back to the previous head).
|
||||
ControlEntityKind.INVITE_REGISTRY ->
|
||||
edition.entityIdHex == ConcordKeyDerivation.inviteLinksCoordinate(communityId, edition.author.hexToByteArray()).toHexKey() &&
|
||||
edition.entityIdHex == inviteLinksCoordinateHex(communityId, communityIdHex, edition.author) &&
|
||||
ConcordInviteRegistry.isWellFormed(edition.content)
|
||||
else -> true
|
||||
}
|
||||
|
||||
+13
-5
@@ -106,10 +106,13 @@ object ConcordPins {
|
||||
val sealedUnavailable: Boolean,
|
||||
/** True when the content broke a cap or the format, so every reader treats it as empty. */
|
||||
val violating: Boolean,
|
||||
/** True when the content is the sealed form (`{"epoch","sealed"}`) — the same answer as [isSealedForm]. */
|
||||
val sealedForm: Boolean = false,
|
||||
) {
|
||||
companion object {
|
||||
val EMPTY = PinListRead(emptyList(), sealedUnavailable = false, violating = false)
|
||||
val VIOLATING = PinListRead(emptyList(), sealedUnavailable = false, violating = true)
|
||||
val VIOLATING_SEALED = PinListRead(emptyList(), sealedUnavailable = false, violating = true, sealedForm = true)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -137,18 +140,23 @@ object ConcordPins {
|
||||
val entries = root["entries"]
|
||||
if (entries != null) return entriesOf(entries)
|
||||
|
||||
// From here the content is the sealed form exactly when [isSealedForm] says so — reported on
|
||||
// the read so a caller need not parse the content a second time.
|
||||
val sealedForm = root["sealed"] != null
|
||||
val violating = if (sealedForm) PinListRead.VIOLATING_SEALED else PinListRead.VIOLATING
|
||||
val epoch = (root["epoch"] as? JsonPrimitive)?.takeIf { it.isString }?.content
|
||||
val sealed = (root["sealed"] as? JsonPrimitive)?.takeIf { it.isString }?.content
|
||||
if (epoch == null || sealed == null || !DECIMAL.matches(epoch)) return PinListRead.VIOLATING
|
||||
val epochValue = epoch.toLongOrNull() ?: return PinListRead.VIOLATING
|
||||
val key = unsealKey(epochValue) ?: return PinListRead(emptyList(), sealedUnavailable = true, violating = false)
|
||||
if (epoch == null || sealed == null || !DECIMAL.matches(epoch)) return violating
|
||||
val epochValue = epoch.toLongOrNull() ?: return violating
|
||||
val key = unsealKey(epochValue) ?: return PinListRead(emptyList(), sealedUnavailable = true, violating = false, sealedForm = true)
|
||||
val inner =
|
||||
try {
|
||||
parse(Nip44.v2.decrypt(sealed, key)) as? JsonObject
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
} ?: return PinListRead.VIOLATING
|
||||
return entriesOf(inner["entries"] ?: return PinListRead.VIOLATING)
|
||||
} ?: return violating
|
||||
val read = entriesOf(inner["entries"] ?: return violating)
|
||||
return PinListRead(read.entries, read.sealedUnavailable, read.violating, sealedForm = true)
|
||||
}
|
||||
|
||||
private fun entriesOf(element: JsonElement): PinListRead {
|
||||
|
||||
+85
-3
@@ -27,6 +27,7 @@ import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
|
||||
import com.vitorpamplona.quartz.nip01Core.crypto.verify
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.SignerExceptions
|
||||
import com.vitorpamplona.quartz.nip40Expiration.ExpirationTag
|
||||
import com.vitorpamplona.quartz.nip40Expiration.isExpirationBefore
|
||||
import com.vitorpamplona.quartz.nip59Giftwrap.rumors.Rumor
|
||||
@@ -35,6 +36,7 @@ import com.vitorpamplona.quartz.nip59Giftwrap.seals.SealEvent
|
||||
import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent
|
||||
import com.vitorpamplona.quartz.utils.RandomInstance
|
||||
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||
import kotlinx.coroutines.CancellationException
|
||||
|
||||
/**
|
||||
* A Direct Invite opened by its recipient (CORD-05 §6): the bundle plus the seal-verified [sender].
|
||||
@@ -118,6 +120,9 @@ object ConcordDirectInvite {
|
||||
)
|
||||
}
|
||||
|
||||
/** True when [wrap] is a giftwrap carrying the `["k","3313"]` Direct Invite index tag (a hint, not authority). */
|
||||
fun isInviteTagged(wrap: Event): Boolean = wrap.kind == GiftWrapEvent.KIND && wrap.tags.any { it.size >= 2 && it[0] == TAG_K && it[1] == KIND.toString() }
|
||||
|
||||
/**
|
||||
* True when [wrap]'s NIP-40 `expiration` (unix seconds) is at or before [nowSecs]: an expired
|
||||
* handoff is never decrypted or surfaced.
|
||||
@@ -144,15 +149,27 @@ object ConcordDirectInvite {
|
||||
suspend fun open(
|
||||
wrap: Event,
|
||||
recipientSigner: NostrSigner,
|
||||
): OpenedDirectInvite? = definitiveOrNull { openOrRetry(wrap, recipientSigner) }
|
||||
|
||||
/**
|
||||
* [open], except that a failure that says nothing about the wrap — the coroutine cancelled, or
|
||||
* the signer unable to answer right now (timed out, busy, not approved, not found) — is thrown
|
||||
* instead of reported as "not an invite", so an inbox can leave the wrap to be retried rather
|
||||
* than write it off for good. Null still means definitively not a valid invite for us.
|
||||
*/
|
||||
suspend fun openOrRetry(
|
||||
wrap: Event,
|
||||
recipientSigner: NostrSigner,
|
||||
): OpenedDirectInvite? {
|
||||
if (wrap.kind != GiftWrapEvent.KIND) return null
|
||||
val plaintext = decryptOrNull { recipientSigner.nip44Decrypt(wrap.content, wrap.pubKey) } ?: return null
|
||||
val seal =
|
||||
try {
|
||||
Event.fromJson(recipientSigner.nip44Decrypt(wrap.content, wrap.pubKey))
|
||||
Event.fromJson(plaintext)
|
||||
} catch (_: Exception) {
|
||||
return null
|
||||
}
|
||||
return openSeal(wrap.id, seal, recipientSigner)
|
||||
return openSealOrRetry(wrap.id, seal, recipientSigner)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -163,11 +180,41 @@ object ConcordDirectInvite {
|
||||
wrapId: HexKey,
|
||||
seal: Event,
|
||||
recipientSigner: NostrSigner,
|
||||
): OpenedDirectInvite? = definitiveOrNull { openSealOrRetry(wrapId, seal, recipientSigner) }
|
||||
|
||||
/** [openSeal] with [openOrRetry]'s transient-failure contract. */
|
||||
suspend fun openSealOrRetry(
|
||||
wrapId: HexKey,
|
||||
seal: Event,
|
||||
recipientSigner: NostrSigner,
|
||||
): OpenedDirectInvite? {
|
||||
if (seal !is SealEvent) return null
|
||||
if (!runCatching { seal.verify() }.getOrDefault(false)) return null
|
||||
val plaintext = decryptOrNull { recipientSigner.nip44Decrypt(seal.content, seal.pubKey) } ?: return null
|
||||
val rumor =
|
||||
try {
|
||||
Rumor.fromJson(plaintext)
|
||||
} catch (_: Exception) {
|
||||
return null
|
||||
}
|
||||
return openRumor(wrapId, seal, rumor)
|
||||
}
|
||||
|
||||
/**
|
||||
* [openSeal] for a pipeline that already decrypted [seal] into [rumor] — the rumor exactly as the
|
||||
* seal carries it, its claimed `pubkey` NOT yet overwritten by the seal's (see
|
||||
* [SealEvent.unsealRumorThrowing]) — so the invite costs no second decrypt. Validates only: the
|
||||
* seal's signature, the NIP-59 anti-spoofing author check, the rumor kind, the §1 bounds and the
|
||||
* owner proof. Never throws.
|
||||
*/
|
||||
fun openRumor(
|
||||
wrapId: HexKey,
|
||||
seal: Event,
|
||||
rumor: Rumor,
|
||||
): OpenedDirectInvite? {
|
||||
if (seal !is SealEvent) return null
|
||||
return try {
|
||||
if (!seal.verify()) return null
|
||||
val rumor = Rumor.fromJson(recipientSigner.nip44Decrypt(seal.content, seal.pubKey))
|
||||
// NIP-59 anti-spoofing: the rumor's claimed author must be the seal's signer. The generic
|
||||
// unseal path overwrites the rumor's pubkey with the seal's, which hides a mismatch; here
|
||||
// a mismatch is a forgery and the whole invite is refused.
|
||||
@@ -189,6 +236,41 @@ object ConcordDirectInvite {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* True for a signer failure that says the signer could not answer *now* — timed out, not
|
||||
* approved (yet), backgrounded without permission, not found — not that the payload is
|
||||
* undecryptable, so the same wrap may open on a later try. A signer that tried and failed
|
||||
* ([SignerExceptions.CouldNotPerformException], which is also how a local key reports a payload
|
||||
* that is not for it) is definitive.
|
||||
*/
|
||||
fun isTransientSignerFailure(e: Throwable): Boolean =
|
||||
e is SignerExceptions.TimedOutException ||
|
||||
e is SignerExceptions.ManuallyUnauthorizedException ||
|
||||
e is SignerExceptions.AutomaticallyUnauthorizedException ||
|
||||
e is SignerExceptions.RunningOnBackgroundWithoutAutomaticPermissionException ||
|
||||
e is SignerExceptions.SignerNotFoundException
|
||||
|
||||
/** [decrypt]'s plaintext, null when the payload is not for us, rethrowing a transient failure. */
|
||||
private suspend fun decryptOrNull(decrypt: suspend () -> String): String? =
|
||||
try {
|
||||
decrypt()
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
if (isTransientSignerFailure(e)) throw e
|
||||
null
|
||||
}
|
||||
|
||||
/** Runs [block], turning a transient failure into null for the callers that never retry. */
|
||||
private suspend fun definitiveOrNull(block: suspend () -> OpenedDirectInvite?): OpenedDirectInvite? =
|
||||
try {
|
||||
block()
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens a direct-invite giftwrap addressed to [recipientSigner] and returns the
|
||||
* [CommunityInvite], or null if it isn't a valid direct invite for this user. See [open], which
|
||||
|
||||
+13
-7
@@ -115,13 +115,18 @@ object ConcordInviteRegistry {
|
||||
/**
|
||||
* The link signers [creator]'s next registry edition lists (CORD-05 §5, "a Registry edit
|
||||
* accompanies every mint and every retire"): the registry they currently publish ([published],
|
||||
* their honored head), plus every link their Invite List [list] still holds for [communityIdHex],
|
||||
* plus [minted]; minus [retired], and minus every link the list records as tombstoned or past its
|
||||
* `expires_at` at [nowSecs] — an elapsed link can no longer be joined, so it must stop keeping the
|
||||
* community Public. A null [list] (unreadable) contributes nothing and prunes nothing.
|
||||
* their honored head) and [minted], minus [retired].
|
||||
*
|
||||
* The Invite List half heals a registry that fell behind: a link minted before any registry was
|
||||
* published (or by a device whose registry edit never landed) is re-listed on the next edit.
|
||||
* When the Invite List [list] is readable it is **authoritative**: the result is exactly the
|
||||
* links it still holds live for [communityIdHex] (not tombstoned, not past `expires_at` at
|
||||
* [nowSecs]) plus [minted]. A registry entry no live list entry backs is dropped — a retired
|
||||
* link's list entry is gone after the tombstone merge (so it can no longer be marked dead by its
|
||||
* token), and carrying the [published] entry forward would resurrect it and keep the community
|
||||
* Public forever. Every link is recorded in the list before its URL is handed out, so nothing
|
||||
* live is lost; and the list half heals a registry that fell behind (a link minted before any
|
||||
* registry was published is re-listed on the next edit).
|
||||
*
|
||||
* A null [list] (unreadable) keeps [published] as is: it contributes nothing and prunes nothing.
|
||||
*/
|
||||
fun nextLinks(
|
||||
published: Collection<HexKey>,
|
||||
@@ -133,8 +138,9 @@ object ConcordInviteRegistry {
|
||||
): List<HexKey> {
|
||||
val dead = retired.mapTo(HashSet()) { it.lowercase() }
|
||||
val live = LinkedHashSet<HexKey>()
|
||||
if (list == null) {
|
||||
published.forEach { live += it.lowercase() }
|
||||
if (list != null) {
|
||||
} else {
|
||||
val tombstoned = list.tombstones.mapTo(HashSet()) { it.token }
|
||||
for (entry in list.entries) {
|
||||
if (!entry.communityId.equals(communityIdHex, ignoreCase = true)) continue
|
||||
|
||||
+138
-12
@@ -20,9 +20,9 @@
|
||||
*/
|
||||
package com.vitorpamplona.quartz.concord.cord05Invites
|
||||
|
||||
import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityList.withPrivateChannels
|
||||
import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEntry
|
||||
import com.vitorpamplona.quartz.concord.cord02Community.PrivateChannelKey
|
||||
import com.vitorpamplona.quartz.concord.cord03Channels.ConcordChannelKeyring
|
||||
import com.vitorpamplona.quartz.concord.cord04Roles.AuthorityResolver
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
|
||||
@@ -80,6 +80,51 @@ object ConcordInviteVend {
|
||||
return held.filter { it.key.isNotBlank() && isEntitled(authority, memberHex, it.channelId) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Everyone entitled to [channelIdHex]'s key under [authority]: the owner plus every non-banned
|
||||
* holder of a Role scoped to the channel. Entitlement needs a Grant, so the roster enumerates it
|
||||
* exactly — no member census required. This is the keep-set of a channel rotation (CORD-06).
|
||||
*/
|
||||
fun entitledMembers(
|
||||
authority: AuthorityResolver,
|
||||
channelIdHex: HexKey,
|
||||
): Set<HexKey> {
|
||||
val scoped = channelRoleIds(authority, channelIdHex)
|
||||
val out = HashSet<HexKey>()
|
||||
out.add(authority.owner())
|
||||
for (member in authority.roleHolders()) {
|
||||
if (authority.isBanned(member)) continue
|
||||
if (authority.rolesOf(member).any { it in scoped }) out.add(member.lowercase())
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/** Who gained and who lost a Private Channel's entitlement between two folds. */
|
||||
class AccessChange(
|
||||
val channelIdHex: HexKey,
|
||||
val gained: Set<HexKey>,
|
||||
val lost: Set<HexKey>,
|
||||
)
|
||||
|
||||
/**
|
||||
* How a roster change — a Grant, a revoke, a Role's scope edit or deletion, a ban — moved
|
||||
* entitlement to each of [channelIds] (Armada `channelsHingingOn`, generalised to any edit):
|
||||
* the members to vend each channel's key to, and the ones a rotation must now cut. Channels
|
||||
* nobody gained or lost are omitted.
|
||||
*/
|
||||
fun accessChanges(
|
||||
before: AuthorityResolver,
|
||||
after: AuthorityResolver,
|
||||
channelIds: Collection<HexKey>,
|
||||
): List<AccessChange> =
|
||||
channelIds.mapNotNull { id ->
|
||||
val was = entitledMembers(before, id)
|
||||
val now = entitledMembers(after, id)
|
||||
val gained = now - was
|
||||
val lost = was - now
|
||||
if (gained.isEmpty() && lost.isEmpty()) null else AccessChange(id.lowercase(), gained, lost)
|
||||
}
|
||||
|
||||
/** The [held] keys as bundle channel grants (lowercase hex, as Armada writes them). */
|
||||
fun toInviteChannels(held: List<PrivateChannelKey>): List<InviteChannel> = held.map { InviteChannel(it.channelId.lowercase(), it.key.lowercase(), it.epoch, it.name) }
|
||||
|
||||
@@ -92,6 +137,12 @@ object ConcordInviteVend {
|
||||
* the member onto attacker-read streams. So it counts only on the SAME `community_root`,
|
||||
* `root_epoch` and `control_pk` (swapping `control_pk` alone would eclipse the member onto an
|
||||
* attacker's Control Plane); the base advances only by a CORD-06 rekey.
|
||||
*
|
||||
* And it only ever ADDS a channel this member holds no key for. A held key moves forward only
|
||||
* through a channel rekey (CORD-06 §2), whose `prevcommit` proves it extends the very key held;
|
||||
* a bare bundle proves nothing, so letting one replace a held key would let any keyholder
|
||||
* park a member on a dead key at an absurd epoch that every later honest delivery then loses
|
||||
* to. A key below a recorded cut (the rotation that removed us) never comes back either.
|
||||
*/
|
||||
fun catchUpChannelIds(
|
||||
held: ConcordCommunityListEntry?,
|
||||
@@ -102,34 +153,109 @@ object ConcordInviteVend {
|
||||
if (!bundle.communityRoot.equals(held.root, ignoreCase = true)) return emptyList()
|
||||
if (bundle.rootEpoch != held.rootEpoch) return emptyList()
|
||||
if (!sameOptionalHex(bundle.controlPk, held.controlPk)) return emptyList()
|
||||
val heldEpochs = held.privateChannels.filter { it.key.isNotBlank() }.associate { it.channelId.lowercase() to it.epoch }
|
||||
val heldIds = held.privateChannels.filter { HEX64.matches(it.key) }.mapTo(HashSet()) { it.channelId.lowercase() }
|
||||
return bundle.channels
|
||||
.filter { HEX64.matches(it.id) && HEX64.matches(it.key) }
|
||||
.filter { c ->
|
||||
val heldEpoch = heldEpochs[c.id.lowercase()]
|
||||
heldEpoch == null || c.epoch > heldEpoch
|
||||
}.map { it.id.lowercase() }
|
||||
.filter { it.id.lowercase() !in heldIds }
|
||||
.filterNot { ConcordChannelKeyring.isCutOff(held, it.id, it.epoch) }
|
||||
.map { it.id.lowercase() }
|
||||
.distinct()
|
||||
}
|
||||
|
||||
/**
|
||||
* [held] with the Private Channel keys [bundle] newly contributes ([catchUpChannelIds]) merged
|
||||
* in — a newer epoch replaces the held one — or null when the bundle contributes nothing. The
|
||||
* base, epoch, control keys and every other field stay exactly as held.
|
||||
* The subset of [catchUpChannelIds] a catch-up may actually deliver against the held fold: only
|
||||
* from a [sender] who is staff there (the owner or a Control-writing permission holder,
|
||||
* CORD-04 §3 — a plain keyholder could otherwise plant a wrong key that blocks the right one),
|
||||
* and only for channels the fold knows as live Private Channels ([privateChannelIds]). Empty
|
||||
* when either fails.
|
||||
*/
|
||||
fun admissibleCatchUpIds(
|
||||
held: ConcordCommunityListEntry?,
|
||||
bundle: CommunityInvite,
|
||||
authority: AuthorityResolver,
|
||||
privateChannelIds: Set<HexKey>,
|
||||
sender: HexKey,
|
||||
): List<HexKey> {
|
||||
if (!authority.isStaff(sender)) return emptyList()
|
||||
val live = privateChannelIds.mapTo(HashSet()) { it.lowercase() }
|
||||
return catchUpChannelIds(held, bundle).filter { it in live }
|
||||
}
|
||||
|
||||
/**
|
||||
* [held] with the Private Channel keys [bundle] newly contributes ([catchUpChannelIds], narrowed
|
||||
* to [only] when given) added, or null when the bundle contributes nothing. A held key is never
|
||||
* replaced; the base, epoch, control keys and every other field stay exactly as held.
|
||||
*/
|
||||
fun adoptCatchUp(
|
||||
held: ConcordCommunityListEntry,
|
||||
bundle: CommunityInvite,
|
||||
only: Collection<HexKey>? = null,
|
||||
): ConcordCommunityListEntry? {
|
||||
val newIds = catchUpChannelIds(held, bundle).toSet()
|
||||
val newIds = catchUpChannelIds(held, bundle).filter { only == null || it in only }.toSet()
|
||||
if (newIds.isEmpty()) return null
|
||||
val delivered =
|
||||
bundle.channels
|
||||
.filter { it.id.lowercase() in newIds && HEX64.matches(it.key) }
|
||||
.groupBy { it.id.lowercase() }
|
||||
.map { (id, grants) -> grants.maxBy { it.epoch }.let { PrivateChannelKey(id, it.key.lowercase(), it.epoch, it.name) } }
|
||||
val kept = held.privateChannels.filterNot { it.channelId.lowercase() in newIds }
|
||||
return held.withPrivateChannels(kept + delivered)
|
||||
// Through the keyring: a replaced key keeps the unknown fields another client wrote in it.
|
||||
var next = held
|
||||
for (key in delivered) next = ConcordChannelKeyring.withChannelKey(next, key) ?: next
|
||||
return if (next === held) null else next
|
||||
}
|
||||
|
||||
/** Why a catch-up Direct Invite may or may not be adopted without a click ([judgeCatchUp]). */
|
||||
enum class CatchUpVerdict {
|
||||
/** The Grant was the consent: adopt now. */
|
||||
ADOPT,
|
||||
|
||||
/** No folded roster yet (the Grant's fold lags): wait. */
|
||||
NO_FOLD,
|
||||
|
||||
/** Not a catch-up at all ([catchUpChannelIds] is empty). */
|
||||
NOTHING_NEW,
|
||||
|
||||
/** The recipient is banned (CORD-04 §4). */
|
||||
BANNED,
|
||||
|
||||
/** A plain keyholder sent it; only staff may plant a key automatically. */
|
||||
SENDER_NOT_STAFF,
|
||||
|
||||
/** It carries a channel the recipient's Roles don't entitle them to. */
|
||||
NOT_ENTITLED,
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a parked catch-up invite — a Direct Invite carrying a Private Channel key an existing
|
||||
* member lacks, which is how a role grant's key arrives (CORD-05 §6) — may be adopted WITHOUT a
|
||||
* click (Armada `judgeCatchUp`). Consent came from the Grant; this checks the bundle is the
|
||||
* delivery it prescribes, against the folded [authority]:
|
||||
* - the [sender] is the owner or staff (CORD-04 §3), so a plain keyholder can't plant a wrong
|
||||
* key that would block the right one;
|
||||
* - the [recipient] isn't banned;
|
||||
* - EVERY newly contributed channel is one the recipient's Roles entitle them to ([isEntitled]).
|
||||
*
|
||||
* - every such channel is a live Private Channel in the fold ([privateChannelIds]).
|
||||
*
|
||||
* A manual Accept still needs a staff sender and a live Private Channel
|
||||
* ([admissibleCatchUpIds]); only the entitlement check is waived by the click.
|
||||
*/
|
||||
fun judgeCatchUp(
|
||||
authority: AuthorityResolver?,
|
||||
privateChannelIds: Set<HexKey>,
|
||||
recipient: HexKey,
|
||||
sender: HexKey,
|
||||
bundle: CommunityInvite,
|
||||
held: ConcordCommunityListEntry?,
|
||||
): CatchUpVerdict {
|
||||
val vended = catchUpChannelIds(held, bundle)
|
||||
if (vended.isEmpty()) return CatchUpVerdict.NOTHING_NEW
|
||||
if (authority == null) return CatchUpVerdict.NO_FOLD
|
||||
if (authority.isBanned(recipient)) return CatchUpVerdict.BANNED
|
||||
if (!authority.isStaff(sender)) return CatchUpVerdict.SENDER_NOT_STAFF
|
||||
val live = privateChannelIds.mapTo(HashSet()) { it.lowercase() }
|
||||
if (vended.any { it !in live || !isEntitled(authority, recipient, it) }) return CatchUpVerdict.NOT_ENTITLED
|
||||
return CatchUpVerdict.ADOPT
|
||||
}
|
||||
|
||||
private val HEX64 = Regex("^[0-9a-fA-F]{64}$")
|
||||
|
||||
+328
@@ -0,0 +1,328 @@
|
||||
/*
|
||||
* 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.concord.cord06Rekey
|
||||
|
||||
import com.vitorpamplona.quartz.concord.cord04Roles.AuthorityCitation
|
||||
import com.vitorpamplona.quartz.concord.cord04Roles.control.tags.VacTag
|
||||
import com.vitorpamplona.quartz.concord.crypto.ConcordKeyDerivation
|
||||
import com.vitorpamplona.quartz.concord.crypto.GroupKey
|
||||
import com.vitorpamplona.quartz.concord.envelope.ConcordStreamEnvelope
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.core.firstTagValue
|
||||
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray
|
||||
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
|
||||
import com.vitorpamplona.quartz.nip59Giftwrap.rumors.RumorAssembler
|
||||
import com.vitorpamplona.quartz.utils.RandomInstance
|
||||
|
||||
/**
|
||||
* One Private Channel's rotation as a receiver sees it (CORD-06 §2): the kind-3303 chunks one
|
||||
* [rotator] published at one ([newEpoch], [prevCommit]) — two Rotators concurrently rekeying the
|
||||
* same epoch never merge into one set. [complete] once every chunk `1..total` is held; a missing
|
||||
* chunk is never a removal. [createdAt] is the newest chunk's `created_at` (seconds), which a
|
||||
* receiver compares against its join time: a rotation predating the join is not an exclusion.
|
||||
*/
|
||||
class ChannelRotation(
|
||||
val rotator: HexKey,
|
||||
val channelIdHex: HexKey,
|
||||
val newEpoch: Long,
|
||||
val prevEpoch: Long,
|
||||
val prevCommit: HexKey,
|
||||
val total: Int,
|
||||
val chunks: Map<Int, List<RekeyBlob>>,
|
||||
val authority: AuthorityCitation?,
|
||||
val createdAt: Long,
|
||||
) {
|
||||
val complete: Boolean get() = (1..total).all { it in chunks }
|
||||
|
||||
/** Every blob across the held chunks. */
|
||||
fun blobs(): List<RekeyBlob> = chunks.values.flatten()
|
||||
}
|
||||
|
||||
/** A key the receiver stepped off while walking a channel's rotations: it still reads its own era. */
|
||||
class SteppedChannelKey(
|
||||
val key: ByteArray,
|
||||
val epoch: Long,
|
||||
/** When the rotation that superseded it was published (seconds). */
|
||||
val retiredAt: Long,
|
||||
)
|
||||
|
||||
/** What a Private Channel's pending rotations mean for the key this account holds (CORD-06 §2). */
|
||||
sealed class ChannelRekeyOutcome {
|
||||
/** Nothing to act on (no honored complete rotation past the held epoch, or one we cannot yet verify). */
|
||||
object None : ChannelRekeyOutcome()
|
||||
|
||||
/** Adopt [key] at [epoch]; [steppedOver] are the keys the walk left behind, newest first. */
|
||||
class Adopted(
|
||||
val key: ByteArray,
|
||||
val epoch: Long,
|
||||
val steppedOver: List<SteppedChannelKey>,
|
||||
) : ChannelRekeyOutcome()
|
||||
|
||||
/**
|
||||
* A complete, honored rotation to [epoch] that could have carried our blob did not: we were cut
|
||||
* from the channel. Drop the key and record the cut, so a stale bundle cannot restore it.
|
||||
*/
|
||||
class Removed(
|
||||
val epoch: Long,
|
||||
) : ChannelRekeyOutcome()
|
||||
}
|
||||
|
||||
/**
|
||||
* Single-channel Rekeys (CORD-06 §1-2): rotate one Private Channel's independent key to exactly the
|
||||
* members who should keep reading it, and follow such a rotation as a member.
|
||||
*
|
||||
* - **Address.** A channel rotation to `new_epoch` rides `group_key("concord/rekey-pseudonym",
|
||||
* community_root, channel_id, new_epoch)` — keyed by the *community* root, not the channel key
|
||||
* (CORD-02 derivation table), so every member can precompute it. A Refounding seals its channel
|
||||
* rekeys under the **prior** root (CORD-06 §3), so a receiver watches under the root it holds and
|
||||
* the one before it.
|
||||
* - **Blob.** 72 bytes, `scope_id[32] ‖ epoch_be[8] ‖ new_key[32]`, with the channel id as the
|
||||
* scope ([RekeyPayload]); scope and epoch are verified against the tags before adoption.
|
||||
* - **Continuity.** `prevcommit` is the epoch-key commitment over the channel key being replaced at
|
||||
* its epoch; a receiver adopts only a rotation off the exact key it holds, walking several
|
||||
* rotations in one pass when it missed some.
|
||||
* - **Authority.** A single-channel Rekey needs `MANAGE_CHANNELS`, a Refounding's needs `BAN`, and
|
||||
* the Rotator must strictly outrank every removed target — so a receiver treats "no blob for me"
|
||||
* as a removal only from a Rotator that outranks it (Armada `useChannelRekeyWatch`).
|
||||
*
|
||||
* Pinned byte-for-byte to the reference client's `lib/rekey.ts` (`encodeWrappedKey`,
|
||||
* `buildRekeyRumors`, `channelRekeyGroupKey`) and walk (`useChannelRekeyWatch`).
|
||||
*/
|
||||
object ConcordChannelRekey {
|
||||
/**
|
||||
* How many channel epochs past the held one a member watches. Watching only `held + 1` strands
|
||||
* anyone who missed a rotation — they'd never learn they were removed. The reference client's
|
||||
* `CHANNEL_REKEY_LOOKAHEAD`.
|
||||
*/
|
||||
const val LOOKAHEAD = 8
|
||||
|
||||
/** The rekey address a rotation of [channelId] to [newEpoch] rides, sealed under [sealingRoot]. */
|
||||
fun address(
|
||||
sealingRoot: ByteArray,
|
||||
channelId: ByteArray,
|
||||
newEpoch: Long,
|
||||
): GroupKey = ConcordKeyDerivation.channelRekeyAddress(sealingRoot, channelId, newEpoch)
|
||||
|
||||
/** The `prevcommit` a rotation off [heldKey] at [heldEpoch] carries (CORD-02 A.5). */
|
||||
fun prevCommit(
|
||||
heldEpoch: Long,
|
||||
heldKey: ByteArray,
|
||||
): HexKey = ConcordKeyDerivation.epochKeyCommitment(heldEpoch, heldKey).toHexKey()
|
||||
|
||||
/** A fresh 32-byte channel key. */
|
||||
fun mintKey(): ByteArray = RandomInstance.bytes(32)
|
||||
|
||||
/**
|
||||
* The kind-1059 wraps of one channel rotation: the chunked kind-3303 rumors delivering
|
||||
* [newKey] at `heldEpoch + 1` to [recipients] (the rotator should include itself, or nobody
|
||||
* could rotate the channel next time), each chunk carrying [authority] (`vac`), sealed
|
||||
* (encrypted, rotator-signed) at [address] under [sealingRoot].
|
||||
*/
|
||||
suspend fun build(
|
||||
rotatorSigner: NostrSigner,
|
||||
sealingRoot: ByteArray,
|
||||
channelId: ByteArray,
|
||||
heldKey: ByteArray,
|
||||
heldEpoch: Long,
|
||||
newKey: ByteArray,
|
||||
recipients: Collection<HexKey>,
|
||||
createdAt: Long,
|
||||
authority: AuthorityCitation? = null,
|
||||
): List<Event> {
|
||||
val newEpoch = heldEpoch + 1
|
||||
val prevCommit = prevCommit(heldEpoch, heldKey)
|
||||
val blobs =
|
||||
recipients.map { it.lowercase() }.distinct().map { recipient ->
|
||||
ConcordRekey.blobForSigner(rotatorSigner, recipient.hexToByteArray(), channelId, newEpoch, newKey)
|
||||
}
|
||||
val widest = blobs.size.coerceAtLeast(1)
|
||||
val envelopeTags = ConcordRekey.tags(channelId, newEpoch, heldEpoch, prevCommit, widest, widest, authority)
|
||||
val envelope =
|
||||
RumorAssembler
|
||||
.assembleRumor<Event>(rotatorSigner.pubKey, createdAt, ConcordRekey.KIND, envelopeTags, "")
|
||||
.toJson()
|
||||
.encodeToByteArray()
|
||||
.size
|
||||
val chunks = ConcordRekey.chunkBlobs(blobs, envelope)
|
||||
val stream = address(sealingRoot, channelId, newEpoch)
|
||||
return chunks.mapIndexed { index, chunk ->
|
||||
val tags = ConcordRekey.tags(channelId, newEpoch, heldEpoch, prevCommit, index + 1, chunks.size, authority)
|
||||
val rumor = RumorAssembler.assembleRumor<Event>(rotatorSigner.pubKey, createdAt, ConcordRekey.KIND, tags, ConcordRekey.encodeContent(chunk))
|
||||
ConcordStreamEnvelope.wrap(rumor, stream, rotatorSigner, encrypted = true, createdAt = createdAt)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens the kind-1059 [wraps] seen at channel-rekey addresses ([keys], address hex → key) and
|
||||
* groups the well-formed chunks for [channelIdHex] into [ChannelRotation]s, keyed by
|
||||
* (rotator, newepoch, prevcommit). Drops: a wrap at no known address, a plaintext seal (a rekey
|
||||
* seal is encrypted, CORD-02 §5), a non-3303 rumor, a scope other than the channel, a
|
||||
* non-decimal or 0-based chunk, a malformed `vac`. A rotation whose chunks disagree on
|
||||
* `prevepoch`, `total` or citation is distrusted whole.
|
||||
*/
|
||||
fun rotations(
|
||||
wraps: Collection<Event>,
|
||||
keys: Map<HexKey, GroupKey>,
|
||||
channelIdHex: HexKey,
|
||||
): List<ChannelRotation> {
|
||||
val scope = channelIdHex.lowercase()
|
||||
val groups = LinkedHashMap<String, MutableList<Parsed>>()
|
||||
for (wrap in wraps.distinctBy { it.id }) {
|
||||
val key = keys[wrap.pubKey] ?: continue
|
||||
val opened = ConcordStreamEnvelope.openOrNull(wrap, key) ?: continue
|
||||
if (opened.sealKind != ConcordStreamEnvelope.KIND_SEAL_ENCRYPTED) continue
|
||||
val rumor = opened.rumor
|
||||
if (rumor.kind != ConcordRekey.KIND) continue
|
||||
if (rumor.tags.firstTagValue(ConcordRekey.TAG_SCOPE)?.lowercase() != scope) continue
|
||||
val newEpoch = strictLong(rumor.tags.firstTagValue(ConcordRekey.TAG_NEWEPOCH)) ?: continue
|
||||
val prevEpoch = strictLong(rumor.tags.firstTagValue(ConcordRekey.TAG_PREVEPOCH)) ?: continue
|
||||
val prevCommit = rumor.tags.firstTagValue(ConcordRekey.TAG_PREVCOMMIT)?.lowercase() ?: continue
|
||||
if (!HEX64.matches(prevCommit)) continue
|
||||
val (index, total) = ConcordRekey.chunkOf(rumor.tags) ?: continue
|
||||
val vacTag = rumor.tags.firstOrNull { it.isNotEmpty() && it[0] == VacTag.TAG_NAME }
|
||||
val citation = if (vacTag == null) null else VacTag.parse(vacTag) ?: continue
|
||||
val rotator = opened.author.lowercase()
|
||||
groups
|
||||
.getOrPut("$rotator:$newEpoch:$prevCommit") { ArrayList() }
|
||||
.add(Parsed(rotator, newEpoch, prevEpoch, prevCommit, index, total, ConcordRekey.decodeContent(rumor.content), citation, rumor.createdAt))
|
||||
}
|
||||
return groups.values.mapNotNull { parsed ->
|
||||
val first = parsed.first()
|
||||
if (parsed.any { it.prevEpoch != first.prevEpoch || it.total != first.total }) return@mapNotNull null
|
||||
val citations = parsed.map { p -> p.citation?.let { VacTag.assemble(it).joinToString(",") } }.distinct()
|
||||
if (citations.size > 1) return@mapNotNull null
|
||||
ChannelRotation(
|
||||
rotator = first.rotator,
|
||||
channelIdHex = scope,
|
||||
newEpoch = first.newEpoch,
|
||||
prevEpoch = first.prevEpoch,
|
||||
prevCommit = first.prevCommit,
|
||||
total = first.total,
|
||||
chunks = parsed.groupBy { it.index }.mapValues { (_, same) -> same.first().blobs },
|
||||
authority = first.citation,
|
||||
createdAt = parsed.maxOf { it.createdAt },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Walks [rotations] of the channel whose key this account holds ([heldKey] at [heldEpoch]) and
|
||||
* decides what to do (Armada `useChannelRekeyWatch`):
|
||||
*
|
||||
* - only **complete** rotations past [heldEpoch] from an [honored] Rotator count;
|
||||
* - epoch by epoch, ascending: a rotation carrying our blob **and** continuing the key we hold
|
||||
* at that point (`prevepoch`/`prevcommit`) hands us the next key — racing Rotators at one
|
||||
* epoch converge on the lexicographically lowest key — and the walk moves on from it;
|
||||
* - a rotation that carries our blob off a key we can't verify is neither adoption nor removal
|
||||
* (a gap to fetch);
|
||||
* - a rotation with no blob for us, published at or after [joinedAtSecs] by a Rotator who
|
||||
* [outranksMe], is the read-cut (removal needs no chain: hiding is local and safe);
|
||||
* - a key adopted above the newest exclusion is a re-admission; otherwise the exclusion wins.
|
||||
*
|
||||
* The blob opens through [recipientSigner] (one NIP-44 decrypt: bunker-friendly).
|
||||
*/
|
||||
suspend fun walk(
|
||||
rotations: List<ChannelRotation>,
|
||||
channelIdHex: HexKey,
|
||||
heldKey: ByteArray,
|
||||
heldEpoch: Long,
|
||||
recipientSigner: NostrSigner,
|
||||
joinedAtSecs: Long,
|
||||
honored: (ChannelRotation) -> Boolean,
|
||||
outranksMe: (HexKey) -> Boolean,
|
||||
): ChannelRekeyOutcome {
|
||||
val channelId = channelIdHex.hexToByteArray()
|
||||
val byEpoch =
|
||||
rotations
|
||||
.filter { it.channelIdHex.equals(channelIdHex, ignoreCase = true) && it.newEpoch > heldEpoch && it.complete && honored(it) }
|
||||
.groupBy { it.newEpoch }
|
||||
.entries
|
||||
.sortedBy { it.key }
|
||||
if (byEpoch.isEmpty()) return ChannelRekeyOutcome.None
|
||||
|
||||
var chainEpoch = heldEpoch
|
||||
var chainKey = heldKey
|
||||
var adoptedEpoch: Long? = null
|
||||
var excludedAt: Long? = null
|
||||
val stepped = ArrayList<SteppedChannelKey>()
|
||||
|
||||
for ((epoch, candidates) in byEpoch) {
|
||||
var keyHere: ByteArray? = null
|
||||
var publishedHere: Long? = null
|
||||
var addressedHere = false
|
||||
for (set in candidates) {
|
||||
val locator =
|
||||
ConcordKeyDerivation
|
||||
.recipientLocator(set.rotator.hexToByteArray(), recipientSigner.pubKey.hexToByteArray(), channelId, epoch)
|
||||
.toHexKey()
|
||||
if (set.blobs().none { it.locator == locator }) continue
|
||||
addressedHere = true
|
||||
// Only a rotation off the key we hold at this point can hand us the next one.
|
||||
if (set.prevEpoch != chainEpoch || set.prevCommit != prevCommit(chainEpoch, chainKey)) continue
|
||||
val payload =
|
||||
ConcordRekey.findPayloadWithSigner(set.blobs(), recipientSigner, set.rotator.hexToByteArray(), channelId, epoch)
|
||||
// A channel blob is exactly 72 bytes; a wider (base-form) payload is malformed here.
|
||||
if (payload == null || payload.newControlPk != null) continue
|
||||
keyHere = keyHere?.let { if (ConcordRefounding.compareKeys(payload.newKey, it) < 0) payload.newKey else it } ?: payload.newKey
|
||||
publishedHere = publishedHere?.let { minOf(it, set.createdAt) } ?: set.createdAt
|
||||
}
|
||||
val next = keyHere
|
||||
if (next != null) {
|
||||
stepped.add(0, SteppedChannelKey(chainKey, chainEpoch, publishedHere ?: 0))
|
||||
chainEpoch = epoch
|
||||
chainKey = next
|
||||
adoptedEpoch = epoch
|
||||
continue
|
||||
}
|
||||
if (addressedHere) continue
|
||||
if (candidates.any { it.createdAt >= joinedAtSecs && outranksMe(it.rotator) }) excludedAt = epoch
|
||||
}
|
||||
|
||||
val adopted = adoptedEpoch
|
||||
if (adopted != null && (excludedAt == null || adopted > excludedAt)) {
|
||||
return ChannelRekeyOutcome.Adopted(chainKey, adopted, stepped)
|
||||
}
|
||||
return excludedAt?.let { ChannelRekeyOutcome.Removed(it) } ?: ChannelRekeyOutcome.None
|
||||
}
|
||||
|
||||
private class Parsed(
|
||||
val rotator: HexKey,
|
||||
val newEpoch: Long,
|
||||
val prevEpoch: Long,
|
||||
val prevCommit: HexKey,
|
||||
val index: Int,
|
||||
val total: Int,
|
||||
val blobs: List<RekeyBlob>,
|
||||
val citation: AuthorityCitation?,
|
||||
val createdAt: Long,
|
||||
)
|
||||
|
||||
private val HEX64 = Regex("^[0-9a-f]{64}$")
|
||||
|
||||
/** Strict decimal (`0|[1-9][0-9]*`), as the reference client's `isTagDecimal`. */
|
||||
private fun strictLong(value: String?): Long? {
|
||||
if (value.isNullOrEmpty() || value.length > 18 || !value.all { it in '0'..'9' }) return null
|
||||
if (value.length > 1 && value[0] == '0') return null
|
||||
return value.toLong()
|
||||
}
|
||||
}
|
||||
+19
-1
@@ -22,8 +22,10 @@ package com.vitorpamplona.quartz.concord.cord07Voice
|
||||
|
||||
import com.vitorpamplona.quartz.concord.crypto.GroupKey
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync
|
||||
import com.vitorpamplona.quartz.utils.RandomInstance
|
||||
import kotlin.io.encoding.Base64
|
||||
import kotlin.io.encoding.ExperimentalEncodingApi
|
||||
|
||||
@@ -42,6 +44,10 @@ object ConcordBrokerToken {
|
||||
const val AUTH_SCHEME = "Concord"
|
||||
const val TAG_URL = "u"
|
||||
const val TAG_METHOD = "method"
|
||||
const val TAG_NONCE = "nonce"
|
||||
|
||||
/** Bytes of fresh entropy in the grant's [TAG_NONCE] tag (64 lowercase hex chars, as the reference client). */
|
||||
const val NONCE_BYTES = 32
|
||||
|
||||
/** The broker path for a voice room (the room = the voice signer's x-only pubkey hex). */
|
||||
fun wellKnownPath(voiceRoomHex: String): String = "/.well-known/concord/av/$voiceRoomHex"
|
||||
@@ -49,15 +55,27 @@ object ConcordBrokerToken {
|
||||
/**
|
||||
* Builds the kind-27235 auth event for [url]/[method], signed by the channel's
|
||||
* [voiceSigner] key (its public key is the voice room / SFU name).
|
||||
*
|
||||
* Every member of a Channel signs with the **same** `voice_key.sk`, so two members
|
||||
* requesting in the same second would otherwise build byte-identical events — one id —
|
||||
* and the broker's anti-replay set (keyed on the id, CORD-07 §2) would refuse the second.
|
||||
* The random [nonce] tag (`["nonce", <64 hex>]`, after `u` and `method`, as the reference
|
||||
* client signs it) keeps every grant's id unique.
|
||||
*/
|
||||
fun buildAuthEvent(
|
||||
voiceSigner: GroupKey,
|
||||
url: String,
|
||||
createdAt: Long,
|
||||
method: String = "GET",
|
||||
nonce: String = RandomInstance.bytes(NONCE_BYTES).toHexKey(),
|
||||
): Event {
|
||||
val signer = NostrSignerSync(KeyPair(privKey = voiceSigner.secretKey))
|
||||
return signer.signNormal(createdAt, KIND, arrayOf(arrayOf(TAG_URL, url), arrayOf(TAG_METHOD, method)), "")
|
||||
return signer.signNormal(
|
||||
createdAt,
|
||||
KIND,
|
||||
arrayOf(arrayOf(TAG_URL, url), arrayOf(TAG_METHOD, method), arrayOf(TAG_NONCE, nonce)),
|
||||
"",
|
||||
)
|
||||
}
|
||||
|
||||
/** The `Authorization` header value carrying a base64 of the signed auth [event]. */
|
||||
|
||||
+91
-16
@@ -23,12 +23,17 @@ package com.vitorpamplona.quartz.concord.cord07Voice
|
||||
import com.vitorpamplona.quartz.concord.cord03Channels.ChannelChat
|
||||
import com.vitorpamplona.quartz.concord.cord03Channels.tags.ChannelTag
|
||||
import com.vitorpamplona.quartz.concord.cord03Channels.tags.EpochTag
|
||||
import com.vitorpamplona.quartz.concord.cord03Channels.tags.MsTag
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.core.firstTagValue
|
||||
import com.vitorpamplona.quartz.nip59Giftwrap.rumors.RumorAssembler
|
||||
|
||||
/** A parsed voice presence: who is in the call, under which SFU [identity], and on which [broker]. */
|
||||
/**
|
||||
* A parsed voice presence: who is in the call, under which SFU [identity], and on which [broker].
|
||||
* [ms] is the CORD-02 §4 ordering basis (`created_at * 1000 + ms`) and [rumorId] the equal-time
|
||||
* tiebreak — together they decide which of an author's presences is the latest.
|
||||
*/
|
||||
class VoicePresenceInfo(
|
||||
val author: HexKey,
|
||||
val channelId: HexKey?,
|
||||
@@ -37,6 +42,18 @@ class VoicePresenceInfo(
|
||||
val identity: String?,
|
||||
val broker: String?,
|
||||
val createdAt: Long,
|
||||
val ms: Long,
|
||||
val rumorId: HexKey,
|
||||
)
|
||||
|
||||
/**
|
||||
* The folded view of one Channel's call (CORD-07 §4): [present] holds each author's latest
|
||||
* presence when it is a fresh `joined`; [verified] maps an SFU identity to the single author
|
||||
* whose fresh presence claims it (contested and unclaimed identities are absent).
|
||||
*/
|
||||
class VoicePresenceFold(
|
||||
val present: List<VoicePresenceInfo>,
|
||||
val verified: Map<String, HexKey>,
|
||||
)
|
||||
|
||||
/**
|
||||
@@ -44,9 +61,11 @@ class VoicePresenceInfo(
|
||||
* Channel plane (like Chat Plane messages), announcing that a member is in the
|
||||
* call under a broker-assigned SFU [VoicePresenceInfo.identity].
|
||||
*
|
||||
* A participant renders as a verified member only when **exactly one author's
|
||||
* fresh signed presence** claims an identity ([verifiedParticipants]); contested
|
||||
* identities render unverified. Presence is heartbeated every
|
||||
* Per author the **latest** presence wins ([latestPerAuthor]: millisecond basis, lower rumor id
|
||||
* on a tie — the reference client's `foldVoicePresence`), so a `left` supersedes an earlier
|
||||
* `joined` and a heartbeat under a new identity drops the old claim. A participant renders as a
|
||||
* verified member only when **exactly one author's fresh signed presence** claims an identity
|
||||
* ([fold]); contested identities render unverified. Presence is heartbeated every
|
||||
* [HEARTBEAT_MS] and considered absent after [STALE_MS].
|
||||
*/
|
||||
object VoicePresence {
|
||||
@@ -67,48 +86,104 @@ object VoicePresence {
|
||||
identity: String,
|
||||
createdAt: Long,
|
||||
broker: String? = null,
|
||||
subMs: Int? = null,
|
||||
subMs: Int? = MsTag.remainderFor(createdAt),
|
||||
): Event {
|
||||
val tags = ArrayList<Array<String>>()
|
||||
tags.add(ChannelTag.assemble(channelId))
|
||||
tags.add(EpochTag.assemble(epoch))
|
||||
tags.add(arrayOf(TAG_IDENTITY, identity))
|
||||
if (broker != null) tags.add(arrayOf(TAG_BROKER, broker))
|
||||
if (subMs != null) tags.add(arrayOf("ms", subMs.toString()))
|
||||
if (subMs != null) tags.add(MsTag.assemble(subMs))
|
||||
return RumorAssembler.assembleRumor(authorPubKey, createdAt, KIND, tags.toTypedArray(), CONTENT_JOINED)
|
||||
}
|
||||
|
||||
/** A "left" presence bound to the channel/epoch. */
|
||||
/** A "left" presence bound to the channel/epoch (identity and broker omitted). */
|
||||
fun left(
|
||||
authorPubKey: HexKey,
|
||||
channelId: HexKey,
|
||||
epoch: Long,
|
||||
createdAt: Long,
|
||||
): Event = RumorAssembler.assembleRumor(authorPubKey, createdAt, KIND, arrayOf(ChannelTag.assemble(channelId), EpochTag.assemble(epoch)), CONTENT_LEFT)
|
||||
subMs: Int? = MsTag.remainderFor(createdAt),
|
||||
): Event {
|
||||
val tags = ArrayList<Array<String>>(3)
|
||||
tags.add(ChannelTag.assemble(channelId))
|
||||
tags.add(EpochTag.assemble(epoch))
|
||||
if (subMs != null) tags.add(MsTag.assemble(subMs))
|
||||
return RumorAssembler.assembleRumor(authorPubKey, createdAt, KIND, tags.toTypedArray(), CONTENT_LEFT)
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses a kind-23313 rumor, or null when malformed: the content is not `joined`/`left`, a
|
||||
* `joined` names no identity, or the `ms` tag is malformed/duplicated (dropped, never
|
||||
* interpreted — CORD-02 §5). The caller checks the channel/epoch binding.
|
||||
*/
|
||||
fun parse(rumor: Event): VoicePresenceInfo? {
|
||||
if (rumor.kind != KIND) return null
|
||||
val joined =
|
||||
when (rumor.content) {
|
||||
CONTENT_JOINED -> true
|
||||
CONTENT_LEFT -> false
|
||||
else -> return null
|
||||
}
|
||||
val ms = MsTag.orderingMs(rumor.createdAt, rumor.tags) ?: return null
|
||||
val identity = rumor.tags.firstTagValue(TAG_IDENTITY)?.takeIf { it.isNotEmpty() }
|
||||
if (joined && identity == null) return null
|
||||
return VoicePresenceInfo(
|
||||
author = rumor.pubKey,
|
||||
channelId = ChannelChat.channelOf(rumor),
|
||||
epoch = ChannelChat.epochOf(rumor),
|
||||
joined = rumor.content == CONTENT_JOINED,
|
||||
identity = rumor.tags.firstTagValue(TAG_IDENTITY),
|
||||
broker = rumor.tags.firstTagValue(TAG_BROKER),
|
||||
joined = joined,
|
||||
identity = if (joined) identity else null,
|
||||
broker = if (joined) rumor.tags.firstTagValue(TAG_BROKER) else null,
|
||||
createdAt = rumor.createdAt,
|
||||
ms = ms,
|
||||
rumorId = rumor.id,
|
||||
)
|
||||
}
|
||||
|
||||
/** True if [presence] is within [STALE_MS] of [nowMs] (createdAt is unix seconds). */
|
||||
/** True if [presence] is within [STALE_MS] of [nowMs], on the millisecond basis. */
|
||||
fun isFresh(
|
||||
presence: VoicePresenceInfo,
|
||||
nowMs: Long,
|
||||
): Boolean = nowMs - presence.createdAt * 1000 <= STALE_MS
|
||||
): Boolean = nowMs - presence.ms <= STALE_MS
|
||||
|
||||
/** True when [candidate] supersedes [current]: later on the ms basis, or the lower rumor id on a tie. */
|
||||
private fun isLater(
|
||||
candidate: VoicePresenceInfo,
|
||||
current: VoicePresenceInfo,
|
||||
): Boolean = candidate.ms > current.ms || (candidate.ms == current.ms && candidate.rumorId < current.rumorId)
|
||||
|
||||
/** Each author's latest presence (CORD-07 §4: ms basis, lower rumor id on equal ms). */
|
||||
fun latestPerAuthor(presences: Collection<VoicePresenceInfo>): Map<HexKey, VoicePresenceInfo> {
|
||||
val latest = HashMap<HexKey, VoicePresenceInfo>()
|
||||
for (p in presences) {
|
||||
val prev = latest[p.author]
|
||||
if (prev == null || isLater(p, prev)) latest[p.author] = p
|
||||
}
|
||||
return latest
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps each SFU identity to its single verified author across the given fresh
|
||||
* [presences]. An identity claimed by zero or more-than-one author is omitted
|
||||
* (contested identities render unverified).
|
||||
* Folds every received presence of one call: per author the latest wins, then a `joined`
|
||||
* older than [STALE_MS] counts as absent, then identities claimed by exactly one of the
|
||||
* remaining authors verify. [VoicePresenceFold.present] is ordered by time, then author.
|
||||
*/
|
||||
fun fold(
|
||||
presences: Collection<VoicePresenceInfo>,
|
||||
nowMs: Long,
|
||||
): VoicePresenceFold {
|
||||
val present =
|
||||
latestPerAuthor(presences)
|
||||
.values
|
||||
.filter { it.joined && it.identity != null && isFresh(it, nowMs) }
|
||||
.sortedWith(compareBy<VoicePresenceInfo> { it.ms }.thenBy { it.author })
|
||||
return VoicePresenceFold(present, verifiedParticipants(present))
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps each SFU identity to its single verified author across [presences], which must
|
||||
* already be the per-author latest, fresh presences ([fold] does both). An identity claimed
|
||||
* by more than one author is omitted (contested identities render unverified).
|
||||
*/
|
||||
fun verifiedParticipants(presences: List<VoicePresenceInfo>): Map<String, HexKey> {
|
||||
val claimants = HashMap<String, MutableSet<HexKey>>()
|
||||
|
||||
+11
-2
@@ -66,9 +66,18 @@ class SealEvent(
|
||||
|
||||
override fun isContentEncoded() = true
|
||||
|
||||
suspend fun unsealThrowing(signer: NostrSigner): Event {
|
||||
val rumor = Rumor.fromJson(plainContent(signer))
|
||||
suspend fun unsealThrowing(signer: NostrSigner): Event = unsealed(unsealRumorThrowing(signer))
|
||||
|
||||
/**
|
||||
* The rumor exactly as this seal carries it — its claimed `pubkey` NOT yet overwritten by the
|
||||
* seal's — in one decrypt. For a caller that must run the NIP-59 anti-spoofing check itself
|
||||
* (rumor author == seal author) and then still wants the merged event: pass the result to
|
||||
* [unsealed] rather than decrypting again.
|
||||
*/
|
||||
suspend fun unsealRumorThrowing(signer: NostrSigner): Rumor = Rumor.fromJson(plainContent(signer))
|
||||
|
||||
/** [rumor] (decrypted from this seal) merged into the inner event, recorded as [innerEventId]. */
|
||||
fun unsealed(rumor: Rumor): Event {
|
||||
val event = rumor.mergeWith(this)
|
||||
innerEventId = event.id
|
||||
|
||||
|
||||
+157
-2
@@ -20,11 +20,21 @@
|
||||
*/
|
||||
package com.vitorpamplona.quartz.concord.cord02Community
|
||||
|
||||
import com.vitorpamplona.quartz.concord.cord04Roles.AuthorityCitation
|
||||
import com.vitorpamplona.quartz.concord.cord04Roles.AuthorityResolver
|
||||
import com.vitorpamplona.quartz.concord.cord04Roles.ControlEdition
|
||||
import com.vitorpamplona.quartz.concord.cord04Roles.ControlEntityKind
|
||||
import com.vitorpamplona.quartz.concord.cord04Roles.ControlFixtures
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray
|
||||
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertNotNull
|
||||
import kotlin.test.assertNull
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class GuestbookTest {
|
||||
private val member = KeyPair().pubKey.toHexKey()
|
||||
@@ -42,6 +52,8 @@ class GuestbookTest {
|
||||
assertEquals(member, entry?.member)
|
||||
assertEquals(creator, entry?.inviteCreator)
|
||||
assertEquals("Reddit", entry?.inviteLabel)
|
||||
assertEquals(1_700_000_000_128L, entry?.ms)
|
||||
assertEquals(rumor.id, entry?.rumorId)
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -59,7 +71,150 @@ class GuestbookTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun parseIgnoresNonGuestbookRumors() {
|
||||
assertNull(Guestbook.parse(Guestbook.kick(creator, target, 1L))) // kick is not a join/leave
|
||||
fun kickMatchesTheExampleWireShape() {
|
||||
// examples.md §3.2: content "", tags ms, p, vac.
|
||||
val citation = AuthorityCitation(ByteArray(32) { 1 }, 3, ByteArray(32) { 2 })
|
||||
val rumor = Guestbook.kick(creator, target, createdAt = 1_722_410_000L, subMs = 301, citation = citation)
|
||||
assertEquals("", rumor.content)
|
||||
assertEquals(listOf("ms", "p", "vac"), rumor.tags.map { it[0] })
|
||||
assertEquals(listOf("ms", "301"), rumor.tags[0].toList())
|
||||
assertEquals(listOf("vac", "01".repeat(32), "3", "02".repeat(32)), rumor.tags[2].toList())
|
||||
|
||||
val entry = assertNotNull(Guestbook.parse(rumor))
|
||||
assertEquals(GuestbookAction.KICK, entry.action)
|
||||
assertEquals(target, entry.member) // the state it sets is the target's
|
||||
assertEquals(creator, entry.author)
|
||||
assertEquals(1_722_410_000_301L, entry.ms)
|
||||
assertEquals(3L, entry.citation?.grantVersion)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun parseIgnoresNonGuestbookAndMalformedRumors() {
|
||||
assertNull(Guestbook.parse(Event("00".repeat(32), member, 1L, 9, emptyArray(), "join", "")))
|
||||
// A 3306 whose verb is "kick" is not a Kick: only kind 3309 can kick.
|
||||
assertNull(Guestbook.parse(Event("00".repeat(32), member, 1L, Guestbook.KIND_JOIN_LEAVE, emptyArray(), "kick", "")))
|
||||
// A malformed ms is dropped, never interpreted (CORD-02 §5).
|
||||
assertNull(Guestbook.parse(Event("00".repeat(32), member, 1L, Guestbook.KIND_JOIN_LEAVE, arrayOf(arrayOf("ms", "1000")), "join", "")))
|
||||
// A Kick naming no valid target.
|
||||
assertNull(Guestbook.parse(Event("00".repeat(32), member, 1L, Guestbook.KIND_KICK, arrayOf(arrayOf("p", "zz")), "", "")))
|
||||
}
|
||||
|
||||
// ---- Kick authority (CORD-04 §5/§6) --------------------------------------
|
||||
|
||||
private val owner = "0f".repeat(32)
|
||||
private val admin = "a1".repeat(32)
|
||||
private val mod = "b2".repeat(32)
|
||||
private val plain = "c3".repeat(32)
|
||||
private val other = "d4".repeat(32)
|
||||
private val adminRole = "11".repeat(32)
|
||||
private val modRole = "22".repeat(32)
|
||||
|
||||
private fun role(
|
||||
roleId: String,
|
||||
json: String,
|
||||
) = ControlEdition(ControlEntityKind.ROLE, roleId.hexToByteArray(), 0, null, null, json, owner, "role-$roleId", 0)
|
||||
|
||||
private fun grant(
|
||||
member: String,
|
||||
roleIds: List<String>,
|
||||
) = ControlEdition(
|
||||
ControlEntityKind.GRANT,
|
||||
ControlFixtures.grantEid(member).hexToByteArray(),
|
||||
0,
|
||||
null,
|
||||
null,
|
||||
"""{"member":"$member","role_ids":[${roleIds.joinToString(",") { "\"$it\"" }}]}""",
|
||||
owner,
|
||||
"grant-$member",
|
||||
0,
|
||||
)
|
||||
|
||||
private fun banlist(vararg banned: String) = ControlEdition(ControlEntityKind.BANLIST, ControlFixtures.banlistEid().hexToByteArray(), 0, null, null, "[${banned.joinToString(",") { "\"$it\"" }}]", owner, "ban", 0)
|
||||
|
||||
/** Admin at position 1 (KICK|BAN|MANAGE_ROLES), Mod at position 5 (KICK only). */
|
||||
private fun roster(vararg extra: ControlEdition): AuthorityResolver =
|
||||
ControlFixtures.resolve(
|
||||
listOf(
|
||||
role(adminRole, """{"name":"Admin","position":1,"permissions":"25"}"""),
|
||||
role(modRole, """{"name":"Mod","position":5,"permissions":"8"}"""),
|
||||
grant(admin, listOf(adminRole)),
|
||||
grant(mod, listOf(modRole)),
|
||||
) + extra,
|
||||
owner,
|
||||
)
|
||||
|
||||
private fun kickBy(
|
||||
actor: String,
|
||||
target: String,
|
||||
authority: AuthorityResolver,
|
||||
at: Long = 100L,
|
||||
citation: AuthorityCitation? = authority.citationFor(actor),
|
||||
) = Guestbook.parse(Guestbook.kick(actor, target, at, subMs = 0, citation = citation))!!
|
||||
|
||||
private fun join(
|
||||
who: String,
|
||||
at: Long,
|
||||
) = Guestbook.parse(Guestbook.join(who, at, subMs = 0))!!
|
||||
|
||||
@Test
|
||||
fun kickHonoredOnlyFromAKickHolderWhoOutranksTheTarget() {
|
||||
val r = roster()
|
||||
assertTrue(Guestbook.canKick(r, kickBy(owner, plain, r))) // owner, no citation needed
|
||||
assertTrue(Guestbook.canKick(r, kickBy(mod, plain, r))) // KICK at position 5 over a roleless member
|
||||
assertTrue(Guestbook.canKick(r, kickBy(admin, mod, r))) // 1 outranks 5
|
||||
assertFalse(Guestbook.canKick(r, kickBy(mod, admin, r))) // 5 does not outrank 1
|
||||
assertFalse(Guestbook.canKick(r, kickBy(plain, other, r))) // no KICK bit
|
||||
assertFalse(Guestbook.canKick(r, kickBy(admin, owner, r))) // the owner is never a target
|
||||
assertFalse(Guestbook.canKick(r, kickBy(mod, mod, r))) // equal cannot act on equal
|
||||
}
|
||||
|
||||
@Test
|
||||
fun kickWithoutASyncedCitationParks() {
|
||||
val r = roster()
|
||||
// No vac from a non-owner: the sync floor cannot be met.
|
||||
assertFalse(Guestbook.canKick(r, kickBy(mod, plain, r, citation = null)))
|
||||
// A vac ahead of the Grant we hold (not yet synced) parks too.
|
||||
val held = r.citationFor(mod)!!
|
||||
val ahead = AuthorityCitation(held.grantId, held.grantVersion + 1, held.grantHash)
|
||||
assertFalse(Guestbook.canKick(r, kickBy(mod, plain, r, citation = ahead)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun coalesceTakesTheLatestHonoredMotionPerMember() {
|
||||
val r = roster()
|
||||
val joined = join(plain, 50L)
|
||||
val kicked = kickBy(mod, plain, r, at = 100L)
|
||||
val folded = Guestbook.coalesce(listOf(kicked, joined), nowMs = 200_000L, authority = r)
|
||||
assertEquals(GuestbookAction.KICK, folded[plain]?.action)
|
||||
|
||||
// A kicked member may re-join: a newer Join supersedes the Kick.
|
||||
val rejoined = join(plain, 150L)
|
||||
assertEquals(GuestbookAction.JOIN, Guestbook.coalesce(listOf(kicked, joined, rejoined), 200_000L, r)[plain]?.action)
|
||||
|
||||
// A Kick from someone who may not kick leaves the member joined.
|
||||
val forged = kickBy(plain, other, r, at = 100L)
|
||||
val otherJoined = join(other, 50L)
|
||||
assertEquals(GuestbookAction.JOIN, Guestbook.coalesce(listOf(otherJoined, forged), 200_000L, r)[other]?.action)
|
||||
|
||||
// Without a roster no Kick is honored.
|
||||
assertEquals(GuestbookAction.JOIN, Guestbook.coalesce(listOf(joined, kicked), 200_000L, authority = null)[plain]?.action)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun coalesceDropsFutureAndBannedEntriesAndTiesToTheLowerRumorId() {
|
||||
val now = 1_000_000L
|
||||
val future = Guestbook.parse(Guestbook.leave(plain, now / 1000 + 3601, subMs = 0))!!
|
||||
val joined = join(plain, 10L)
|
||||
assertEquals(GuestbookAction.JOIN, Guestbook.coalesce(listOf(joined, future), now, authority = null)[plain]?.action)
|
||||
|
||||
// A banned member's own motions are dropped.
|
||||
val banned = roster(banlist(other))
|
||||
assertNull(Guestbook.coalesce(listOf(join(other, 10L)), now, banned)[other])
|
||||
|
||||
val a = Guestbook.parse(Guestbook.join(plain, 10L, subMs = 5))!!
|
||||
val b = Guestbook.parse(Guestbook.leave(plain, 10L, subMs = 5))!!
|
||||
val lower = if (a.rumorId < b.rumorId) a else b
|
||||
assertEquals(lower.rumorId, Guestbook.coalesce(listOf(a, b), now, authority = null)[plain]?.rumorId)
|
||||
assertEquals(lower.rumorId, Guestbook.coalesce(listOf(b, a), now, authority = null)[plain]?.rumorId)
|
||||
}
|
||||
}
|
||||
|
||||
+144
@@ -0,0 +1,144 @@
|
||||
/*
|
||||
* 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.concord.cord03Channels
|
||||
|
||||
import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityList
|
||||
import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEntry
|
||||
import com.vitorpamplona.quartz.concord.cord02Community.PrivateChannelKey
|
||||
import com.vitorpamplona.quartz.concord.cord04Roles.ConcordJson
|
||||
import kotlinx.serialization.json.JsonArray
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import kotlinx.serialization.json.jsonArray
|
||||
import kotlinx.serialization.json.jsonObject
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertNotNull
|
||||
import kotlin.test.assertNull
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
/**
|
||||
* The List side of Private Channel keys (CORD-02 §8, CORD-03 §2, CORD-06 §2): rotations replace
|
||||
* the one current key in place, older keys are read from `seed` / a peer's `priors` but never
|
||||
* written, and the reference client's `channel_cuts` floor survives a round trip and refuses a
|
||||
* key below it.
|
||||
*/
|
||||
class ConcordChannelKeyringTest {
|
||||
private val chan = "a1".repeat(32)
|
||||
private val other = "b2".repeat(32)
|
||||
private val k0 = "10".repeat(32)
|
||||
private val k1 = "11".repeat(32)
|
||||
private val k2 = "12".repeat(32)
|
||||
|
||||
private fun entry(channels: List<PrivateChannelKey>) =
|
||||
ConcordCommunityListEntry(
|
||||
id = "c0".repeat(32),
|
||||
owner = "0f".repeat(32),
|
||||
ownerSalt = "5a".repeat(32),
|
||||
root = "22".repeat(32),
|
||||
rootEpoch = 2,
|
||||
privateChannels = channels,
|
||||
name = "Test",
|
||||
addedAt = 1,
|
||||
)
|
||||
|
||||
private fun roundTrip(e: ConcordCommunityListEntry): ConcordCommunityListEntry = ConcordCommunityList.decode(ConcordCommunityList.encode(listOf(e))).single()
|
||||
|
||||
@Test
|
||||
fun aRotationReplacesTheKeyInPlaceKeepingUnknownFields() {
|
||||
val wire =
|
||||
"""{"entries":[{"community_id":"${"c0".repeat(32)}","added_at":1,"current":{"community_id":"${"c0".repeat(32)}",
|
||||
"owner":"${"0f".repeat(32)}","owner_salt":"${"5a".repeat(32)}","community_root":"${"22".repeat(32)}","root_epoch":2,
|
||||
"channels":[{"id":"$chan","key":"$k0","epoch":0,"name":"mods","tint":"red"}],"relays":[],"name":"Test"}}]}"""
|
||||
val held = ConcordCommunityList.decode(wire).single()
|
||||
val rotated = assertNotNull(ConcordChannelKeyring.withRotatedKey(held, chan, k1, 1))
|
||||
val ch = rotated.privateChannels.single()
|
||||
assertEquals(k1, ch.key)
|
||||
assertEquals(1, ch.epoch)
|
||||
assertEquals("mods", ch.name)
|
||||
// Another client's field inside the channel object survives.
|
||||
val back = roundTrip(rotated).privateChannels.single()
|
||||
assertEquals(JsonPrimitive("red"), back.extras["tint"])
|
||||
// Never backward, never sideways.
|
||||
assertNull(ConcordChannelKeyring.withRotatedKey(rotated, chan, k2, 1))
|
||||
assertNull(ConcordChannelKeyring.withRotatedKey(rotated, chan, k2, 0))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aCutRoundTripsAndRefusesOlderKeys() {
|
||||
val held = entry(listOf(PrivateChannelKey(chan, k0, 0, "mods"), PrivateChannelKey(other, k1, 3, "vip")))
|
||||
val cut = ConcordChannelKeyring.withoutChannel(held, chan, 1)
|
||||
assertEquals(listOf(other), cut.privateChannels.map { it.channelId })
|
||||
|
||||
// Written as the reference client's entry-level extension and read back.
|
||||
val back = roundTrip(cut)
|
||||
val encoded = ConcordJson.instance.parseToJsonElement(ConcordCommunityList.encode(listOf(cut))).jsonObject
|
||||
val cuts = encoded["entries"]!!.jsonArray[0].jsonObject["channel_cuts"]!!.jsonArray
|
||||
assertEquals(listOf(JsonObject(mapOf("id" to JsonPrimitive(chan), "epoch" to JsonPrimitive(1L)))), cuts.toList())
|
||||
assertEquals(mapOf(chan to 1L), ConcordChannelKeyring.cutsOf(back))
|
||||
|
||||
// A stale key below the cut never comes back; one at/above it (a re-grant) does.
|
||||
assertTrue(ConcordChannelKeyring.isCutOff(back, chan, 0))
|
||||
assertNull(ConcordChannelKeyring.withChannelKey(back, PrivateChannelKey(chan, k0, 0)))
|
||||
assertNotNull(ConcordChannelKeyring.withChannelKey(back, PrivateChannelKey(chan, k2, 1)))
|
||||
|
||||
// Max wins; a lower cut never rolls it back.
|
||||
assertEquals(1L, ConcordChannelKeyring.cutsOf(ConcordChannelKeyring.withCut(back, chan, 0))[chan])
|
||||
assertEquals(4L, ConcordChannelKeyring.cutsOf(ConcordChannelKeyring.withCut(back, chan, 4))[chan])
|
||||
}
|
||||
|
||||
@Test
|
||||
fun anUnknownFieldInsideACutSurvivesARaise() {
|
||||
val wire =
|
||||
"""{"entries":[{"community_id":"${"c0".repeat(32)}","added_at":1,"channel_cuts":[{"id":"$chan","epoch":1,"why":"x"},{"id":"$other","epoch":2}],
|
||||
"current":{"community_id":"${"c0".repeat(32)}","owner":"${"0f".repeat(32)}","owner_salt":"${"5a".repeat(32)}",
|
||||
"community_root":"${"22".repeat(32)}","root_epoch":2,"channels":[],"relays":[],"name":"Test"}}]}"""
|
||||
val held = ConcordCommunityList.decode(wire).single()
|
||||
val raised = roundTrip(ConcordChannelKeyring.withCut(held, chan, 3))
|
||||
val cuts = raised.residue.entryExtras["channel_cuts"] as JsonArray
|
||||
val mine = cuts.map { it.jsonObject }.single { (it["id"] as JsonPrimitive).content == chan }
|
||||
assertEquals(JsonPrimitive("x"), mine["why"])
|
||||
assertEquals(mapOf(chan to 3L, other to 2L), ConcordChannelKeyring.cutsOf(raised))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun olderKeysAreReadFromSeedAndPriorsButNeverWritten() {
|
||||
val wire =
|
||||
"""{"entries":[{"community_id":"${"c0".repeat(32)}","added_at":1,
|
||||
"seed":{"community_id":"${"c0".repeat(32)}","owner":"${"0f".repeat(32)}","owner_salt":"${"5a".repeat(32)}","community_root":"${"22".repeat(32)}",
|
||||
"root_epoch":0,"channels":[{"id":"$chan","key":"$k0","epoch":0,"name":"mods"}],"relays":[],"name":"Test"},
|
||||
"current":{"community_id":"${"c0".repeat(32)}","owner":"${"0f".repeat(32)}","owner_salt":"${"5a".repeat(32)}","community_root":"${"22".repeat(32)}",
|
||||
"root_epoch":2,"channels":[{"id":"$chan","key":"$k2","epoch":2,"name":"mods","priors":[{"key":"$k1","epoch":1,"retired_at":5}]}],"relays":[],"name":"Test"}}]}"""
|
||||
val held = ConcordCommunityList.decode(wire).single()
|
||||
assertEquals(listOf(1L to k1, 0L to k0), ConcordChannelKeyring.historicalKeys(held, chan).map { it.epoch to it.key })
|
||||
// The next privatisation climbs past every generation this entry knows of.
|
||||
assertEquals(3, ConcordChannelKeyring.nextChannelEpoch(held, chan))
|
||||
assertEquals(10, ConcordChannelKeyring.nextChannelEpoch(held, chan, observedFloor = 9))
|
||||
assertEquals(1, ConcordChannelKeyring.nextChannelEpoch(held, other))
|
||||
|
||||
// A rotation we launch adds no prior of its own (CORD-02 §8 keeps intermediate keys out of the List).
|
||||
val rotated = assertNotNull(ConcordChannelKeyring.withRotatedKey(held, chan, "13".repeat(32), 3))
|
||||
val priors = rotated.privateChannels.single().extras[ConcordChannelKeyring.PRIORS] as JsonArray
|
||||
assertEquals(1, priors.size)
|
||||
assertFalse(ConcordChannelKeyring.historicalKeys(rotated, chan).any { it.key == "13".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.concord.cord03Channels
|
||||
|
||||
import com.vitorpamplona.quartz.concord.crypto.ConcordKeyDerivation
|
||||
import com.vitorpamplona.quartz.concord.crypto.ConcordLabels
|
||||
import com.vitorpamplona.quartz.concord.envelope.ConcordStreamEnvelope
|
||||
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray
|
||||
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertNotNull
|
||||
import kotlin.test.assertNull
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
/** WebXDC signals (kind 3310): Chat-plane plumbing only — built, bound, gated apart from chat rows, parsed. */
|
||||
class ConcordWebxdcTest {
|
||||
private val author = NostrSignerInternal(KeyPair())
|
||||
private val channelId = "42".repeat(32)
|
||||
private val topic = "A".repeat(26) + "234567".repeat(4) + "BC" // 52 base32 chars
|
||||
|
||||
@Test
|
||||
fun theExamplesBindingAndTheStateUpdateTags() {
|
||||
val rumor = ConcordWebxdc.stateUpdate(author.pubKey, channelId, 0L, session = "uuid-1", payload = "{\"x\":1}", createdAt = 1_686_840_700L, info = "moved", ms = 266)
|
||||
assertEquals(3310, rumor.kind)
|
||||
// examples.md §2.6: channel, epoch, ms — then the app's own tags.
|
||||
assertEquals(listOf("channel", "epoch", "ms", "i", "alt", "info"), rumor.tags.map { it[0] })
|
||||
assertTrue(ChannelChat.isBoundTo(rumor, channelId, 0L))
|
||||
assertEquals("uuid-1", ConcordWebxdc.sessionOf(rumor))
|
||||
assertEquals("{\"x\":1}", rumor.content)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun peerSignalsRoundTripInTheReferenceShape() {
|
||||
val ad = ConcordWebxdc.peerSignal(author.pubKey, channelId, 0L, topic, nodeAddr = "node-addr", createdAt = 5L)
|
||||
assertEquals("{\"op\":\"ad\",\"topic\":\"$topic\",\"addr\":\"node-addr\"}", ad.content)
|
||||
assertNull(ConcordWebxdc.sessionOf(ad))
|
||||
val parsed = assertNotNull(ConcordWebxdc.parsePeerSignal(ad.content))
|
||||
assertTrue(parsed.isAdvert)
|
||||
assertEquals("node-addr", parsed.addr)
|
||||
|
||||
val left = ConcordWebxdc.peerSignal(author.pubKey, channelId, 0L, topic, nodeAddr = null, createdAt = 6L)
|
||||
assertEquals("{\"op\":\"left\",\"topic\":\"$topic\"}", left.content)
|
||||
assertFalse(assertNotNull(ConcordWebxdc.parsePeerSignal(left.content)).isAdvert)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun untrustedPeerSignalsAreBounded() {
|
||||
assertNull(ConcordWebxdc.parsePeerSignal("not json"))
|
||||
assertNull(ConcordWebxdc.parsePeerSignal("{\"op\":\"ad\",\"topic\":\"short\",\"addr\":\"a\"}"))
|
||||
assertNull(ConcordWebxdc.parsePeerSignal("{\"op\":\"ad\",\"topic\":\"${topic.lowercase()}\",\"addr\":\"a\"}"))
|
||||
assertNull(ConcordWebxdc.parsePeerSignal("{\"op\":\"ad\",\"topic\":\"$topic\",\"addr\":\"\"}"))
|
||||
assertNull(ConcordWebxdc.parsePeerSignal("{\"op\":\"ad\",\"topic\":\"$topic\",\"addr\":\"${"a".repeat(ConcordWebxdc.MAX_NODE_ADDR_CHARS + 1)}\"}"))
|
||||
assertNull(ConcordWebxdc.parsePeerSignal("{\"op\":\"join\",\"topic\":\"$topic\"}"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun theChatGateKeepsWebxdcOutOfChatRowsButThePlaneAdmitsIt() =
|
||||
runTest {
|
||||
val plane = ConcordKeyDerivation.groupKey(ConcordLabels.CHANNEL, ByteArray(32) { 9 }, channelId.hexToByteArray(), 0)
|
||||
val rumor = ConcordWebxdc.stateUpdate(author.pubKey, channelId, 0L, "uuid-1", "{}", createdAt = 5L)
|
||||
val wrap = ConcordStreamEnvelope.wrap(rumor, plane, author, encrypted = true, createdAt = 5L)
|
||||
val opened = assertNotNull(ConcordStreamEnvelope.openOrNull(wrap, plane))
|
||||
|
||||
assertFalse(ChannelChat.isChatKind(ConcordWebxdc.KIND), "never a chat row")
|
||||
assertNull(ChannelChat.acceptOpened(opened, channelId, 0L), "the chat gate refuses it")
|
||||
assertEquals(rumor.id, ChannelChat.acceptOpened(opened, channelId, 0L, ChannelChat.PLANE_KINDS)?.id, "the plane carries it")
|
||||
assertNull(ChannelChat.acceptOpened(opened, channelId, 1L, ChannelChat.PLANE_KINDS), "under the same strict binding")
|
||||
}
|
||||
}
|
||||
+6
@@ -167,6 +167,12 @@ class ConcordPinsTest {
|
||||
val noKey = ConcordPins.read(sealed) { null }
|
||||
assertTrue(noKey.sealedUnavailable, "unreadable is not empty: a writer must not build on it")
|
||||
assertTrue(noKey.entries.isEmpty())
|
||||
|
||||
// The read reports the form itself, matching isSealedForm, so nobody parses twice.
|
||||
for (content in listOf(sealed, ConcordPins.serializePublic(listOf(entry)), "not json", """{"sealed":1}""", """{"epoch":"x","sealed":"y"}""")) {
|
||||
assertEquals(ConcordPins.isSealedForm(content), ConcordPins.read(content) { plane.conversationKey }.sealedForm, content)
|
||||
assertEquals(ConcordPins.isSealedForm(content), ConcordPins.read(content) { null }.sealedForm, content)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
+39
-4
@@ -20,6 +20,7 @@
|
||||
*/
|
||||
package com.vitorpamplona.quartz.concord.cord05Invites
|
||||
|
||||
import com.vitorpamplona.quartz.concord.cord04Roles.AuthorityResolver
|
||||
import com.vitorpamplona.quartz.concord.cord04Roles.ConcordPermissions
|
||||
import com.vitorpamplona.quartz.concord.cord04Roles.ControlEdition
|
||||
import com.vitorpamplona.quartz.concord.cord04Roles.ControlEntityKind
|
||||
@@ -196,6 +197,8 @@ class ConcordInviteRegistryTest {
|
||||
assertFalse(state.retiringWouldPrivatize(listOf(link1)))
|
||||
assertTrue(state.retiringWouldPrivatize(listOf(link1, link2)))
|
||||
assertFalse(ControlFixtures.fold(emptyList(), owner).retiringWouldPrivatize(listOf(link1)), "already Private: nothing flips")
|
||||
// A dissolved community is never Refounded (CORD-02 §9): the last retire is just a retire.
|
||||
assertFalse(state.withDissolved(true).retiringWouldPrivatize(listOf(link1, link2)))
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -259,15 +262,47 @@ class ConcordInviteRegistryTest {
|
||||
val livePk = live.pubKey.toHexKey()
|
||||
val expiredPk = expired.pubKey.toHexKey()
|
||||
|
||||
// The published registry still lists the expired link and an unrecorded one (link1); a new mint adds link2.
|
||||
// The published registry still lists the expired link and one no list entry backs (link1): the
|
||||
// readable list is authoritative, so both go; the recorded live link heals in; a mint adds link2.
|
||||
val next = ConcordInviteRegistry.nextLinks(listOf(expiredPk, link1), doc, ControlFixtures.COMMUNITY_ID_HEX, nowSecs = 200, minted = listOf(link2))
|
||||
assertEquals(listOf(link1, link2, livePk).sorted(), next)
|
||||
assertEquals(listOf(link2, livePk).sorted(), next)
|
||||
|
||||
// Retiring the recorded live link and link1 leaves only the mint.
|
||||
assertEquals(listOf(link2), ConcordInviteRegistry.nextLinks(next, doc, ControlFixtures.COMMUNITY_ID_HEX, nowSecs = 200, retired = listOf(livePk, link1)))
|
||||
// Retiring the recorded live link, with the next mint recorded too, leaves only that mint.
|
||||
val withMint = ConcordInviteListDocument(entries = doc.entries + entry("05", KeyPair()), tombstones = doc.tombstones)
|
||||
val link3 = withMint.entries.last().signerPubKeyHex()
|
||||
assertEquals(listOf(link3), ConcordInviteRegistry.nextLinks(next, withMint, ControlFixtures.COMMUNITY_ID_HEX, nowSecs = 200, retired = listOf(livePk)))
|
||||
|
||||
// Before its expiry the link is still live; an unreadable list prunes nothing.
|
||||
assertTrue(expiredPk in ConcordInviteRegistry.nextLinks(emptyList(), doc, ControlFixtures.COMMUNITY_ID_HEX, nowSecs = 50))
|
||||
assertEquals(listOf(expiredPk), ConcordInviteRegistry.nextLinks(listOf(expiredPk), null, ControlFixtures.COMMUNITY_ID_HEX, nowSecs = 200))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun theMemoizedRegistryCoordinateIsTheDerivedOne() {
|
||||
val author = KeyPair().pubKey.toHexKey()
|
||||
val derived = ConcordInviteRegistry.coordinateHex(ControlFixtures.COMMUNITY_ID_HEX.hexToByteArray(), author)
|
||||
repeat(2) {
|
||||
assertEquals(derived, AuthorityResolver.inviteLinksCoordinateHex(ControlFixtures.COMMUNITY_ID_HEX.hexToByteArray(), ControlFixtures.COMMUNITY_ID_HEX, author))
|
||||
}
|
||||
// Keyed by community too: the same author elsewhere is a different coordinate.
|
||||
val other = "ab".repeat(32)
|
||||
assertEquals(ConcordInviteRegistry.coordinateHex(other.hexToByteArray(), author), AuthorityResolver.inviteLinksCoordinateHex(other.hexToByteArray(), other, author))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aRetiredLinkWhoseEntryTheMergeDroppedIsNotResurrectedFromThePublishedRegistry() {
|
||||
val retired = KeyPair()
|
||||
val kept = KeyPair()
|
||||
val retiredPk = retired.pubKey.toHexKey()
|
||||
val keptPk = kept.pubKey.toHexKey()
|
||||
// After the tombstone merge, the retired link's entry (and its token) is gone from the list:
|
||||
// only the tombstone remains, which no longer names the signer.
|
||||
val merged =
|
||||
ConcordInviteListDocument(
|
||||
entries = listOf(entry("0a", kept)),
|
||||
tombstones = listOf(ConcordInviteListTombstone("0b", ControlFixtures.COMMUNITY_ID_HEX)),
|
||||
)
|
||||
// A later, unrelated registry edit (e.g. the next mint) must not carry the retired signer forward.
|
||||
assertEquals(listOf(keptPk), ConcordInviteRegistry.nextLinks(listOf(retiredPk, keptPk), merged, ControlFixtures.COMMUNITY_ID_HEX, nowSecs = 200))
|
||||
}
|
||||
}
|
||||
|
||||
+117
-9
@@ -22,6 +22,12 @@ package com.vitorpamplona.quartz.concord.cord05Invites
|
||||
|
||||
import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEntry
|
||||
import com.vitorpamplona.quartz.concord.cord02Community.PrivateChannelKey
|
||||
import com.vitorpamplona.quartz.concord.cord03Channels.ConcordChannelKeyring
|
||||
import com.vitorpamplona.quartz.concord.cord04Roles.AuthorityResolver
|
||||
import com.vitorpamplona.quartz.concord.cord04Roles.ControlEdition
|
||||
import com.vitorpamplona.quartz.concord.cord04Roles.ControlEntityKind
|
||||
import com.vitorpamplona.quartz.concord.cord04Roles.ControlFixtures
|
||||
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertNotNull
|
||||
@@ -87,15 +93,15 @@ class ConcordInviteVendTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aNewerEpochOfAHeldChannelReplacesIt() {
|
||||
val newer = "ee".repeat(32)
|
||||
val b = bundle(channels = listOf(InviteChannel(chanA, newer, 2, "mods")))
|
||||
assertEquals(listOf(chanA), ConcordInviteVend.catchUpChannelIds(held, b))
|
||||
val adopted = assertNotNull(ConcordInviteVend.adoptCatchUp(held, b))
|
||||
assertEquals(listOf(Triple(chanA, newer, 2L)), adopted.privateChannels.map { Triple(it.channelId, it.key, it.epoch) })
|
||||
|
||||
// Same or older epoch contributes nothing.
|
||||
assertTrue(ConcordInviteVend.catchUpChannelIds(held, bundle(channels = listOf(InviteChannel(chanA, newer, 1)))).isEmpty())
|
||||
fun aBundleNeverReplacesAHeldKey() {
|
||||
// A held key moves only through a channel rekey (prevcommit continuity). A bare bundle at a
|
||||
// higher — even absurd — epoch contributes nothing, so a keyholder can't park us on a dead key.
|
||||
val bogus = "ee".repeat(32)
|
||||
for (epoch in listOf(2L, 1_000_000_000L)) {
|
||||
val b = bundle(channels = listOf(InviteChannel(chanA, bogus, epoch, "mods")))
|
||||
assertTrue(ConcordInviteVend.catchUpChannelIds(held, b).isEmpty())
|
||||
assertNull(ConcordInviteVend.adoptCatchUp(held, b))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -114,4 +120,106 @@ class ConcordInviteVendTest {
|
||||
assertTrue(ConcordInviteVend.catchUpChannelIds(held, bundle(channels = listOf(InviteChannel(chanB, "", 0)))).isEmpty())
|
||||
assertNull(ConcordInviteVend.adoptCatchUp(held, bundle(channels = emptyList())))
|
||||
}
|
||||
|
||||
// ---- entitlement (Armada channelAccess.ts) and catch-up adoption (catchUpAdoption.ts) --------
|
||||
|
||||
private val owner = "0f".repeat(32)
|
||||
private val alice = "a1".repeat(32)
|
||||
private val bob = "b2".repeat(32)
|
||||
private val modRole = "71".repeat(32)
|
||||
private val accessRole = "72".repeat(32)
|
||||
|
||||
private fun role(
|
||||
roleId: String,
|
||||
json: String,
|
||||
) = ControlEdition(ControlEntityKind.ROLE, roleId.hexToByteArray(), 0, null, null, json, owner, "role-$roleId", 0)
|
||||
|
||||
private fun grant(
|
||||
member: String,
|
||||
roleIds: List<String>,
|
||||
version: Long = 0,
|
||||
prev: ControlEdition? = null,
|
||||
) = ControlEdition(
|
||||
ControlEntityKind.GRANT,
|
||||
ControlFixtures.grantEid(member).hexToByteArray(),
|
||||
version,
|
||||
prev?.hash,
|
||||
null,
|
||||
"""{"member":"$member","role_ids":[${roleIds.joinToString(",") { "\"$it\"" }}]}""",
|
||||
owner,
|
||||
"grant-$member-$version",
|
||||
version,
|
||||
)
|
||||
|
||||
private val roles =
|
||||
listOf(
|
||||
// A staff role (MANAGE_CHANNELS) with server scope, and a bit-less access role scoped to chanA.
|
||||
role(modRole, """{"role_id":"$modRole","name":"Mod","position":2,"permissions":"2"}"""),
|
||||
role(accessRole, """{"role_id":"$accessRole","name":"mods-room","position":10,"permissions":"0","scope":{"kind":"channel","channel_id":"$chanA"}}"""),
|
||||
)
|
||||
|
||||
private fun authority(vararg extra: ControlEdition): AuthorityResolver = ControlFixtures.resolve(roles + extra, owner)
|
||||
|
||||
@Test
|
||||
fun theOwnerAndScopedRoleHoldersAreEntitled() {
|
||||
val aliceIn = grant(alice, listOf(accessRole))
|
||||
val bobMod = grant(bob, listOf(modRole))
|
||||
val a = authority(aliceIn, bobMod)
|
||||
assertEquals(setOf(owner, alice), ConcordInviteVend.entitledMembers(a, chanA))
|
||||
assertTrue(ConcordInviteVend.isEntitled(a, owner, chanB))
|
||||
// A server-scoped staff role grants authority, never read access.
|
||||
assertTrue(!ConcordInviteVend.isEntitled(a, bob, chanA))
|
||||
// A link has no recipient and vends nothing; a member gets exactly their channels.
|
||||
val held = listOf(PrivateChannelKey(chanA, keyA, 1, "mods"), PrivateChannelKey(chanB, keyB, 0, "vip"))
|
||||
assertTrue(ConcordInviteVend.vendableChannels(held, a, null).isEmpty())
|
||||
assertEquals(listOf(chanA), ConcordInviteVend.vendableChannels(held, a, alice).map { it.channelId })
|
||||
assertEquals(listOf(chanA, chanB), ConcordInviteVend.vendableChannels(held, a, owner).map { it.channelId })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun accessChangesNameWhoToVendAndWhoARotationMustCut() {
|
||||
val aliceIn = grant(alice, listOf(accessRole))
|
||||
val before = authority()
|
||||
val afterGrant = authority(aliceIn)
|
||||
val granted = ConcordInviteVend.accessChanges(before, afterGrant, listOf(chanA, chanB)).single()
|
||||
assertEquals(chanA, granted.channelIdHex)
|
||||
assertEquals(setOf(alice), granted.gained)
|
||||
assertTrue(granted.lost.isEmpty())
|
||||
|
||||
val afterRevoke = authority(aliceIn, grant(alice, emptyList(), version = 1, prev = aliceIn))
|
||||
val revoked = ConcordInviteVend.accessChanges(afterGrant, afterRevoke, listOf(chanA)).single()
|
||||
assertEquals(setOf(alice), revoked.lost)
|
||||
assertTrue(ConcordInviteVend.accessChanges(afterGrant, afterGrant, listOf(chanA)).isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aStaffSentCatchUpForAnEntitledChannelIsAdoptedWithoutAClick() {
|
||||
val member = held
|
||||
val grantedChan = bundle(channels = listOf(InviteChannel(chanB, keyB, 0, "vip")))
|
||||
val scopedB = role("73".repeat(32), """{"role_id":"${"73".repeat(32)}","name":"vip","position":11,"permissions":"0","scope":{"kind":"channel","channel_id":"$chanB"}}""")
|
||||
val a = authority(scopedB, grant(alice, listOf("73".repeat(32))), grant(bob, listOf(modRole)))
|
||||
val live = setOf(chanA, chanB)
|
||||
assertEquals(ConcordInviteVend.CatchUpVerdict.ADOPT, ConcordInviteVend.judgeCatchUp(a, live, alice, owner, grantedChan, member))
|
||||
assertEquals(ConcordInviteVend.CatchUpVerdict.ADOPT, ConcordInviteVend.judgeCatchUp(a, live, alice, bob, grantedChan, member))
|
||||
assertEquals(ConcordInviteVend.CatchUpVerdict.NO_FOLD, ConcordInviteVend.judgeCatchUp(null, live, alice, owner, grantedChan, member))
|
||||
// A plain keyholder (alice) can't plant a key in bob's list automatically.
|
||||
assertEquals(ConcordInviteVend.CatchUpVerdict.SENDER_NOT_STAFF, ConcordInviteVend.judgeCatchUp(a, live, bob, alice, grantedChan, member))
|
||||
// Bob holds no role scoped to chanB.
|
||||
assertEquals(ConcordInviteVend.CatchUpVerdict.NOT_ENTITLED, ConcordInviteVend.judgeCatchUp(a, live, bob, owner, grantedChan, member))
|
||||
assertEquals(ConcordInviteVend.CatchUpVerdict.NOTHING_NEW, ConcordInviteVend.judgeCatchUp(a, live, alice, owner, bundle(channels = listOf(InviteChannel(chanA, keyA, 1))), member))
|
||||
// A channel the fold doesn't know as a live Private Channel is never auto-adopted.
|
||||
assertEquals(ConcordInviteVend.CatchUpVerdict.NOT_ENTITLED, ConcordInviteVend.judgeCatchUp(a, setOf(chanA), alice, owner, grantedChan, member))
|
||||
|
||||
// Manual accept: staff sender and a live Private Channel, entitlement waived by the click.
|
||||
assertEquals(listOf(chanB), ConcordInviteVend.admissibleCatchUpIds(member, grantedChan, a, live, owner))
|
||||
assertTrue(ConcordInviteVend.admissibleCatchUpIds(member, grantedChan, a, live, alice).isEmpty())
|
||||
assertTrue(ConcordInviteVend.admissibleCatchUpIds(member, grantedChan, a, setOf(chanA), owner).isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aCatchUpNeverRestoresAKeyBelowACut() {
|
||||
val cut = ConcordChannelKeyring.withoutChannel(held, chanA, 2)
|
||||
assertTrue(ConcordInviteVend.catchUpChannelIds(cut, bundle(channels = listOf(InviteChannel(chanA, keyA, 1)))).isEmpty())
|
||||
assertEquals(listOf(chanA), ConcordInviteVend.catchUpChannelIds(cut, bundle(channels = listOf(InviteChannel(chanA, keyB, 2)))))
|
||||
}
|
||||
}
|
||||
|
||||
+230
@@ -0,0 +1,230 @@
|
||||
/*
|
||||
* 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.concord.cord06Rekey
|
||||
|
||||
import com.vitorpamplona.quartz.concord.crypto.ConcordKeyDerivation
|
||||
import com.vitorpamplona.quartz.concord.crypto.ConcordLabels
|
||||
import com.vitorpamplona.quartz.concord.crypto.GroupKey
|
||||
import com.vitorpamplona.quartz.concord.envelope.ConcordStreamEnvelope
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray
|
||||
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal
|
||||
import com.vitorpamplona.quartz.nip44Encryption.Nip44
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import kotlin.io.encoding.Base64
|
||||
import kotlin.io.encoding.ExperimentalEncodingApi
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertContentEquals
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertIs
|
||||
import kotlin.test.assertNotEquals
|
||||
import kotlin.test.assertNotNull
|
||||
import kotlin.test.assertSame
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
/**
|
||||
* CORD-06 §1-2 single-channel Rekeys: the channel rekey address, the 72-byte scope-bound blob,
|
||||
* the `prevcommit` continuity walk, chunk completeness, racing rotators and the removal rule —
|
||||
* pinned to the reference client's `lib/rekey.ts` / `useChannelRekeyWatch`.
|
||||
*/
|
||||
class ConcordChannelRekeyTest {
|
||||
private val admin = NostrSignerInternal(KeyPair())
|
||||
private val alice = NostrSignerInternal(KeyPair())
|
||||
private val bob = NostrSignerInternal(KeyPair())
|
||||
private val root = ByteArray(32) { 0x21 }
|
||||
private val channelId = ByteArray(32) { 0x5C }
|
||||
private val channelHex = channelId.toHexKey()
|
||||
private val key0 = ByteArray(32) { 0x10 }
|
||||
private val now = 1_700_000_000L
|
||||
|
||||
private fun keysFor(vararg epochs: Long): Map<HexKey, GroupKey> = epochs.associate { e -> ConcordChannelRekey.address(root, channelId, e).let { it.publicKeyHex to it } }
|
||||
|
||||
private suspend fun rotate(
|
||||
heldKey: ByteArray,
|
||||
heldEpoch: Long,
|
||||
newKey: ByteArray,
|
||||
recipients: List<HexKey>,
|
||||
rotator: NostrSignerInternal = admin,
|
||||
createdAt: Long = now,
|
||||
): List<Event> = ConcordChannelRekey.build(rotator, root, channelId, heldKey, heldEpoch, newKey, recipients + rotator.pubKey, createdAt)
|
||||
|
||||
private suspend fun walk(
|
||||
wraps: List<Event>,
|
||||
me: NostrSignerInternal,
|
||||
heldKey: ByteArray = key0,
|
||||
heldEpoch: Long = 0,
|
||||
joinedAt: Long = 0,
|
||||
honored: (ChannelRotation) -> Boolean = { true },
|
||||
outranksMe: (HexKey) -> Boolean = { true },
|
||||
): ChannelRekeyOutcome {
|
||||
val keys = keysFor(*(heldEpoch + 1..heldEpoch + ConcordChannelRekey.LOOKAHEAD).toList().toLongArray())
|
||||
return ConcordChannelRekey.walk(ConcordChannelRekey.rotations(wraps, keys, channelHex), channelHex, heldKey, heldEpoch, me, joinedAt, honored, outranksMe)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun theChannelRekeyAddressIsTheRootKeyedRekeyPseudonym() {
|
||||
// CORD-02 derivation table: concord/rekey-pseudonym, prior community_root, channel_id, new_epoch.
|
||||
val address = ConcordChannelRekey.address(root, channelId, 3)
|
||||
assertEquals(ConcordKeyDerivation.groupKey(ConcordLabels.REKEY_PSEUDONYM, root, channelId, 3).publicKeyHex, address.publicKeyHex)
|
||||
// Keyed by the ROOT, never the channel key: every member can precompute it.
|
||||
assertNotEquals(ConcordKeyDerivation.groupKey(ConcordLabels.REKEY_PSEUDONYM, key0, channelId, 3).publicKeyHex, address.publicKeyHex)
|
||||
// Distinct per epoch and from the base-rotation address.
|
||||
assertNotEquals(address.publicKeyHex, ConcordChannelRekey.address(root, channelId, 4).publicKeyHex)
|
||||
assertNotEquals(address.publicKeyHex, ConcordKeyDerivation.baseRekeyAddress(root, channelId, 3).publicKeyHex)
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalEncodingApi::class)
|
||||
@Test
|
||||
fun aChannelRotationCarriesTheSpecTagsAnd72ByteScopeBoundBlobs() =
|
||||
runTest {
|
||||
val newKey = ByteArray(32) { 0x77 }
|
||||
val wraps = rotate(key0, 0, newKey, listOf(alice.pubKey))
|
||||
assertEquals(1, wraps.size)
|
||||
val opened = assertNotNull(ConcordStreamEnvelope.openOrNull(wraps.single(), ConcordChannelRekey.address(root, channelId, 1)))
|
||||
// A rekey seal is encrypted; the seal names the rotator.
|
||||
assertEquals(ConcordStreamEnvelope.KIND_SEAL_ENCRYPTED, opened.sealKind)
|
||||
assertEquals(admin.pubKey, opened.author)
|
||||
val rumor = opened.rumor
|
||||
assertEquals(ConcordRekey.KIND, rumor.kind)
|
||||
// ["scope", channel_id] ["newepoch", held+1] ["prevepoch", held] ["prevcommit", A.5 over the held key] ["chunk","1","1"]
|
||||
assertEquals(
|
||||
listOf(
|
||||
listOf("scope", channelHex),
|
||||
listOf("newepoch", "1"),
|
||||
listOf("prevepoch", "0"),
|
||||
listOf("prevcommit", ConcordKeyDerivation.epochKeyCommitment(0, key0).toHexKey()),
|
||||
listOf("chunk", "1", "1"),
|
||||
),
|
||||
rumor.tags.map { it.toList() },
|
||||
)
|
||||
// Alice's blob opens under the admin<->alice pairwise key to exactly scope ‖ epoch_be ‖ key.
|
||||
val blobs = ConcordRekey.decodeContent(rumor.content)
|
||||
assertEquals(2, blobs.size)
|
||||
val locator = ConcordKeyDerivation.recipientLocator(admin.pubKey.hexToByteArray(), alice.pubKey.hexToByteArray(), channelId, 1).toHexKey()
|
||||
val mine = blobs.single { it.locator == locator }
|
||||
val plain = Base64.Default.decode(Nip44.v2.decrypt(mine.wrapped, Nip44.v2.getConversationKey(alice.keyPair.privKey!!, admin.pubKey.hexToByteArray())))
|
||||
assertEquals(72, plain.size)
|
||||
assertContentEquals(channelId, plain.copyOfRange(0, 32))
|
||||
assertContentEquals(byteArrayOf(0, 0, 0, 0, 0, 0, 0, 1), plain.copyOfRange(32, 40))
|
||||
assertContentEquals(newKey, plain.copyOfRange(40, 72))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aKeptMemberAdoptsTheNewKeyAndARemovedOneIsCut() =
|
||||
runTest {
|
||||
val newKey = ByteArray(32) { 0x42 }
|
||||
val wraps = rotate(key0, 0, newKey, listOf(alice.pubKey))
|
||||
|
||||
val kept = assertIs<ChannelRekeyOutcome.Adopted>(walk(wraps, alice))
|
||||
assertContentEquals(newKey, kept.key)
|
||||
assertEquals(1, kept.epoch)
|
||||
assertEquals(1, kept.steppedOver.size)
|
||||
assertContentEquals(key0, kept.steppedOver.single().key)
|
||||
|
||||
val cut = assertIs<ChannelRekeyOutcome.Removed>(walk(wraps, bob))
|
||||
assertEquals(1, cut.epoch)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun noBlobIsARemovalOnlyFromAnOutrankingRotatorAfterTheJoin() =
|
||||
runTest {
|
||||
val wraps = rotate(key0, 0, ByteArray(32) { 0x42 }, listOf(alice.pubKey))
|
||||
// A rotator that does not strictly outrank us cannot cut us (CORD-06 Authority).
|
||||
assertSame(ChannelRekeyOutcome.None, walk(wraps, bob, outranksMe = { false }))
|
||||
// A rotation that predates our join is history, not an exclusion.
|
||||
assertSame(ChannelRekeyOutcome.None, walk(wraps, bob, joinedAt = now + 1))
|
||||
// An unauthorized rotator is ignored entirely.
|
||||
assertSame(ChannelRekeyOutcome.None, walk(wraps, alice, honored = { false }))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aRotationOffAKeyWeDoNotHoldIsNeitherAdoptedNorARemoval() =
|
||||
runTest {
|
||||
// The rotator claims to extend a different key at our epoch: a fork, never adopted.
|
||||
val forged = rotate(ByteArray(32) { 0x66 }, 0, ByteArray(32) { 0x42 }, listOf(alice.pubKey))
|
||||
assertSame(ChannelRekeyOutcome.None, walk(forged, alice))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aMissedRotationIsWalkedInOnePass() =
|
||||
runTest {
|
||||
val key1 = ByteArray(32) { 0x31 }
|
||||
val key2 = ByteArray(32) { 0x32 }
|
||||
val wraps = rotate(key0, 0, key1, listOf(alice.pubKey)) + rotate(key1, 1, key2, listOf(alice.pubKey), createdAt = now + 10)
|
||||
val adopted = assertIs<ChannelRekeyOutcome.Adopted>(walk(wraps, alice))
|
||||
assertContentEquals(key2, adopted.key)
|
||||
assertEquals(2, adopted.epoch)
|
||||
assertEquals(listOf(1L, 0L), adopted.steppedOver.map { it.epoch })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aReadmissionAboveTheCutWinsAndACutAboveTheKeyWins() =
|
||||
runTest {
|
||||
val key1 = ByteArray(32) { 0x31 }
|
||||
val key2 = ByteArray(32) { 0x32 }
|
||||
// Cut at 1, re-admitted at 2 — but 2 extends key1, which bob never got: no adoption, cut stands.
|
||||
val wraps = rotate(key0, 0, key1, listOf(alice.pubKey)) + rotate(key1, 1, key2, listOf(alice.pubKey, bob.pubKey))
|
||||
assertIs<ChannelRekeyOutcome.Removed>(walk(wraps, bob))
|
||||
// Alice kept through 1 and then cut at 2: the cut above her key wins.
|
||||
val cutLater = rotate(key0, 0, key1, listOf(alice.pubKey)) + rotate(key1, 1, key2, emptyList())
|
||||
assertEquals(2, assertIs<ChannelRekeyOutcome.Removed>(walk(cutLater, alice)).epoch)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun racingRotatorsConvergeOnTheLowestKey() =
|
||||
runTest {
|
||||
val low = ByteArray(32) { 0x01 }
|
||||
val high = ByteArray(32) { 0x7F }
|
||||
val other = NostrSignerInternal(KeyPair())
|
||||
val wraps = rotate(key0, 0, high, listOf(alice.pubKey)) + rotate(key0, 0, low, listOf(alice.pubKey), rotator = other)
|
||||
val rotations = ConcordChannelRekey.rotations(wraps, keysFor(1), channelHex)
|
||||
// Two Rotators at one epoch never merge into one set.
|
||||
assertEquals(2, rotations.size)
|
||||
assertContentEquals(low, assertIs<ChannelRekeyOutcome.Adopted>(walk(wraps, alice)).key)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun anIncompleteRotationIsNeverARemoval() =
|
||||
runTest {
|
||||
// 130 recipients span two chunks; drop the second.
|
||||
val crowd = List(130) { KeyPair().pubKey.toHexKey() }
|
||||
val wraps = rotate(key0, 0, ByteArray(32) { 0x42 }, crowd)
|
||||
assertEquals(2, wraps.size)
|
||||
val rotations = ConcordChannelRekey.rotations(wraps.take(1), keysFor(1), channelHex)
|
||||
assertTrue(rotations.none { it.complete })
|
||||
assertSame(ChannelRekeyOutcome.None, walk(wraps.take(1), bob))
|
||||
assertIs<ChannelRekeyOutcome.Removed>(walk(wraps, bob))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aRotationForAnotherChannelOrAtAnUnwatchedAddressIsIgnored() =
|
||||
runTest {
|
||||
val otherChannel = ByteArray(32) { 0x5D }
|
||||
val elsewhere = ConcordChannelRekey.build(admin, root, otherChannel, key0, 0, ByteArray(32) { 0x42 }, listOf(alice.pubKey), now)
|
||||
// Not at our channel's addresses, and its scope names another channel.
|
||||
assertTrue(ConcordChannelRekey.rotations(elsewhere, keysFor(1), channelHex).isEmpty())
|
||||
val atOurAddress = mapOf(ConcordChannelRekey.address(root, otherChannel, 1).let { it.publicKeyHex to it })
|
||||
assertTrue(ConcordChannelRekey.rotations(elsewhere, atOurAddress, channelHex).isEmpty())
|
||||
}
|
||||
}
|
||||
+84
-8
@@ -21,17 +21,21 @@
|
||||
package com.vitorpamplona.quartz.concord.cord07Voice
|
||||
|
||||
import com.vitorpamplona.quartz.concord.crypto.ConcordKeyDerivation
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
|
||||
import com.vitorpamplona.quartz.nip01Core.crypto.verify
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertNotEquals
|
||||
import kotlin.test.assertNull
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class ConcordVoiceTest {
|
||||
private val alice = KeyPair().pubKey.toHexKey()
|
||||
private val bob = KeyPair().pubKey.toHexKey()
|
||||
private val carol = KeyPair().pubKey.toHexKey()
|
||||
private val channelId = "42".repeat(32)
|
||||
|
||||
@Test
|
||||
@@ -44,20 +48,75 @@ class ConcordVoiceTest {
|
||||
assertEquals(channelId, info?.channelId)
|
||||
assertEquals(0L, info?.epoch)
|
||||
assertTrue(info?.joined == true)
|
||||
// The examples' tag order: channel, epoch, identity, broker, ms.
|
||||
assertEquals(listOf("channel", "epoch", "identity", "broker", "ms"), rumor.tags.map { it[0] })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun onlyUncontestedIdentitiesVerify() {
|
||||
val aliceP = VoicePresence.parse(VoicePresence.joined(alice, channelId, 0, "id-alice", 1L))!!
|
||||
val aliceP = VoicePresence.parse(VoicePresence.joined(alice, channelId, 0, "id-x", 1L))!!
|
||||
val bobP = VoicePresence.parse(VoicePresence.joined(bob, channelId, 0, "id-bob", 1L))!!
|
||||
// both Alice and Bob claim the same identity -> contested
|
||||
val contestedA = VoicePresence.parse(VoicePresence.joined(alice, channelId, 0, "id-x", 1L))!!
|
||||
val contestedB = VoicePresence.parse(VoicePresence.joined(bob, channelId, 0, "id-x", 1L))!!
|
||||
val carolP = VoicePresence.parse(VoicePresence.joined(carol, channelId, 0, "id-x", 1L))!!
|
||||
|
||||
val verified = VoicePresence.verifiedParticipants(listOf(aliceP, bobP, contestedA, contestedB))
|
||||
assertEquals(alice, verified["id-alice"])
|
||||
assertEquals(bob, verified["id-bob"])
|
||||
assertFalse(verified.containsKey("id-x")) // contested identity omitted
|
||||
val fold = VoicePresence.fold(listOf(aliceP, bobP, carolP), nowMs = 1_000L)
|
||||
assertEquals(bob, fold.verified["id-bob"])
|
||||
assertFalse(fold.verified.containsKey("id-x")) // Alice and Carol both claim it: contested
|
||||
assertEquals(3, fold.present.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun latestPresencePerAuthorWins() {
|
||||
// Alice joined as id-a, left, then rejoined as id-b: only her latest counts.
|
||||
val joinedA = VoicePresence.parse(VoicePresence.joined(alice, channelId, 0, "id-a", 10L, subMs = 0))!!
|
||||
val left = VoicePresence.parse(VoicePresence.left(alice, channelId, 0, 20L, subMs = 0))!!
|
||||
val joinedB = VoicePresence.parse(VoicePresence.joined(alice, channelId, 0, "id-b", 30L, subMs = 0))!!
|
||||
// Bob's older heartbeat claimed id-b too, but his latest presence is a left.
|
||||
val bobOld = VoicePresence.parse(VoicePresence.joined(bob, channelId, 0, "id-b", 5L, subMs = 0))!!
|
||||
val bobLeft = VoicePresence.parse(VoicePresence.left(bob, channelId, 0, 6L, subMs = 0))!!
|
||||
|
||||
// Arrival order must not matter.
|
||||
val fold = VoicePresence.fold(listOf(joinedB, bobLeft, joinedA, left, bobOld), nowMs = 31_000L)
|
||||
assertEquals(listOf(alice), fold.present.map { it.author })
|
||||
assertEquals("id-b", fold.present.single().identity)
|
||||
assertEquals(mapOf("id-b" to alice), fold.verified)
|
||||
|
||||
// Before the rejoin arrived, Alice's latest is the left: absent.
|
||||
assertTrue(VoicePresence.fold(listOf(left, joinedA), nowMs = 21_000L).present.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun msTagOrdersPresencesWithinOneSecond() {
|
||||
val early = VoicePresence.parse(VoicePresence.joined(alice, channelId, 0, "id-a", 10L, subMs = 100))!!
|
||||
val late = VoicePresence.parse(VoicePresence.left(alice, channelId, 0, 10L, subMs = 900))!!
|
||||
assertEquals(10_100L, early.ms)
|
||||
assertEquals(10_900L, late.ms)
|
||||
assertTrue(VoicePresence.fold(listOf(late, early), nowMs = 11_000L).present.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun equalTimeTiesBreakByLowerRumorId() {
|
||||
val a = VoicePresence.parse(VoicePresence.joined(alice, channelId, 0, "id-a", 10L, subMs = 0))!!
|
||||
val b = VoicePresence.parse(VoicePresence.joined(alice, channelId, 0, "id-b", 10L, subMs = 0))!!
|
||||
val winner = if (a.rumorId < b.rumorId) a else b
|
||||
assertEquals(winner.rumorId, VoicePresence.latestPerAuthor(listOf(a, b))[alice]?.rumorId)
|
||||
assertEquals(winner.rumorId, VoicePresence.latestPerAuthor(listOf(b, a))[alice]?.rumorId)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun staleLatestJoinedIsAbsent() {
|
||||
val p = VoicePresence.parse(VoicePresence.joined(alice, channelId, 0, "id-a", 10L, subMs = 0))!!
|
||||
assertEquals(1, VoicePresence.fold(listOf(p), nowMs = 10_000L + VoicePresence.STALE_MS).present.size)
|
||||
assertTrue(VoicePresence.fold(listOf(p), nowMs = 10_001L + VoicePresence.STALE_MS).present.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun malformedPresenceIsDropped() {
|
||||
val base = VoicePresence.joined(alice, channelId, 0, "id-a", 10L, subMs = 0)
|
||||
assertNull(VoicePresence.parse(Event(base.id, base.pubKey, base.createdAt, base.kind, base.tags, "here", "")))
|
||||
val noIdentity = base.tags.filterNot { it[0] == VoicePresence.TAG_IDENTITY }.toTypedArray()
|
||||
assertNull(VoicePresence.parse(Event(base.id, base.pubKey, base.createdAt, base.kind, noIdentity, "joined", "")))
|
||||
val badMs = base.tags.map { if (it[0] == "ms") arrayOf("ms", "1000") else it }.toTypedArray()
|
||||
assertNull(VoicePresence.parse(Event(base.id, base.pubKey, base.createdAt, base.kind, badMs, "joined", "")))
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -83,5 +142,22 @@ class ConcordVoiceTest {
|
||||
assertTrue(header.startsWith("Concord "))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun sameSecondBrokerGrantsNeverShareAnId() {
|
||||
// Every member signs with the same voice_key.sk; without the nonce two joiners in one
|
||||
// second build one id and the broker's anti-replay set drops the second (CORD-07 §2).
|
||||
val voiceSigner = ConcordKeyDerivation.voiceSignerKey(ByteArray(32) { 0x5A }, channelId.chunkedToBytes(), epoch = 0)
|
||||
val url = "https://broker.example" + ConcordBrokerToken.wellKnownPath(voiceSigner.publicKeyHex)
|
||||
val a = ConcordBrokerToken.buildAuthEvent(voiceSigner, url, createdAt = 1_700_000_000L)
|
||||
val b = ConcordBrokerToken.buildAuthEvent(voiceSigner, url, createdAt = 1_700_000_000L)
|
||||
|
||||
val nonceOf = { e: Event -> e.tags.firstOrNull { it[0] == ConcordBrokerToken.TAG_NONCE }?.getOrNull(1) }
|
||||
assertTrue(Regex("^[0-9a-f]{64}$").matches(nonceOf(a)!!))
|
||||
assertNotEquals(nonceOf(a), nonceOf(b))
|
||||
assertNotEquals(a.id, b.id)
|
||||
// Tag order matches the reference client: u, method, nonce.
|
||||
assertEquals(listOf("u", "method", "nonce"), a.tags.map { it[0] })
|
||||
}
|
||||
|
||||
private fun String.chunkedToBytes(): ByteArray = ByteArray(length / 2) { substring(it * 2, it * 2 + 2).toInt(16).toByte() }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user