mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-10-06 03:38:23 +00:00
feat(cli): amy concord channel create|privatize|publicize|rekey, channel rekeys in rekey/grant/refound
- `concord channel create COMMUNITY NAME [--private [--role NAME]]`, `channel privatize` (next channel epoch, probed off the wire), `channel publicize`, `channel rekey` (reserved key in concord.json so a re-run re-delivers the same one) — thin assembly over ConcordPrivateChannels. - `concord rekey` also follows every held private channel's rotations (`channel_rekeys`: adopt, or drop + record the cut). - `concord grant` vends the private channels a Grant opens (Direct Invite limited to them) and rotates the ones it closes. - `concord refound` rotates every held private channel to its entitled kept set under the prior root before adopting the new epoch. - `concord accept` re-applies a catch-up to the record as stored at write time; the store keeps `channelCuts`. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PmuzkD5qdqgv6dMeRMoC5N
This commit is contained in:
+7
-3
@@ -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). |
|
||||
| `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,12 +690,12 @@ 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. |
|
||||
|
||||
@@ -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,7 +119,9 @@ 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 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
|
||||
@@ -133,7 +148,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|unban|pins|pin|unpin|refound|dissolve|timer>",
|
||||
help = USAGE,
|
||||
routes =
|
||||
mapOf(
|
||||
@@ -141,6 +156,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) },
|
||||
@@ -729,9 +745,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 +859,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 +891,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 +1070,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
|
||||
}
|
||||
}
|
||||
@@ -544,11 +552,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 +636,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
|
||||
|
||||
+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)
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user