From 9b217a99095d686ba217b459cea6084fcfe0ff42 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 29 Sep 2026 16:13:17 +0000 Subject: [PATCH 1/7] =?UTF-8?q?feat(concord):=20CORD-04=20=C2=A77=20pins?= =?UTF-8?q?=20read/write=20in=20commons?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Read a Channel's Pin List from the session fold (ConcordPinLists.heads with the same floors/snapshot as the community fold), open the sealed form with the held key of its epoch, verify entries with a per-entry-identity cache, hide entries killed by the author's held kind 5 and mark entries behind a newer held Edit as edited, keeping sealedUnavailable distinct from empty. Write pin/unpin/omission/edit-refresh as replace-entire editions chained on the head that was read (ConcordModeration.setPinList), gated on PIN_MESSAGES and the control write key, withheld when the list is unreadable, capped before publishing, never re-forming a private-era sealed list into the public form. The session indexes rumor id -> carrying wrap so a pin proves its message with the original seal and the key of its epoch. Also fixes DeletionIndex.DeletionRequest.compareTo, which compared the publicKey with itself, so on JVM/Android (ConcurrentSkipListMap) anyone's kind 5 matched any author's event id. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01PmuzkD5qdqgv6dMeRMoC5N --- .../commons/actions/ConcordModeration.kt | 29 ++ .../commons/actions/ConcordPinning.kt | 490 ++++++++++++++++++ .../commons/model/AccountConcordActions.kt | 175 +++++++ .../model/concord/ConcordCommunitySession.kt | 89 +++- .../commons/actions/ConcordPinningTest.kt | 416 +++++++++++++++ .../cord04Roles/pins/ConcordPinLists.kt | 10 +- .../concord/cord04Roles/pins/ConcordPins.kt | 19 + .../quartz/nip09Deletions/DeletionIndex.kt | 12 +- .../cord04Roles/pins/ConcordPinsTest.kt | 5 + .../nip09Deletions/DeletionIndexByIdTest.kt | 50 ++ 10 files changed, 1284 insertions(+), 11 deletions(-) create mode 100644 commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordPinning.kt create mode 100644 commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordPinningTest.kt create mode 100644 quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip09Deletions/DeletionIndexByIdTest.kt diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordModeration.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordModeration.kt index 8881ee593b..ec03b534b2 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordModeration.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordModeration.kt @@ -35,6 +35,7 @@ import com.vitorpamplona.quartz.concord.cord04Roles.ControlRootWrap import com.vitorpamplona.quartz.concord.cord04Roles.GrantEntity import com.vitorpamplona.quartz.concord.cord04Roles.MetadataEntity import com.vitorpamplona.quartz.concord.cord04Roles.RoleEntity +import com.vitorpamplona.quartz.concord.cord04Roles.pins.ConcordPins import com.vitorpamplona.quartz.concord.crypto.ConcordKeyDerivation import com.vitorpamplona.quartz.concord.crypto.ControlPlaneKeys import com.vitorpamplona.quartz.concord.envelope.ConcordStreamEnvelope @@ -301,6 +302,34 @@ object ConcordModeration { return roleIds.any { roles[it]?.permissionBits()?.hasAny(ConcordPermissions.STAFF_BITS) == true } } + /** + * Writes [content] — an already-serialized Pin List ([ConcordPins.serializePublic] / + * [ConcordPins.serializeSealed]) — as the next edition of [channelId]'s Pin List (CORD-04 §7, + * vsk 11, at `pins_locator(community_id, channel_id)`), chained onto [head]. + * + * Unlike the other editors this takes the head explicitly rather than re-folding [current]: a + * Pin List is replaced entire, so the edition MUST chain onto exactly the list the caller read + * its entries from (§7 — never build from a list you could not read). [ConcordPinning] is the + * caller that enforces that, the PIN_MESSAGES gate and the caps; this only mints the wrap. + */ + suspend fun setPinList( + actor: NostrSigner, + controlPlane: ControlPlaneKeys, + communityId: ByteArray, + channelId: ByteArray, + head: ControlEdition?, + content: String, + current: List, + createdAt: Long, + citation: AuthorityCitation? = null, + owner: HexKey, + ): Event { + require(content.encodeToByteArray().size <= ConcordPins.MAX_CONTENT_BYTES) { "pin list exceeds ${ConcordPins.MAX_CONTENT_BYTES} bytes" } + val entityId = ConcordKeyDerivation.pinsCoordinate(communityId, channelId) + require(head == null || head.entityIdHex == entityId.toHexKey()) { "head is not this channel's Pin List" } + return wrap(actor, controlPlane, communityId, ControlEntityKind.PIN_LIST, entityId, head, content, current, createdAt, citation, owner) + } + /** * Adds [member] to the banlist, written over the current folded head. Another admin's * concurrent edition at the same version may win the fold (CORD-04 §4); calling this again diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordPinning.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordPinning.kt new file mode 100644 index 0000000000..9f9bf8b85d --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordPinning.kt @@ -0,0 +1,490 @@ +/* + * 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.util.KmpLock +import com.vitorpamplona.amethyst.commons.util.withLock +import com.vitorpamplona.quartz.concord.cord03Channels.ChannelChat +import com.vitorpamplona.quartz.concord.cord04Roles.AuthorityResolver +import com.vitorpamplona.quartz.concord.cord04Roles.ControlEdition +import com.vitorpamplona.quartz.concord.cord04Roles.EntityFloor +import com.vitorpamplona.quartz.concord.cord04Roles.pins.ConcordPinLists +import com.vitorpamplona.quartz.concord.cord04Roles.pins.ConcordPins +import com.vitorpamplona.quartz.concord.cord04Roles.pins.ConcordPins.VerifiedPin +import com.vitorpamplona.quartz.concord.crypto.ControlPlaneKeys +import com.vitorpamplona.quartz.concord.envelope.ConcordStreamEnvelope +import com.vitorpamplona.quartz.concord.envelope.OpenedStreamEvent +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.sha256.sha256 +import kotlinx.serialization.json.JsonObject +import kotlin.random.Random + +/** An Edit (kind 3302) this client holds for a pinned message — the author's newest, by send time. */ +class ConcordLocalEdit( + val rumorId: HexKey, + val author: HexKey, + val content: String, + /** `created_at * 1000 + ms`, comparable with [VerifiedPin.editOrderMs]. */ + val orderMs: Long, +) + +/** One verified pin as a reader shows it (CORD-04 §7). */ +class ConcordPinnedMessage( + val pin: VerifiedPin, + /** The newest words this client can show: a held newer Edit's, else the proof's. */ + val content: String, + /** + * True when the message was revised: the entry carries a proven Edit, or this client holds a + * newer one (§7: a client holding a newer Edit MUST mark the pin edited, never render the + * superseded words as current). + */ + val edited: Boolean, + /** A held Edit newer than the entry's proof — what a curator's refresh would attach. */ + val newerEdit: ConcordLocalEdit?, +) { + val rumorId: HexKey get() = pin.rumorId + val author: HexKey get() = pin.author +} + +/** + * A Channel's Pin List as this client reads it (CORD-04 §7). + * + * [sealedUnavailable] is deliberately distinct from an empty list: the list exists but is sealed + * under an epoch key this client never held. It renders as "unavailable", and no edition may be + * built from it — publishing would silently drop every entry this client cannot see. + */ +class ConcordChannelPins( + val channelIdHex: HexKey, + /** The authorized head edition the list was read from, or null when the Channel has none. */ + val head: ControlEdition?, + /** Verified, un-deleted entries in wire order — the base a write replaces entire. */ + val alive: List, + /** Verified entries their author erased (a held kind 5): hidden now, owed an omitting edition. */ + val killed: List, + /** [alive] for display, newest message first, with held Edits applied. */ + val pins: List, + val sealedUnavailable: Boolean, + /** True when the head's content broke a cap or the format, so it reads as empty. */ + val violating: Boolean, + /** True when the head is the sealed form (`{"epoch","sealed"}`). */ + val sealedForm: Boolean, + /** Entries that failed verification and were dropped alone. */ + val invalidEntries: Int, +) { + val count: Int get() = pins.size + + fun isPinned(rumorId: HexKey): Boolean = alive.any { it.rumorId == rumorId } + + /** 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 } + + companion object { + fun none(channelIdHex: HexKey) = ConcordChannelPins(channelIdHex, null, emptyList(), emptyList(), emptyList(), sealedUnavailable = false, violating = false, sealedForm = false, invalidEntries = 0) + } +} + +/** + * Pin-entry verification memoized by entry identity (§7 Weight: a re-folding client "SHOULD cache + * entry verification by entry identity" — otherwise every fold redoes each signature, MAC and + * decryption). The key is a hash of the channel and the entry's exact bytes, so a cached verdict can + * never be served for a different entry that merely names the same seal. Bounded; failures are + * cached too. + */ +class ConcordPinVerifier( + private val maxEntries: Int = 512, +) { + private val lock = KmpLock() + private val verdicts = LinkedHashMap() + + /** Verification runs actually performed (cache misses) — for tests. */ + var misses: Int = 0 + private set + + fun verify( + entry: JsonObject, + channelIdHex: HexKey, + ): VerifiedPin? { + val key = sha256((channelIdHex + entry.toString()).encodeToByteArray()).toHexKey() + lock.withLock { if (verdicts.containsKey(key)) return verdicts[key] } + val verdict = ConcordPins.verify(entry, channelIdHex) + lock.withLock { + misses++ + verdicts[key] = verdict + while (verdicts.size > maxEntries) verdicts.remove(verdicts.keys.first()) + } + return verdict + } +} + +/** The proof material for pinning one opened message: its original seal and the plane key of its epoch. */ +class ConcordPinSource( + val opened: OpenedStreamEvent, + /** The carrying wrap, as the entry's (unverifiable) locator hint. */ + val wrapId: HexKey?, + /** The Channel's conversation key at the message's epoch — what the disclosure derives from. */ + val conversationKey: ByteArray, +) { + val rumorId: HexKey get() = opened.rumor.id +} + +/** + * What the reader holds about pinned messages from its own Chat Plane view: the kind-5 deletes and + * kind-3302 Edits among [rumors]. The app builds the same lookups off its event store; `amy` and the + * tests build them from the rumors they drained. + */ +class ConcordPinEvidence( + rumors: Collection, +) { + private val deletesByTarget = HashMap>() + private val editsByTarget = HashMap>() + + init { + for (rumor in rumors) { + when (rumor.kind) { + 5 -> rumor.tags.forEach { if (it.size >= 2 && it[0] == "e") deletesByTarget.getOrPut(it[1]) { ArrayList() }.add(rumor) } + ConcordPins.KIND_EDIT -> rumor.tags.firstOrNull { it.size >= 2 && it[0] == "e" }?.let { editsByTarget.getOrPut(it[1]) { ArrayList() }.add(rumor) } + } + } + } + + /** True when a held delete by the pin's proven author names it (§7 Interaction with deletion). */ + fun isKilled(pin: VerifiedPin): Boolean = deletesByTarget[pin.rumorId]?.any { ConcordPins.killedBy(pin, it.pubKey, it.tags) } == true + + /** The author's newest held Edit of the pinned message, or null. */ + fun newestEdit(pin: VerifiedPin): ConcordLocalEdit? = + editsByTarget[pin.rumorId] + ?.filter { it.pubKey == pin.author } + ?.maxWithOrNull(compareBy({ orderMsOf(it) }, { it.id })) + ?.let { ConcordLocalEdit(it.id, it.pubKey, it.content, orderMsOf(it)) } + + private fun orderMsOf(rumor: Event): Long = ChannelChat.orderingMs(rumor) ?: (rumor.createdAt * 1000) +} + +/** Why a pin write did or did not publish. Everything but [PUBLISHED] publishes nothing. */ +enum class ConcordPinOutcome { + PUBLISHED, + ALREADY_PINNED, + NOT_PINNED, + + /** The head already says what the write would say (a duty already done by someone else). */ + NOTHING_TO_DO, + NOT_WRITEABLE, + + /** Neither the owner nor a PIN_MESSAGES holder (a banned holder included). */ + NOT_AUTHORIZED, + + /** No Control Plane write key (`control_root`) at this epoch (CORD-02 §2). */ + NO_WRITE_KEY, + + /** The community or channel has not folded yet: there is no list to build on. */ + NOT_FOLDED, + + /** A Private Channel whose current key this account does not hold: the list cannot be sealed. */ + NO_CHANNEL_KEY, + + /** The current list is sealed under a key this client never held — MUST withhold the write. */ + LIST_UNAVAILABLE, + + /** The message's original wrap (and so its seal) is not held, so it cannot be proven. */ + MESSAGE_UNAVAILABLE, + + /** The entry would not verify (not a message or reply, or not openable at its epoch). */ + UNVERIFIABLE, + TOO_MANY_PINS, + TOO_LARGE, +} + +class ConcordPinWrite( + val outcome: ConcordPinOutcome, + /** The Control Plane wrap to publish when [outcome] is [ConcordPinOutcome.PUBLISHED]. */ + val wrap: Event? = null, + /** The entries the new edition carries. */ + val entries: List = emptyList(), +) { + val published: Boolean get() = outcome == ConcordPinOutcome.PUBLISHED +} + +/** Everything a Pin List write needs, resolved by the caller (the app's session, or `amy`'s drain). */ +class ConcordPinContext( + val actor: NostrSigner, + val controlPlane: ControlPlaneKeys, + val communityId: ByteArray, + val owner: HexKey, + /** The community's current Control Plane editions (for the `vac` citation). */ + val current: List, + val channelIdHex: HexKey, + /** The Channel's folded `private` flag: it alone picks the form a writer uses (§7). */ + val channelIsPrivate: Boolean, + /** The Channel's current plane — a private list is sealed under its key at its epoch. */ + val currentPlane: ChannelPlane?, + /** The list as read from its head: the base every write replaces entire. */ + val pins: ConcordChannelPins, + /** The owner or a PIN_MESSAGES holder, per the fold (hasPermission, so a banned holder is not). */ + val authorized: Boolean, +) + +/** + * CORD-04 §7 Pins at the commons layer: read a Channel's Pin List into verified, deletion-aware, + * edit-aware pins, and write the next edition for pin, unpin, the deletion omission and the Edit + * refresh. Pure — the caller publishes the returned wrap (and, in the app, echoes it into the + * session so the next write chains onto it). + */ +object ConcordPinning { + /** The window a non-pinner witness waits before a duty republish, so simultaneous curators collapse to one. */ + const val DUTY_MIN_DELAY_MS = 3_000L + const val DUTY_MAX_DELAY_MS = 15_000L + + fun dutyDelayMs(random: Random = Random.Default): Long = random.nextLong(DUTY_MIN_DELAY_MS, DUTY_MAX_DELAY_MS + 1) + + /** The authorized head of [channelIdHex]'s Pin List among [editions], or null (the `amy` / one-shot path). */ + fun headFor( + editions: Collection, + communityIdHex: HexKey, + owner: HexKey, + channelIdHex: HexKey, + floors: Map = emptyMap(), + ): ControlEdition? { + val authority = AuthorityResolver.resolve(editions, communityIdHex.hexToByteArray(), owner) + return ConcordPinLists.heads(editions, authority, communityIdHex, listOf(channelIdHex), floors)[channelIdHex] + } + + /** + * Reads [head] as [channelIdHex]'s Pin List: the sealed form opens with [unsealKey] (the + * Channel's conversation key at the named epoch), each entry is verified through [verifier] + * (dropped alone on failure), an entry its author erased ([isKilled]) is hidden at once, and an + * entry behind a held newer Edit ([newestEdit]) is marked edited and shows the newer words. + */ + fun read( + head: ControlEdition?, + channelIdHex: HexKey, + unsealKey: (epoch: Long) -> ByteArray?, + verifier: ConcordPinVerifier = ConcordPinVerifier(), + isKilled: (VerifiedPin) -> Boolean = { false }, + newestEdit: (VerifiedPin) -> ConcordLocalEdit? = { null }, + ): ConcordChannelPins { + if (head == null) return ConcordChannelPins.none(channelIdHex) + val read = ConcordPins.read(head.content, unsealKey) + val alive = ArrayList() + val killed = ArrayList() + var invalid = 0 + val seen = HashSet() + for (entry in read.entries) { + val pin = verifier.verify(entry, channelIdHex) + if (pin == null) { + invalid++ + continue + } + // The recomputed rumor id is the entry's identity, for deduplication too. + if (!seen.add(pin.rumorId)) continue + if (isKilled(pin)) killed.add(pin) else alive.add(pin) + } + val shown = + alive + .map { pin -> + val local = newestEdit(pin)?.takeIf { it.author == pin.author && isNewer(it, pin) } + ConcordPinnedMessage(pin, local?.content ?: pin.content, edited = pin.edited || local != null, newerEdit = local) + }.sortedWith(compareByDescending { it.pin.orderMs }.thenBy { it.rumorId }) + return ConcordChannelPins( + channelIdHex = channelIdHex, + head = head, + alive = alive, + killed = killed, + pins = shown, + sealedUnavailable = read.sealedUnavailable, + violating = read.violating, + sealedForm = ConcordPins.isSealedForm(head.content), + invalidEntries = invalid, + ) + } + + /** True when [edit] is newer than whatever Edit [pin]'s proof already carries. */ + private fun isNewer( + edit: ConcordLocalEdit, + pin: VerifiedPin, + ): Boolean { + if (edit.rumorId == pin.editRumorId) return false + val proven = pin.editOrderMs ?: return true + return edit.orderMs > proven || (edit.orderMs == proven && edit.rumorId > (pin.editRumorId ?: "")) + } + + /** + * Reopens [wrap] on [plane] as the proof source for [rumorId], or null when it does not carry + * exactly that message under the Chat ingest gate. + */ + fun sourceOf( + wrap: Event, + plane: ChannelPlane, + rumorId: HexKey, + ): ConcordPinSource? { + val opened = ConcordStreamEnvelope.openOrNull(wrap, plane.key) ?: return null + val rumor = ChannelChat.acceptOpened(opened, plane.channelIdHex, plane.epoch) ?: return null + if (rumor.id != rumorId) return null + return ConcordPinSource(opened, wrap.id, plane.key.conversationKey) + } + + /** Finds [rumorId] among [wraps] on any of [planes] (the one-shot path, e.g. `amy`). */ + fun sourceFrom( + wraps: Collection, + planes: Collection, + rumorId: HexKey, + ): ConcordPinSource? { + val byAddress = planes.associateBy { it.key.publicKeyHex } + for (wrap in wraps) { + val plane = byAddress[wrap.pubKey] ?: continue + sourceOf(wrap, plane, rumorId)?.let { return it } + } + return null + } + + /** The first reason [ctx] may not write at all, or null. */ + fun refusal(ctx: ConcordPinContext): ConcordPinOutcome? = + when { + !ctx.authorized -> ConcordPinOutcome.NOT_AUTHORIZED + !ctx.controlPlane.canWrite -> ConcordPinOutcome.NO_WRITE_KEY + // §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 + else -> null + } + + /** + * The entries a write starts from. A list sealed in a Channel's private era is never + * mechanically re-formed into the now-public form (§7): its entries are not carried, so a + * post-switch edition can only disclose what a curator pins deliberately. + */ + private fun base(ctx: ConcordPinContext): List = if (!ctx.channelIsPrivate && ctx.pins.sealedForm) emptyList() else ctx.pins.alive + + private suspend fun publish( + ctx: ConcordPinContext, + entries: List, + createdAt: Long, + ): ConcordPinWrite { + if (entries.size > ConcordPins.MAX_ENTRIES) return ConcordPinWrite(ConcordPinOutcome.TOO_MANY_PINS) + val content = + try { + val plane = ctx.currentPlane + if (ctx.channelIsPrivate && plane != null) { + ConcordPins.serializeSealed(entries, plane.key.conversationKey, plane.epoch) + } else { + ConcordPins.serializePublic(entries) + } + } catch (_: ConcordPins.PinListTooLargeException) { + // Every reader would treat an over-cap edition as an empty list: refuse, never publish it. + return ConcordPinWrite(ConcordPinOutcome.TOO_LARGE) + } + val wrap = + ConcordModeration.setPinList( + ctx.actor, + ctx.controlPlane, + ctx.communityId, + ctx.channelIdHex.hexToByteArray(), + ctx.pins.head, + content, + ctx.current, + createdAt, + owner = ctx.owner, + ) + return ConcordPinWrite(ConcordPinOutcome.PUBLISHED, wrap, entries) + } + + /** Pins [source]'s message: its proof entry prepended to the current list, as the next edition. */ + suspend fun pin( + ctx: ConcordPinContext, + source: ConcordPinSource, + createdAt: Long, + ): ConcordPinWrite { + refusal(ctx)?.let { return ConcordPinWrite(it) } + val base = base(ctx) + if (base.any { it.rumorId == source.rumorId }) return ConcordPinWrite(ConcordPinOutcome.ALREADY_PINNED) + if (base.size >= ConcordPins.MAX_ENTRIES) return ConcordPinWrite(ConcordPinOutcome.TOO_MANY_PINS) + val entry = + ConcordPins.buildEntry(source.opened, source.conversationKey, ctx.channelIdHex, source.wrapId) + ?: return ConcordPinWrite(ConcordPinOutcome.UNVERIFIABLE) + return publish(ctx, listOf(entry) + base.map { it.entry }, createdAt) + } + + /** Unpins [rumorId]: the next edition without it (there is no deletion event, §7). */ + suspend fun unpin( + ctx: ConcordPinContext, + rumorId: HexKey, + createdAt: Long, + ): ConcordPinWrite { + refusal(ctx)?.let { return ConcordPinWrite(it) } + val base = base(ctx) + val carried = base.any { it.rumorId == rumorId } || ctx.pins.killed.any { it.rumorId == rumorId } + if (!carried) return ConcordPinWrite(ConcordPinOutcome.NOT_PINNED) + return publish(ctx, base.filter { it.rumorId != rumorId }.map { it.entry }, createdAt) + } + + /** + * The deletion omission (§7): the list without [rumorIds] and without every entry already known + * erased. The pinner publishes it at once when deleting their own pinned message; the result is + * [ConcordPinOutcome.NOTHING_TO_DO] when the head no longer carries any of them. + */ + suspend fun omit( + ctx: ConcordPinContext, + rumorIds: Set, + createdAt: Long, + ): ConcordPinWrite { + refusal(ctx)?.let { return ConcordPinWrite(it) } + val base = base(ctx) + val keep = base.filter { it.rumorId !in rumorIds } + if (keep.size == base.size && ctx.pins.killed.isEmpty()) return ConcordPinWrite(ConcordPinOutcome.NOTHING_TO_DO) + return publish(ctx, keep.map { it.entry }, createdAt) + } + + /** + * Settles what the head owes keyless readers, in one replace-entire write (§7 Edits + deletion): + * drops every erased entry and attaches the newest provable Edit to each entry behind one. + * [editSource] reopens a held Edit's wrap for its proof; an Edit it cannot prove is skipped, and + * only an Edit newer than the entry's is ever attached, so a refresh never reverts one. + * [ConcordPinOutcome.NOTHING_TO_DO] when nothing is owed — the check a delayed witness re-runs + * after its random wait, so simultaneous curators collapse to one publisher. + */ + suspend fun settle( + ctx: ConcordPinContext, + editSource: (ConcordPinnedMessage) -> ConcordPinSource?, + createdAt: Long, + ): ConcordPinWrite { + refusal(ctx)?.let { return ConcordPinWrite(it) } + val base = base(ctx) + var changed = ctx.pins.killed.isNotEmpty() + val shownById = ctx.pins.pins.associateBy { it.rumorId } + val next = + base.map { pin -> + val shown = shownById[pin.rumorId] + val source = if (shown?.newerEdit != null) editSource(shown) else null + if (source == null) { + pin.entry + } else { + val withEdit = ConcordPins.withEdit(pin.entry, source.opened, source.conversationKey, ctx.channelIdHex) + if (withEdit != pin.entry) changed = true + withEdit + } + } + if (!changed) return ConcordPinWrite(ConcordPinOutcome.NOTHING_TO_DO) + return publish(ctx, next, createdAt) + } +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/AccountConcordActions.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/AccountConcordActions.kt index 0ebe09d709..2222acaf76 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/AccountConcordActions.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/AccountConcordActions.kt @@ -21,7 +21,13 @@ package com.vitorpamplona.amethyst.commons.model import com.vitorpamplona.amethyst.commons.actions.ConcordActions +import com.vitorpamplona.amethyst.commons.actions.ConcordChannelPins +import com.vitorpamplona.amethyst.commons.actions.ConcordLocalEdit 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.ConcordPinning import com.vitorpamplona.amethyst.commons.actions.ConcordReceive import com.vitorpamplona.amethyst.commons.actions.ConcordSubscriptionPlanner import com.vitorpamplona.amethyst.commons.model.ConcordInviteResult @@ -43,6 +49,7 @@ 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.ConcordChatEditEvent import com.vitorpamplona.quartz.concord.cord03Channels.concordEpoch import com.vitorpamplona.quartz.concord.cord04Roles.AuthorityResolver import com.vitorpamplona.quartz.concord.cord04Roles.ChannelEntity @@ -92,6 +99,8 @@ import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock /** Name of the default Concord community Admin role minted by "Make admin". */ private const val CONCORD_ADMIN_ROLE = "Admin" @@ -853,6 +862,10 @@ class AccountConcordActions( publishConcordWrap(session.entry, wrap) sent = true } + // Self-erasure outranks curation (CORD-04 §7): the delete hides a pinned entry for tracking + // members at once, but a future member learns of it only through an omitting edition. The + // author knows their own pins, so when they may write pins they publish it immediately. + if (sent) omitDeletedConcordPins(channel.channelId.communityId, channelIdHex, mine.mapTo(HashSet()) { it.id }) return sent } @@ -1220,6 +1233,168 @@ class AccountConcordActions( return true } + // ── 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 + // (deletes hide, newer Edits mark "edited"); the write side is ConcordPinning, gated here on + // PIN_MESSAGES + the control write key and serialized so two quick writes never drop each other. + + /** Serializes pin writes: each replaces the list entire, so two in flight would lose one. */ + private val concordPinMutex = Mutex() + + /** Owner, or a PIN_MESSAGES holder per the fold (hasPermission, so a banned holder is not). Silent: UI gating asks this often. */ + private fun holdsConcordPinBit(session: ConcordCommunitySession): Boolean { + val me = account.signer.pubKey + if (session.entry.owner.equals(me, ignoreCase = true)) return true + return session.state.value + ?.authority + ?.hasPermission(me, ConcordPermissions.PIN_MESSAGES) == true + } + + /** True when this account may write [communityId]'s Pin Lists now: the bit, the control write key, a signer. */ + fun canPinConcord(communityId: String): Boolean { + val session = account.concordSessions.sessionFor(communityId) ?: return false + return account.isWriteable() && holdsConcordPinBit(session) && session.controlPlaneKeys().canWrite + } + + /** + * [channelIdHex]'s verified pins, read from the current head: sealed lists open with the held + * key of their epoch (else [ConcordChannelPins.sealedUnavailable]), an entry its author deleted + * is hidden by the delete this account holds for the recomputed rumor id, and an entry behind a + * newer held Edit is marked edited. Null until the community has folded the channel. + */ + fun concordChannelPins( + communityId: String, + channelIdHex: String, + ): ConcordChannelPins? { + val session = account.concordSessions.sessionFor(communityId) ?: return null + return session.readPins( + channelIdHex, + isKilled = { account.cache.deletionIndex.hasBeenDeleted(it.rumorId, it.author) }, + newestEdit = { heldConcordEdit(it.rumorId, it.author) }, + ) + } + + /** The author's newest Concord Edit this account holds for [rumorId], or null. */ + private fun heldConcordEdit( + rumorId: HexKey, + author: HexKey, + ): ConcordLocalEdit? { + val edit = + account.cache + .getNoteIfExists(rumorId) + ?.latestConcordEdit() + ?.event as? ConcordChatEditEvent ?: return null + if (edit.pubKey != author) return null + return ConcordLocalEdit(edit.id, edit.pubKey, edit.content, edit.orderingMs()) + } + + /** + * For the message action sheet: null when [note] is not a pinnable Concord message or this + * account cannot write pins there; else whether it is pinned now. + */ + fun concordPinState(note: Note): Boolean? { + val event = note.event ?: return null + if (event !is ChatEvent && event !is CommentEvent) return null + val channel = note.inGatherers?.firstNotNullOfOrNull { it as? ConcordChannel } ?: return null + if (!canPinConcord(channel.channelId.communityId)) return null + val pins = concordChannelPins(channel.channelId.communityId, channel.channelId.channelId) ?: return null + return pins.isPinned(note.idHex) + } + + /** + * 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. + */ + private suspend fun writeConcordPins( + communityId: String, + channelIdHex: String, + op: suspend (ConcordCommunitySession, ConcordPinContext) -> ConcordPinWrite, + ): ConcordPinOutcome = + concordPinMutex.withLock { + if (!account.isWriteable()) return@withLock ConcordPinOutcome.NOT_WRITEABLE + val session = account.concordSessions.sessionFor(communityId) ?: return@withLock ConcordPinOutcome.NOT_FOLDED + val definition = + session.state.value + ?.channels + ?.get(channelIdHex) + ?.definition ?: return@withLock ConcordPinOutcome.NOT_FOLDED + val pins = concordChannelPins(communityId, channelIdHex) ?: return@withLock ConcordPinOutcome.NOT_FOLDED + val ctx = + ConcordPinContext( + actor = account.signer, + controlPlane = session.controlPlaneKeys(), + communityId = communityId.hexToByteArray(), + owner = session.entry.owner, + current = session.controlEditions(), + channelIdHex = channelIdHex, + channelIsPrivate = definition.private, + currentPlane = session.currentChannelPlane(channelIdHex), + pins = pins, + authorized = holdsConcordPinBit(session), + ) + val write = op(session, ctx) + write.wrap?.let { publishConcordWrap(session.entry, it) } + write.outcome + } + + /** Pin Concord message [note] into its channel's Pin List, proving it with its original seal. */ + suspend fun pinConcordMessage(note: Note): ConcordPinOutcome { + val channel = note.inGatherers?.firstNotNullOfOrNull { it as? ConcordChannel } ?: return ConcordPinOutcome.NOT_FOLDED + val channelIdHex = channel.channelId.channelId + return writeConcordPins(channel.channelId.communityId, channelIdHex) { session, ctx -> + val refused = ConcordPinning.refusal(ctx) + val source = if (refused == null) session.pinSource(channelIdHex, note.idHex) else null + when { + refused != null -> ConcordPinWrite(refused) + source == null -> ConcordPinWrite(ConcordPinOutcome.MESSAGE_UNAVAILABLE) + else -> ConcordPinning.pin(ctx, source, TimeUtils.now()) + } + } + } + + /** Unpin Concord message [note]. */ + suspend fun unpinConcordMessage(note: Note): ConcordPinOutcome { + val channel = note.inGatherers?.firstNotNullOfOrNull { it as? ConcordChannel } ?: return ConcordPinOutcome.NOT_FOLDED + return unpinConcordRumor(channel.channelId.communityId, channel.channelId.channelId, note.idHex) + } + + /** Unpin the entry whose recomputed rumor id is [rumorId] — works for a pin whose message this account never held. */ + suspend fun unpinConcordRumor( + communityId: String, + channelIdHex: String, + rumorId: HexKey, + ): ConcordPinOutcome = writeConcordPins(communityId, channelIdHex) { _, ctx -> ConcordPinning.unpin(ctx, rumorId, TimeUtils.now()) } + + /** The pinner-style deletion omission: the list without [rumorIds], published now when this account may write pins. */ + private suspend fun omitDeletedConcordPins( + communityId: String, + channelIdHex: String, + rumorIds: Set, + ) { + if (!canPinConcord(communityId)) return + val pins = concordChannelPins(communityId, channelIdHex) ?: return + if (pins.alive.none { it.rumorId in rumorIds } && pins.killed.none { it.rumorId in rumorIds }) return + writeConcordPins(communityId, channelIdHex) { _, ctx -> ConcordPinning.omit(ctx, rumorIds, TimeUtils.now()) } + } + + /** + * Settle what [channelIdHex]'s head owes keyless readers (CORD-04 §7): drop entries their author + * erased and attach the newest provable Edit to entries behind one. The caller waits + * [ConcordPinning.dutyDelayMs] first; this re-reads the head and publishes only if it is still + * owed, so simultaneous curators collapse to one publisher and a burst of edits costs one write. + */ + suspend fun settleConcordPins( + communityId: String, + channelIdHex: String, + ): ConcordPinOutcome { + if (!canPinConcord(communityId)) return ConcordPinOutcome.NOT_AUTHORIZED + if (concordChannelPins(communityId, channelIdHex)?.owesRepublish != true) return ConcordPinOutcome.NOTHING_TO_DO + return writeConcordPins(communityId, channelIdHex) { session, ctx -> + ConcordPinning.settle(ctx, { pinned -> pinned.newerEdit?.let { session.pinSource(channelIdHex, it.rumorId) } }, TimeUtils.now()) + } + } + // ── 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 diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordCommunitySession.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordCommunitySession.kt index 864f4e808d..585f14aaf0 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordCommunitySession.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordCommunitySession.kt @@ -22,6 +22,11 @@ package com.vitorpamplona.amethyst.commons.model.concord import com.vitorpamplona.amethyst.commons.actions.ChannelPlane import com.vitorpamplona.amethyst.commons.actions.ConcordActions +import com.vitorpamplona.amethyst.commons.actions.ConcordChannelPins +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.util.KmpLock import com.vitorpamplona.amethyst.commons.util.withLock import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEntry @@ -32,6 +37,8 @@ import com.vitorpamplona.quartz.concord.cord03Channels.ChannelChat import com.vitorpamplona.quartz.concord.cord04Roles.ControlEdition import com.vitorpamplona.quartz.concord.cord04Roles.EditionFold import com.vitorpamplona.quartz.concord.cord04Roles.EntityFloor +import com.vitorpamplona.quartz.concord.cord04Roles.pins.ConcordPinLists +import com.vitorpamplona.quartz.concord.cord04Roles.pins.ConcordPins import com.vitorpamplona.quartz.concord.cord06Rekey.ConcordRefounding import com.vitorpamplona.quartz.concord.crypto.ControlPlaneKeys import com.vitorpamplona.quartz.concord.crypto.GroupKey @@ -250,6 +257,14 @@ class ConcordCommunitySession( private val historicalControlWraps = HashMap>() private val channelWrapsById = HashMap>() // channelIdHex -> (wrapId -> wrap) + + // Chat rumor id -> the id of the wrap that carried it, filled as each wrap is emitted. A pin + // proves its message with the ORIGINAL kind-20013 seal (CORD-04 §7), which only the wrap holds, + // so pinning reopens that wrap rather than re-deriving anything from the stored rumor. + private val wrapIdByRumorId = HashMap() + + /** Pin-entry verdicts memoized by entry identity (CORD-04 §7 Weight). */ + private val pinVerifier = ConcordPinVerifier() private val guestbookWraps = LinkedHashMap() private val baseRekeyWraps = LinkedHashMap() private val siblingRekeyWraps = LinkedHashMap() @@ -272,6 +287,15 @@ class ConcordCommunitySession( private val _state = MutableStateFlow(null) val state: StateFlow = _state + private val _pinHeads = MutableStateFlow>(emptyMap()) + + /** + * The authorized head of each folded Channel's Pin List (CORD-04 §7), by channel id, re-derived + * on every control fold. Kept apart from [state] because a pin edition changes no field of the + * folded community, so [state] would not re-emit for it. + */ + val pinHeads: StateFlow> = _pinHeads + private val _members = MutableStateFlow>(emptySet()) /** The live Guestbook membership set (self-signed joins minus later leaves). */ @@ -660,13 +684,9 @@ class ConcordCommunitySession( val newChannels = lock.withLock { val wraps = controlWraps.values.toList() - val folded = - ConcordCommunityState.fold( - editionsLocked(wraps, controlKeys), - communityIdBytes, - entry.owner, - controlFloorsLocked(), - ) + val editions = editionsLocked(wraps, controlKeys) + val floors = controlFloorsLocked() + val folded = ConcordCommunityState.fold(editions, communityIdBytes, entry.owner, floors) val prevAddresses = channelKeysByAddress.keys.toHashSet() val next = HashMap() @@ -687,6 +707,7 @@ class ConcordCommunitySession( derivedPrivateKeys = privateKeySet(entry) _state.value = folded.withDissolved(dissolved) + _pinHeads.value = ConcordPinLists.heads(editions, folded.authority, entry.id, folded.channels.keys, floors) next.filterKeys { it !in prevAddresses }.values.map { it.channelIdHex } } @@ -776,7 +797,9 @@ class ConcordCommunitySession( seenOnRelays: Set = emptySet(), ) { val authors = HashSet() - ConcordActions.channelRumors(wraps, key, channelIdHex, epoch).forEach { rumor -> + for (wrap in wraps) { + val rumor = ConcordActions.openChannelRumor(wrap, key, channelIdHex, epoch) ?: continue + lock.withLock { wrapIdByRumorId[rumor.id] = wrap.id } authors.add(rumor.pubKey.lowercase()) onRumor(entry.id, channelIdHex, rumor, seenOnRelays) } @@ -787,6 +810,56 @@ class ConcordCommunitySession( } } + // ---- Pins (CORD-04 §7) ------------------------------------------------------------------ + + /** + * The Channel's conversation key at [epoch] for opening a sealed Pin List, or null when this + * account holds no plane of [channelIdHex] bound to that epoch. + */ + fun pinUnsealKey( + channelIdHex: HexKey, + epoch: Long, + ): ByteArray? = channelPlaneFor(channelIdHex, epoch)?.key?.conversationKey + + /** + * [channelIdHex]'s Pin List read from its current head: sealed form opened with the held key of + * the named epoch, entries verified (memoized), [isKilled] entries hidden, [newestEdit] applied. + * Null until the Control Plane has folded, so an unfolded community is never mistaken for one + * with no pins. + */ + fun readPins( + channelIdHex: HexKey, + isKilled: (ConcordPins.VerifiedPin) -> Boolean = { false }, + newestEdit: (ConcordPins.VerifiedPin) -> ConcordLocalEdit? = { null }, + ): ConcordChannelPins? { + val state = _state.value ?: return null + if (channelIdHex !in state.channels) return null + return ConcordPinning.read(_pinHeads.value[channelIdHex], channelIdHex, { pinUnsealKey(channelIdHex, it) }, pinVerifier, isKilled, newestEdit) + } + + /** + * The proof source for pinning [rumorId] of [channelIdHex] (or for attaching an Edit): the wrap + * that carried it, reopened on the plane it arrived on so the disclosure derives from the key of + * the message's own epoch. Null when this session never held that wrap. + */ + fun pinSource( + channelIdHex: HexKey, + rumorId: HexKey, + ): ConcordPinSource? { + val (wrap, plane) = + lock.withLock { + val wrapId = wrapIdByRumorId[rumorId] ?: return null + val wrap = channelWrapsById[channelIdHex]?.get(wrapId) ?: return null + val plane = channelKeysByAddress[wrap.pubKey] ?: historicalChannelKeysByAddress[wrap.pubKey] ?: return null + wrap to plane + } + if (plane.channelIdHex != channelIdHex) return null + return ConcordPinning.sourceOf(wrap, plane, rumorId) + } + + /** True when this session holds the wrap that carried [rumorId] (jump-to-context resolves locally). */ + fun holdsRumor(rumorId: HexKey): Boolean = lock.withLock { rumorId in wrapIdByRumorId } + companion object { private fun privateKeySet(e: ConcordCommunityListEntry) = e.privateChannels.mapTo(HashSet()) { Triple(it.channelId.lowercase(), it.key.lowercase(), it.epoch) } diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordPinningTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordPinningTest.kt new file mode 100644 index 0000000000..fb72bcc3f4 --- /dev/null +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordPinningTest.kt @@ -0,0 +1,416 @@ +/* + * 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.quartz.concord.cord02Community.ConcordCommunityFactory +import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEntry +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.cord04Roles.ControlEntityKind +import com.vitorpamplona.quartz.concord.cord04Roles.pins.ConcordPins +import com.vitorpamplona.quartz.concord.crypto.ConcordKeyDerivation +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 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 + +/** CORD-04 §7 Pins end to end at the commons layer: build → publish → fold → verify. */ +class ConcordPinningTest { + private val owner = NostrSignerInternal(KeyPair()) + private val alice = NostrSignerInternal(KeyPair()) + private val stranger = NostrSignerInternal(KeyPair()) + private val secretId = ByteArray(32) { 0x5C } + private val secretIdHex = secretId.toHexKey() + private val channelKey = ByteArray(32) { 0x3C } + private val channelEpoch = 3L + + private fun entryFor( + community: NewConcordCommunity, + privateChannels: List = emptyList(), + ) = 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(), + privateChannels = privateChannels, + relays = listOf("wss://r.example"), + name = "Nostrichs", + ) + + /** A session plus every rumor it emitted, which is the evidence (deletes, edits) a reader holds. */ + private class Harness( + val community: NewConcordCommunity, + entry: ConcordCommunityListEntry, + me: HexKey, + ) { + val rumors = mutableListOf() + val session = ConcordCommunitySession(entry, me) { _, _, rumor, _ -> rumors += rumor } + + fun pins(channelIdHex: HexKey) = ConcordPinEvidence(rumors).let { evidence -> assertNotNull(session.readPins(channelIdHex, evidence::isKilled, evidence::newestEdit)) } + + fun ctx( + actor: NostrSigner, + channelIdHex: HexKey, + authorized: Boolean = true, + ): ConcordPinContext { + val state = session.state.value!! + return ConcordPinContext( + actor = actor, + controlPlane = session.controlPlaneKeys(), + communityId = community.communityId, + owner = community.ownerPubKey, + current = session.controlEditions(), + channelIdHex = channelIdHex, + channelIsPrivate = state.channels[channelIdHex]!!.definition.private, + currentPlane = session.currentChannelPlane(channelIdHex), + pins = pins(channelIdHex), + authorized = authorized, + ) + } + + suspend fun post( + author: NostrSigner, + channelIdHex: HexKey, + text: String, + createdAt: Long, + ): Event { + val plane = session.currentChannelPlane(channelIdHex)!! + val wrap = ConcordActions.buildChannelMessage(author, plane.key, channelIdHex, plane.epoch, text, createdAt) + session.ingest(wrap) + return rumors.last() + } + + suspend fun pin( + actor: NostrSigner, + channelIdHex: HexKey, + rumor: Event, + createdAt: Long, + ): ConcordPinWrite { + val write = ConcordPinning.pin(ctx(actor, channelIdHex), assertNotNull(session.pinSource(channelIdHex, rumor.id)), createdAt) + write.wrap?.let { session.ingest(it) } + return write + } + } + + private suspend fun harness(withPrivate: Boolean = false): Harness { + val community = ConcordCommunityFactory.create(owner, "Nostrichs", createdAt = 1L, relays = listOf("wss://r.example")) + val keys = if (withPrivate) listOf(PrivateChannelKey(secretIdHex, channelKey.toHexKey(), channelEpoch, "secret")) else emptyList() + val h = Harness(community, entryFor(community, keys), owner.pubKey) + community.genesisWraps.forEach { h.session.ingest(it) } + if (withPrivate) { + h.session.ingest( + ConcordModeration.defineChannel(owner, community.controlPlane, community.communityId, secretId, ChannelEntity(name = "secret", private = true), h.session.controlEditions(), 2L, owner = community.ownerPubKey), + ) + } + return h + } + + @Test + fun aPinRoundTripsThroughTheControlPlaneAndUnpinRemovesIt() = + runTest { + val h = harness() + val general = h.community.generalChannelIdHex + assertEquals(0, h.pins(general).count) + assertNull(h.pins(general).head) + + val message = h.post(alice, general, "ship it", 10L) + val write = h.pin(owner, general, message, 11L) + assertEquals(ConcordPinOutcome.PUBLISHED, write.outcome) + + // The edition is a vsk-11 Pin List at pins_locator(community, channel), chained from genesis. + val head = assertNotNull(h.session.pinHeads.value[general]) + assertEquals(ControlEntityKind.PIN_LIST, head.entityKind) + assertEquals(ConcordKeyDerivation.pinsCoordinate(h.community.communityId, general.hexToByteArray()).toHexKey(), head.entityIdHex) + assertEquals(1L, head.version) + assertFalse(ConcordPins.isSealedForm(head.content), "a public channel's list is plaintext") + + val pins = h.pins(general) + assertEquals(1, pins.count) + assertEquals(message.id, pins.pins.single().rumorId) + assertEquals(alice.pubKey, pins.pins.single().author) + assertEquals("ship it", pins.pins.single().content) + assertTrue(h.session.holdsRumor(message.id), "the wrap hint resolves locally, so the row can jump") + + assertEquals(ConcordPinOutcome.ALREADY_PINNED, ConcordPinning.pin(h.ctx(owner, general), h.session.pinSource(general, message.id)!!, 12L).outcome) + + val unpin = ConcordPinning.unpin(h.ctx(owner, general), message.id, 13L) + assertEquals(ConcordPinOutcome.PUBLISHED, unpin.outcome) + h.session.ingest(unpin.wrap!!) + assertEquals( + 2L, + h.session.pinHeads.value[general]!! + .version, + "unpinning is the next edition, not a deletion", + ) + assertEquals(0, h.pins(general).count) + assertEquals(ConcordPinOutcome.NOT_PINNED, ConcordPinning.unpin(h.ctx(owner, general), message.id, 14L).outcome) + } + + @Test + fun theWrapHintPointsAtTheCarryingWrap() = + runTest { + val h = harness() + val general = h.community.generalChannelIdHex + val plane = h.session.currentChannelPlane(general)!! + val wrap = ConcordActions.buildChannelMessage(alice, plane.key, general, plane.epoch, "hint", 10L) + h.session.ingest(wrap) + h.pin(owner, general, h.rumors.last(), 11L) + assertEquals( + wrap.id, + h + .pins(general) + .pins + .single() + .pin.wrapHint, + ) + } + + @Test + fun aNonPinMessagesAuthorsEditionIsIgnored() = + runTest { + val h = harness() + val general = h.community.generalChannelIdHex + val message = h.post(alice, general, "legit", 10L) + h.pin(owner, general, message, 11L) + + // A stranger who somehow holds the write key mints a newer edition emptying the list. + val rogue = ConcordModeration.setPinList(stranger, h.session.controlPlaneKeys(), h.community.communityId, general.hexToByteArray(), h.session.pinHeads.value[general], ConcordPins.serializePublic(emptyList()), h.session.controlEditions(), 12L, owner = h.community.ownerPubKey) + h.session.ingest(rogue) + assertEquals(1, h.pins(general).count, "the fold gates Pin Lists on PIN_MESSAGES") + assertEquals( + owner.pubKey, + h.session.pinHeads.value[general]!! + .author, + ) + + // And the verb refuses outright for an unauthorized actor. + val refused = ConcordPinning.pin(h.ctx(stranger, general, authorized = false), h.session.pinSource(general, message.id)!!, 13L) + assertEquals(ConcordPinOutcome.NOT_AUTHORIZED, refused.outcome) + assertNull(refused.wrap) + } + + @Test + fun aPrivateChannelsListIsSealedAndUnavailableWithoutTheKey() = + runTest { + val h = harness(withPrivate = true) + val message = h.post(alice, secretIdHex, "for members", 10L) + assertEquals(ConcordPinOutcome.PUBLISHED, h.pin(owner, secretIdHex, message, 11L).outcome) + val head = h.session.pinHeads.value[secretIdHex]!! + assertTrue(ConcordPins.isSealedForm(head.content), "the writer uses the form of the channel's folded type") + assertFalse(head.content.contains("for members")) + assertEquals( + "for members", + h + .pins(secretIdHex) + .pins + .single() + .content, + ) + + // A client of this community without the channel key (here the owner's other device). + val keylessHarness = Harness(h.community, entryFor(h.community), owner.pubKey) + h.session.controlPlaneWraps().forEach { keylessHarness.session.ingest(it) } + val dark = keylessHarness.pins(secretIdHex) + assertTrue(dark.sealedUnavailable, "unreadable, not empty") + assertEquals(0, dark.count) + assertNotNull(dark.head) + + // MUST withhold the write: even an unpin of nothing would drop every sealed entry. + val withheld = ConcordPinning.unpin(keylessHarness.ctx(owner, secretIdHex), message.id, 12L) + assertEquals(ConcordPinOutcome.LIST_UNAVAILABLE, withheld.outcome) + assertNull(withheld.wrap) + assertEquals(ConcordPinOutcome.LIST_UNAVAILABLE, ConcordPinning.omit(keylessHarness.ctx(owner, secretIdHex), setOf(message.id), 12L).outcome) + } + + @Test + fun aPrivateToPublicSwitchNeverReformsTheSealedList() = + runTest { + val h = harness(withPrivate = true) + val secretMessage = h.post(alice, secretIdHex, "private era", 10L) + h.pin(owner, secretIdHex, secretMessage, 11L) + + // The channel turns public. + h.session.ingest( + ConcordModeration.defineChannel(owner, h.community.controlPlane, h.community.communityId, secretId, ChannelEntity(name = "secret", private = false), h.session.controlEditions(), 12L, owner = h.community.ownerPubKey), + ) + assertFalse( + h.session.state.value!! + .channels[secretIdHex]!! + .definition.private, + ) + // Still readable (the key is held) — a reader accepts either form. + assertEquals(1, h.pins(secretIdHex).count) + + val publicMessage = h.post(alice, secretIdHex, "public era", 13L) + assertEquals(ConcordPinOutcome.PUBLISHED, h.pin(owner, secretIdHex, publicMessage, 14L).outcome) + val head = h.session.pinHeads.value[secretIdHex]!! + assertFalse(ConcordPins.isSealedForm(head.content)) + assertFalse(head.content.contains(secretMessage.id), "the private-era pin is not republished to everyone") + assertEquals(listOf(publicMessage.id), h.pins(secretIdHex).pins.map { it.rumorId }) + } + + @Test + fun capsRefuseBeforePublishing() = + runTest { + val h = harness() + val general = h.community.generalChannelIdHex + repeat(ConcordPins.MAX_ENTRIES) { i -> + val m = h.post(alice, general, "pin number $i", 100L + i) + assertEquals(ConcordPinOutcome.PUBLISHED, h.pin(owner, general, m, 200L + i).outcome, "pin $i") + } + assertEquals(ConcordPins.MAX_ENTRIES, h.pins(general).count) + val overflow = h.post(alice, general, "one too many", 300L) + val refused = ConcordPinning.pin(h.ctx(owner, general), h.session.pinSource(general, overflow.id)!!, 301L) + assertEquals(ConcordPinOutcome.TOO_MANY_PINS, refused.outcome) + assertNull(refused.wrap) + } + + @Test + fun aSealedListHitsTheByteCapBeforeTheEntryCap() = + runTest { + val h = harness(withPrivate = true) + var outcome = ConcordPinOutcome.PUBLISHED + var pinned = 0 + while (outcome == ConcordPinOutcome.PUBLISHED) { + val m = h.post(alice, secretIdHex, "a typical pinned announcement of about a hundred and thirty five characters, give or take, number $pinned", 100L + pinned) + outcome = h.pin(owner, secretIdHex, m, 200L + pinned).outcome + if (outcome == ConcordPinOutcome.PUBLISHED) pinned++ + } + assertEquals(ConcordPinOutcome.TOO_LARGE, outcome) + assertTrue(pinned in 10 until ConcordPins.MAX_ENTRIES, "the byte cap governs a sealed list (pinned $pinned)") + assertEquals(pinned, h.pins(secretIdHex).count, "the refused write published nothing") + } + + @Test + fun theAuthorsDeleteHidesThePinAndTheOmissionDropsIt() = + runTest { + val h = harness() + val general = h.community.generalChannelIdHex + val keep = h.post(alice, general, "keep", 10L) + val oops = h.post(alice, general, "oops", 11L) + h.pin(owner, general, keep, 12L) + h.pin(owner, general, oops, 13L) + + // Someone else's delete of alice's message does nothing. + val plane = h.session.currentChannelPlane(general)!! + h.session.ingest(ConcordActions.buildChannelDelete(stranger, plane.key, general, plane.epoch, listOf(oops), 14L)) + assertEquals(2, h.pins(general).count) + + h.session.ingest(ConcordActions.buildChannelDelete(alice, plane.key, general, plane.epoch, listOf(oops), 15L)) + val read = h.pins(general) + assertEquals(listOf(keep.id), read.pins.map { it.rumorId }, "a held delete hides the entry immediately") + assertEquals(listOf(oops.id), read.killed.map { it.rumorId }) + assertTrue(read.owesRepublish) + + // The duty write drops it from the head; after that nothing is owed. + val settled = ConcordPinning.settle(h.ctx(owner, general), { null }, 16L) + assertEquals(ConcordPinOutcome.PUBLISHED, settled.outcome) + h.session.ingest(settled.wrap!!) + assertFalse( + h.session.pinHeads.value[general]!! + .content + .contains(oops.id), + ) + assertFalse(h.pins(general).owesRepublish) + assertEquals(ConcordPinOutcome.NOTHING_TO_DO, ConcordPinning.settle(h.ctx(owner, general), { null }, 17L).outcome) + } + + @Test + fun thePinnersOmissionPublishesAtOnce() = + runTest { + val h = harness() + val general = h.community.generalChannelIdHex + val mine = h.post(owner, general, "my announcement", 10L) + h.pin(owner, general, mine, 11L) + val omitted = ConcordPinning.omit(h.ctx(owner, general), setOf(mine.id), 12L) + assertEquals(ConcordPinOutcome.PUBLISHED, omitted.outcome) + h.session.ingest(omitted.wrap!!) + assertEquals(0, h.pins(general).count) + assertEquals(ConcordPinOutcome.NOTHING_TO_DO, ConcordPinning.omit(h.ctx(owner, general), setOf(mine.id), 13L).outcome) + } + + @Test + fun aNewerHeldEditMarksThePinEditedAndTheRefreshAttachesItsProof() = + runTest { + val h = harness() + val general = h.community.generalChannelIdHex + val original = h.post(alice, general, "teh plan", 10L) + h.pin(owner, general, original, 11L) + assertFalse( + h + .pins(general) + .pins + .single() + .edited, + ) + + val plane = h.session.currentChannelPlane(general)!! + h.session.ingest(ConcordActions.buildChannelEdit(alice, plane.key, general, plane.epoch, original, "the plan", 12L)) + val edit = h.rumors.last() + // A forged edit by someone else never counts. + h.session.ingest(ConcordActions.buildChannelEdit(stranger, plane.key, general, plane.epoch, original, "pwned", 13L)) + + val shown = h.pins(general).pins.single() + assertTrue(shown.edited, "a client holding a newer Edit MUST mark the pin edited") + assertEquals("the plan", shown.content) + assertEquals(edit.id, shown.newerEdit?.rumorId) + assertFalse(shown.pin.edited, "the proof itself still carries the original words") + + val refreshed = ConcordPinning.settle(h.ctx(owner, general), { h.session.pinSource(general, it.newerEdit!!.rumorId) }, 14L) + assertEquals(ConcordPinOutcome.PUBLISHED, refreshed.outcome) + h.session.ingest(refreshed.wrap!!) + val after = h.pins(general).pins.single() + assertTrue(after.pin.edited, "the proof now carries the Edit for keyless readers") + assertEquals("the plan", after.pin.content) + assertNull(after.newerEdit, "nothing newer is owed") + assertFalse(h.pins(general).owesRepublish) + } + + @Test + fun verificationIsCachedByEntryIdentity() = + runTest { + val verifier = ConcordPinVerifier() + val h = harness() + val general = h.community.generalChannelIdHex + h.pin(owner, general, h.post(alice, general, "one", 10L), 11L) + h.pin(owner, general, h.post(alice, general, "two", 12L), 13L) + val head = h.session.pinHeads.value[general] + ConcordPinning.read(head, general, { null }, verifier) + assertEquals(2, verifier.misses) + ConcordPinning.read(head, general, { null }, verifier) + assertEquals(2, verifier.misses, "a re-read redoes no signature, MAC or decryption") + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/pins/ConcordPinLists.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/pins/ConcordPinLists.kt index 2c549b9cc5..23a9a23c3f 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/pins/ConcordPinLists.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/pins/ConcordPinLists.kt @@ -55,10 +55,16 @@ object ConcordPinLists { if (channelIds.isEmpty()) return emptyMap() val channelByCoordinate = channelIds.associateBy { coordinate(communityIdHex, it) } val lists = editions.filter { it.entityKind == ControlEntityKind.PIN_LIST && it.entityIdHex in channelByCoordinate } - if (lists.isEmpty()) return emptyMap() + val pinFloors = floors.filterKeys { it in channelByCoordinate } + if (lists.isEmpty() && pinFloors.isEmpty()) return emptyMap() + // The same anti-rollback treatment the community fold gives every other entity: the editions + // handed in are one epoch's, so they are the compaction snapshot, and a list that cannot + // connect to its floor keeps the head we already folded rather than jumping. + val snapshot = editions.mapTo(HashSet(editions.size)) { it.rumorId } + val pool = EditionFold.admissible(lists, pinFloors, snapshot = snapshot) return EditionFold // Same gate every other entity folds under: well-formed, owner or a PIN_MESSAGES holder, vac satisfied. - .foldGated(lists, floors, rank = authority::tieBreakRank) { authority.admits(it, ConcordPermissions.PIN_MESSAGES) } + .foldGated(pool, pinFloors, snapshot = snapshot, rank = authority::tieBreakRank) { authority.admits(it, ConcordPermissions.PIN_MESSAGES) } .mapNotNull { (coordinate, head) -> channelByCoordinate[coordinate]?.let { it to head } } .toMap() } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/pins/ConcordPins.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/pins/ConcordPins.kt index bbea36f329..316dc9aa9a 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/pins/ConcordPins.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/pins/ConcordPins.kt @@ -86,6 +86,12 @@ object ConcordPins { val edited: Boolean, /** The proven Edit's rumor id, when one verified. */ val editRumorId: HexKey?, + /** + * The proven Edit's own send time (`created_at * 1000 + ms`), when one verified. A client + * holding an Edit newer than this MUST mark the pin edited (§7 Edits), and a refresh only + * ever attaches something newer, or it would silently revert the entry. + */ + val editOrderMs: Long?, /** The wire entry, verbatim, for republishing. */ val entry: JsonObject, ) @@ -152,6 +158,18 @@ object ConcordPins { return PinListRead(array.mapNotNull { it as? JsonObject }, sealedUnavailable = false, violating = false) } + /** + * True when [content] is the self-describing **sealed** form (`{"epoch", "sealed"}`), false for + * the public `{"entries"}` form or anything unreadable. A writer needs it to honor the + * private→public rule (§7): a list sealed in a Channel's private era is never mechanically + * re-formed into the public form, which would disclose private-era pins to everyone. + */ + fun isSealedForm(content: String): Boolean { + if (content.encodeToByteArray().size > MAX_CONTENT_BYTES) return false + val root = parse(content) as? JsonObject ?: return false + return root["entries"] == null && root["sealed"] != null + } + // ---- verification ---------------------------------------------------------------------- private class OpenedRumor( @@ -244,6 +262,7 @@ object ConcordPins { wrapHint = (entry["wrap"] as? JsonPrimitive)?.contentOrNull?.takeIf { HEX64.matches(it) }, edited = edit != null, editRumorId = edit?.id, + editOrderMs = edit?.orderMs(), entry = entry, ) } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip09Deletions/DeletionIndex.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip09Deletions/DeletionIndex.kt index 93099829a4..3933122ee1 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip09Deletions/DeletionIndex.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip09Deletions/DeletionIndex.kt @@ -36,7 +36,7 @@ class DeletionIndex { val compared = reference.compareTo(other.reference) return if (compared == 0) { - publicKey.compareTo(publicKey) + publicKey.compareTo(other.publicKey) } else { compared } @@ -128,6 +128,16 @@ class DeletionIndex { pubKey: HexKey, ): Boolean = hasBeenDeleted(DeletionRequest(address.toValue(), pubKey)) + /** + * Checks if a kind-5 event signed by [pubKey] deleted the event [eventId], for callers that hold + * only the id and its proven author — a Concord pin entry names a message the reader may never + * have loaded (CORD-04 §7: a held delete hides the entry by identity). + */ + fun hasBeenDeleted( + eventId: HexKey, + pubKey: HexKey, + ): Boolean = hasBeenDeleted(DeletionRequest(eventId, pubKey)) + private fun hasBeenDeleted(key: DeletionRequest) = deletedReferencesBefore.containsKey(key) private fun hasBeenDeleted( diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/pins/ConcordPinsTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/pins/ConcordPinsTest.kt index 5a86d25d55..7aaf8510e3 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/pins/ConcordPinsTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/pins/ConcordPinsTest.kt @@ -141,6 +141,8 @@ class ConcordPinsTest { val pin = ConcordPins.verify(edited, channelA)!! assertTrue(pin.edited) assertEquals("the plan", pin.content) + assertEquals(2_000L, pin.editOrderMs, "the proven Edit's send time, to judge a newer local Edit against") + assertNull(ConcordPins.verify(entry, channelA)!!.editOrderMs) assertEquals(original.id, pin.rumorId, "the identity stays the original's") val (_, foreign) = sendAndOpen(ChannelChat.edit(mallory.pubKey, channelA, 3, original.id, "pwned", 3), signer = mallory) @@ -159,6 +161,9 @@ class ConcordPinsTest { val sealed = ConcordPins.serializeSealed(listOf(entry), plane.conversationKey, 3) val opened3 = ConcordPins.read(sealed) { epoch -> plane.conversationKey.takeIf { epoch == 3L } } assertEquals(listOf(entry), opened3.entries) + assertTrue(ConcordPins.isSealedForm(sealed)) + assertFalse(ConcordPins.isSealedForm(ConcordPins.serializePublic(listOf(entry)))) + assertFalse(ConcordPins.isSealedForm("not json")) val noKey = ConcordPins.read(sealed) { null } assertTrue(noKey.sealedUnavailable, "unreadable is not empty: a writer must not build on it") assertTrue(noKey.entries.isEmpty()) diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip09Deletions/DeletionIndexByIdTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip09Deletions/DeletionIndexByIdTest.kt new file mode 100644 index 0000000000..e19e42f9ae --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip09Deletions/DeletionIndexByIdTest.kt @@ -0,0 +1,50 @@ +/* + * 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.nip09Deletions + +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal +import com.vitorpamplona.quartz.nipC7Chats.ChatEvent +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class DeletionIndexByIdTest { + private val alice = NostrSignerInternal(KeyPair()) + private val mallory = NostrSignerInternal(KeyPair()) + + @Test + fun aDeleteIsFoundByIdAndAuthorAloneAndOnlyForItsAuthor() = + runTest { + val message = alice.sign(ChatEvent.build("hello", createdAt = 1)) + val index = DeletionIndex() + assertFalse(index.hasBeenDeleted(message.id, alice.pubKey)) + + // A stranger's delete names the id but is not the author's own. + index.add(mallory.sign(DeletionRequestEvent.build(listOf(message), createdAt = 2)), wasVerified = false) + assertFalse(index.hasBeenDeleted(message.id, alice.pubKey)) + assertTrue(index.hasBeenDeleted(message.id, mallory.pubKey)) + + index.add(alice.sign(DeletionRequestEvent.build(listOf(message), createdAt = 3)), wasVerified = false) + assertTrue(index.hasBeenDeleted(message.id, alice.pubKey), "no loaded event needed: the id and its author suffice") + } +} From d1b5f2c6b4987c05e2ea38a152c0f3f1b892b6a0 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 29 Sep 2026 16:38:43 +0000 Subject: [PATCH 2/7] feat(concord): CORD-08 disappearing messages end to end MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sender (§2): every durable Chat rumor the app and amy send (messages, inline/thread replies, image variants, reactions, edits) signs ["expiration", created_at + timer] from the session's current fold, and its kind-1059 wrap repeats the same tag via the new outerTags parameter of ConcordStreamEnvelope.wrap/wrapSeal (the random p tag stays first). Deletes, timer notices and typing never carry it. Reader (§3): expired rumors are refused at ingest (openChannelRumor, the session's emit path, the account rumor sink), hidden from the channel feed, last-message preview and unread counts (Account.isAcceptable), and purged by a sweep scheduled on the earliest known deadline (ConcordSessionManager.nextExpiry): LocalCache rumor note, its wrap note, and the session's buffered wrap so reprojection cannot resurrect it. amy concord read drops expired messages. Notice (§4): typed ConcordTimerNoticeEvent (kind 1740), posted into each channel whose key we hold after a timer change and rendered as an inline system row only when its author holds MANAGE_METADATA. UI: timer picker (Off, 1 day, 1 week, 30 days, 90 days, 1 year) on the community edit screen and a "Messages disappear after …" line above the composer. CLI: amy concord timer COMMUNITY [off|SECONDS|1d|1w|30d|90d|1y]. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01PmuzkD5qdqgv6dMeRMoC5N --- .../loggedIn/chats/feed/ChatMessageCompose.kt | 5 + .../concord/ConcordChannelScreen.kt | 24 ++ .../concord/ConcordEditScreen.kt | 21 +- cli/README.md | 1 + .../cli/commands/ConcordChannelCommands.kt | 3 +- .../amethyst/cli/commands/ConcordCommands.kt | 6 +- .../cli/commands/ConcordModCommands.kt | 73 ++++++ .../commons/actions/ConcordActions.kt | 94 +++++-- .../publicChannels/concord/ConcordUnread.kt | 8 +- .../amethyst/commons/model/Account.kt | 63 +++++ .../commons/model/AccountConcordActions.kt | 55 +++- .../commons/model/cache/EventCache.kt | 6 +- .../model/concord/ConcordCommunitySession.kt | 86 +++++- .../model/concord/ConcordSessionManager.kt | 31 +++ .../concord/ConcordDisappearingSessionTest.kt | 245 ++++++++++++++++++ .../composeResources/values/strings.xml | 6 + .../feed/types/RenderConcordTimerNotice.kt | 93 +++++++ .../concord/ConcordTimerPicker.kt | 68 +++++ .../2026-09-29-concord-spec-conformance.md | 2 +- .../cord03Channels/ConcordDisappearing.kt | 30 +++ .../cord03Channels/ConcordTimerNoticeEvent.kt | 50 ++++ .../concord/envelope/ConcordStreamEnvelope.kt | 15 +- .../quartz/utils/EventFactory.kt | 2 + .../cord03Channels/ConcordDisappearingTest.kt | 42 +++ 24 files changed, 993 insertions(+), 36 deletions(-) create mode 100644 commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordDisappearingSessionTest.kt create mode 100644 commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/feed/types/RenderConcordTimerNotice.kt create mode 100644 commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/publicChannels/concord/ConcordTimerPicker.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord03Channels/ConcordTimerNoticeEvent.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatMessageCompose.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatMessageCompose.kt index 70cd0a46a2..bb2cf0cb60 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatMessageCompose.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatMessageCompose.kt @@ -61,6 +61,7 @@ import com.vitorpamplona.amethyst.commons.ui.note.WatchNoteEvent import com.vitorpamplona.amethyst.commons.ui.note.creators.zapsplits.DisplayZapSplits import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.feed.types.RenderBuzzSystemMessage import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.feed.types.RenderChatClip +import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.feed.types.RenderConcordTimerNotice import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.feed.types.RenderEncryptedFile import com.vitorpamplona.amethyst.commons.ui.theme.ReactionRowZapraiser import com.vitorpamplona.amethyst.commons.ui.theme.StdVertSpacer @@ -86,6 +87,7 @@ import com.vitorpamplona.quartz.buzz.stream.StreamMessageDiffEvent import com.vitorpamplona.quartz.buzz.stream.StreamMessageEditEvent import com.vitorpamplona.quartz.buzz.stream.SystemMessageEvent import com.vitorpamplona.quartz.concord.cord03Channels.ConcordChatEditEvent +import com.vitorpamplona.quartz.concord.cord03Channels.ConcordTimerNoticeEvent import com.vitorpamplona.quartz.marmot.foundation.appEvents.MarmotAppEvent import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip04Dm.messages.EncryptedDmEvent @@ -174,6 +176,9 @@ fun ChatroomMessageCompose( } else if (event is ForumVoteEvent) { // Buzz kind-45002: a forum up/down vote. RenderBuzzForumVote(baseNote, accountViewModel) + } else if (event is ConcordTimerNoticeEvent) { + // Concord kind-1740: "Alice set disappearing messages to 30 days" (CORD-08 §4). + RenderConcordTimerNotice(baseNote, accountViewModel, nav) } else if (isBuzzActivityRow(event)) { // Buzz agent-job (43xxx) and huddle (48xxx) lifecycle narration. Huddles // especially must be caught here — their content is JSON, not chat text. diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordChannelScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordChannelScreen.kt index 92a08474f5..bc22f90500 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordChannelScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordChannelScreen.kt @@ -70,6 +70,7 @@ import com.vitorpamplona.amethyst.commons.resources.back import com.vitorpamplona.amethyst.commons.resources.concord_dissolved_read_only import com.vitorpamplona.amethyst.commons.resources.concord_private_channel_no_key import com.vitorpamplona.amethyst.commons.resources.concord_send_image_title +import com.vitorpamplona.amethyst.commons.resources.concord_timer_active import com.vitorpamplona.amethyst.commons.resources.concord_typing_many import com.vitorpamplona.amethyst.commons.resources.concord_typing_one import com.vitorpamplona.amethyst.commons.resources.concord_typing_two @@ -83,6 +84,7 @@ import com.vitorpamplona.amethyst.commons.ui.feeds.WatchLifecycleAndUpdateModel 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.datasource.ConcordChannelSubscription import com.vitorpamplona.amethyst.commons.ui.stringRes import com.vitorpamplona.amethyst.commons.ui.text.MentionPreservingInputTransformation @@ -255,6 +257,7 @@ fun ConcordChannelScreen( ConcordTypingIndicator(communityId, channelId, accountViewModel) if (channel.canPost()) { + ConcordTimerIndicator(communityId, accountViewModel) Spacer(modifier = DoubleVertSpacer) ConcordMessageComposer( newMessageModel = newMessageModel, @@ -293,6 +296,27 @@ private fun ConcordReadOnlyNotice(message: StringResource) { ) } +/** + * CORD-08: a slim "Messages disappear after 30 days" line above the composer while the community's + * timer is on, so a member knows before sending that the message will not last. + */ +@Composable +private fun ConcordTimerIndicator( + communityId: String, + accountViewModel: AccountViewModel, +) { + val session = remember(communityId) { accountViewModel.account.concordSessions.sessionFor(communityId) } ?: return + val state by session.state.collectAsStateWithLifecycle() + val secs = state?.metadata?.messageExpirationSecs() ?: return + Text( + text = stringRes(Res.string.concord_timer_active, concordTimerText(secs)), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.placeholderText, + maxLines = 1, + modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 2.dp), + ) +} + /** The number of messages a freshly-opened channel eagerly backfills to before paging goes demand-driven. */ private const val CONCORD_HISTORY_TARGET = 50 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordEditScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordEditScreen.kt index ddf048bd2a..87d4f4a264 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordEditScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordEditScreen.kt @@ -54,7 +54,10 @@ import com.vitorpamplona.amethyst.commons.resources.concord_create_relays import com.vitorpamplona.amethyst.commons.resources.concord_edit_relays_desc import com.vitorpamplona.amethyst.commons.resources.concord_edit_save import com.vitorpamplona.amethyst.commons.resources.concord_edit_title +import com.vitorpamplona.amethyst.commons.resources.concord_timer_desc +import com.vitorpamplona.amethyst.commons.resources.concord_timer_title import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.publicChannels.concord.ConcordTimerPicker 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 @@ -93,6 +96,9 @@ fun ConcordEditScreen( val icon = remember { mutableStateOf(null) } val banner = remember { mutableStateOf(null) } val relays = remember { mutableStateListOf() } + // CORD-08 timer, seconds (0 = off): the folded value, and the one picked here. + var foldedTimer by remember { mutableStateOf(0L) } + var timer by remember { mutableStateOf(0L) } var prefilled by remember { mutableStateOf(false) } var working by remember { mutableStateOf(false) } val scope = rememberCoroutineScope() @@ -109,6 +115,8 @@ fun ConcordEditScreen( val seededRelays = (md.relays.takeIf { it.isNotEmpty() } ?: session?.entry?.relays.orEmpty()) relays.clear() relays.addAll(seededRelays.mapNotNull { RelayUrlNormalizer.normalizeOrNull(it) }) + foldedTimer = md.messageExpirationSecs() ?: 0L + timer = foldedTimer prefilled = true } } @@ -161,6 +169,14 @@ fun ConcordEditScreen( nav = nav, ) + // CORD-08: this screen is only reachable with MANAGE_METADATA and the Control write key, + // the same predicate as every other field here. + ConcordSectionHeader( + title = stringRes(Res.string.concord_timer_title), + description = stringRes(Res.string.concord_timer_desc), + ) + ConcordTimerPicker(selected = timer, onSelect = { timer = it }, enabled = !working) + Button( onClick = { if (name.value.isBlank() || working) return@Button @@ -175,7 +191,10 @@ fun ConcordEditScreen( icon = icon.value, banner = banner.value, relays = relays.map { it.url }, - ) + ) && + // A timer change is its own edition, chained on the one above, and + // posts the CORD-08 §4 notice into each channel. + (timer == foldedTimer || account.concord.setConcordMessageExpiration(communityId, timer.takeIf { it > 0 })) } finally { // Always re-enable — a thrown save would otherwise strand the button. working = false diff --git a/cli/README.md b/cli/README.md index 519e277554..2803c0838a 100644 --- a/cli/README.md +++ b/cli/README.md @@ -690,6 +690,7 @@ also carried on-relay as the encrypted, fragmented kind:33302 Community List | `amy concord grant COMMUNITY USER ROLE-ID` | Grant a role to a member. | | `amy concord ban COMMUNITY USER` / `unban COMMUNITY USER` | Ban / unban a member. | | `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. | ### cordn (MLS over an MCP coordinator) diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ConcordChannelCommands.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ConcordChannelCommands.kt index 312c3b88d3..4233eb6c5b 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ConcordChannelCommands.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ConcordChannelCommands.kt @@ -97,7 +97,8 @@ object ConcordChannelCommands { ConcordActions.currentChannelPlane(ConcordCommands.entryFor(sc), state, channelId) ?: return Output.error("no_channel_key", "channel '$channelRef' is not folded, or is private and this account holds no key for it (CORD-03 §1)") val channel = plane.key - val wrap = ConcordActions.buildChannelMessage(ctx.signer, channel, channelId, plane.epoch, text, TimeUtils.now()) + // CORD-08 §2: the folded timer rides inside the signed rumor, and on the wrap for relays. + val wrap = ConcordActions.buildChannelMessage(ctx.signer, channel, channelId, plane.epoch, text, TimeUtils.now(), timerSecs = state.metadata?.messageExpirationSecs()) val relays = ConcordCommands.relaysFor(ctx, sc) // A relay that gates writes behind NIP-42 wants the wrap's author (the stream key) authenticated. ctx.registerConcordStreamKeys(relays, listOf(channel.secretKey)) diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ConcordCommands.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ConcordCommands.kt index dd7a23c1f4..253b26a9fa 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ConcordCommands.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ConcordCommands.kt @@ -96,6 +96,9 @@ object ConcordCommands { | key — the hard removal a ban cannot give | concord dissolve COMMUNITY --yes CORD-02 §9: owner-only, IRREVERSIBLE tombstone | that seals the community read-only for everyone + | concord timer COMMUNITY [off|SECONDS|1d|1w|30d|90d|1y] + | CORD-08 disappearing messages: print the timer, + | or set it (MANAGE_METADATA) + post channel notices """.trimMargin() suspend fun dispatch( @@ -105,7 +108,7 @@ object ConcordCommands { route( "concord", tail, - "concord ", + "concord ", help = USAGE, routes = mapOf( @@ -127,6 +130,7 @@ object ConcordCommands { "unban" to { rest -> ConcordModCommands.unban(dataDir, rest) }, "refound" to { rest -> ConcordModCommands.refound(dataDir, rest) }, "dissolve" to { rest -> ConcordModCommands.dissolve(dataDir, rest) }, + "timer" to { rest -> ConcordModCommands.timer(dataDir, rest) }, ), ) diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ConcordModCommands.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ConcordModCommands.kt index 8e5102440c..a7fee0bd9b 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ConcordModCommands.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ConcordModCommands.kt @@ -32,9 +32,11 @@ import com.vitorpamplona.amethyst.commons.actions.ConcordModeration 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.ConcordDisappearing 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.InviteBundleStatus import com.vitorpamplona.quartz.concord.cord06Rekey.ConcordRefounding @@ -198,6 +200,77 @@ object ConcordModCommands { } } + /** + * `timer COMMUNITY [off|SECONDS|1d|1w|30d|90d|1y]` — CORD-08 disappearing messages. Without a + * value, prints the folded timer (seconds, `0` = off). With one, publishes the metadata edition + * (MANAGE_METADATA, laid over the folded metadata) and then one kind-1740 timer notice into every + * channel this account holds a key for (§4). + */ + suspend fun timer( + dataDir: DataDir, + rest: Array, + ): Int { + val args = Args(rest) + val handle = args.positional(0, "community") + val raw = args.positionalOrNull(1) + args.rejectUnknown() + val secs = + raw?.let { + parseTimer(it) ?: return Output.error("bad_args", "timer must be off, a number of seconds, or Nd/Nw/Ny (e.g. 1d, 1w, 30d, 90d, 1y)").let { 2 } + } + val sc = ConcordStore(dataDir.concordFile).find(handle) ?: return ConcordCommands.notFound(handle) + + Context.open(dataDir).use { ctx -> + ctx.prepare() + val loaded = load(ctx, sc, dataDir) + val cid = sc.communityId.hexToByteArray() + val state = ConcordCommunityState.fold(loaded.editions, cid, sc.owner) + val current = state.metadata?.messageExpirationSecs() ?: 0L + if (secs == null) { + Output.emit(mapOf("community" to sc.communityId, "message_expiration" to current, "enabled" to (current > 0))) + return 0 + } + writeGuard(loaded.keys)?.let { return it } + if (!state.authority.hasPermission(ctx.signer.pubKey, ConcordPermissions.MANAGE_METADATA)) { + return Output.error("forbidden", "setting the timer takes MANAGE_METADATA in '$handle' (CORD-08 §1)") + } + val timer = secs.takeIf { it >= 1 } + val relays = ConcordCommands.relaysFor(ctx, sc) + val wrap = ConcordModeration.setMessageExpiration(ctx.signer, loaded.keys, cid, state.metadata ?: MetadataEntity(), timer, loaded.editions, TimeUtils.now(), owner = sc.owner) + val ack = ctx.publish(wrap, relays) + RawEventSupport.publishGuard(ack, wrap.id)?.let { return it } + + // CORD-08 §4: one notice per channel whose key we hold; the fold stays the authority. + val entry = ConcordCommands.entryFor(loaded.community) + val now = TimeUtils.now() + var notices = 0 + for (channelIdHex in state.channels.keys) { + val plane = ConcordActions.currentChannelPlane(entry, state, channelIdHex) ?: continue + ctx.registerConcordStreamKeys(relays, listOf(plane.key.secretKey)) + val notice = ConcordActions.buildChannelTimerNotice(ctx.signer, plane.key, channelIdHex, plane.epoch, timer ?: 0L, now) + // Best effort, like the reference client: a notice that no relay took is only counted out. + if (ctx.publish(notice, relays).values.any { it.accepted }) notices++ + } + Output.emit(mapOf("community" to sc.communityId, "message_expiration" to (timer ?: 0L), "previous" to current, "notices" to notices) + RawEventSupport.ackFields(ack)) + return 0 + } + } + + /** `off`/`0`, plain seconds, or a count of days/weeks/years (`1d`, `1w`, `30d`, `1y`); null if unparseable. */ + private fun parseTimer(raw: String): Long? { + val v = raw.trim().lowercase() + if (v == "off") return 0L + v.toLongOrNull()?.let { return it.takeIf { it >= 0 } } + val n = v.dropLast(1).toLongOrNull()?.takeIf { it >= 1 } ?: return null + val day = ConcordDisappearing.MIN_OFFERED_SECS + return when (v.last()) { + 'd' -> n * day + 'w' -> n * 7 * day + 'y' -> n * 365 * day + else -> null + } + } + /** Unbans a member: `unban `. */ suspend fun unban( dataDir: DataDir, diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordActions.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordActions.kt index 40ff004467..527ae60ecb 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordActions.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordActions.kt @@ -32,6 +32,8 @@ 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.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.ControlEdition import com.vitorpamplona.quartz.concord.cord05Invites.CommunityInvite @@ -55,6 +57,8 @@ import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.nip22Comments.CommentEvent +import com.vitorpamplona.quartz.nip25Reactions.ReactionEvent import com.vitorpamplona.quartz.nip92IMeta.IMetaTag import com.vitorpamplona.quartz.nipC7Chats.ChatEvent import com.vitorpamplona.quartz.utils.TimeUtils @@ -354,6 +358,41 @@ object ConcordActions { // ---- channel chat --------------------------------------------------------- + /** + * [extraTags] plus the CORD-08 §2 `expiration` a rumor of [kind] created at [createdAt] must carry + * while the community's timer is [timerSecs] — none when the timer is off or the kind is exempt + * (deletes, timer notices, ephemeral kinds). Inside the signed rumor, so it is authoritative. + */ + private fun withTimer( + extraTags: Array>, + kind: Int, + createdAt: Long, + timerSecs: Long?, + ): Array> = ConcordDisappearing.withExpiration(extraTags, ConcordDisappearing.expirationFor(kind, createdAt, timerSecs)) + + /** + * Seals [rumor] (encrypted 20013) and wraps it on the [channel] plane. The wrap repeats the + * rumor's own `expiration`, if any, so NIP-40 relays delete the ciphertext (CORD-08 §2). + */ + private suspend fun wrapChat( + rumor: Event, + channel: GroupKey, + authorSigner: NostrSigner, + ): Event = ConcordStreamEnvelope.wrap(rumor, channel, authorSigner, encrypted = true, outerTags = ConcordDisappearing.wrapTagsFor(rumor)) + + /** + * Builds a CORD-08 §4 timer-notice wrap (kind 1740) announcing [timerSecs] (`0` = off) on the + * [channel] plane. A notice never expires, whatever the timer. + */ + suspend fun buildChannelTimerNotice( + authorSigner: NostrSigner, + channel: GroupKey, + channelId: HexKey, + epoch: Long, + timerSecs: Long, + createdAt: Long, + ): Event = wrapChat(ConcordDisappearing.timerNotice(authorSigner.pubKey, channelId, epoch, timerSecs, createdAt), channel, authorSigner) + /** Builds an encrypted-seal channel message wrap to publish on the [channel] plane. */ suspend fun buildChannelMessage( authorSigner: NostrSigner, @@ -363,9 +402,10 @@ object ConcordActions { text: String, createdAt: Long, extraTags: Array> = emptyArray(), + timerSecs: Long? = null, ): Event { - val rumor = ChannelChat.message(authorSigner.pubKey, channelId, epoch, text, createdAt, extraTags) - return ConcordStreamEnvelope.wrap(rumor, channel, authorSigner, encrypted = true) + val rumor = ChannelChat.message(authorSigner.pubKey, channelId, epoch, text, createdAt, withTimer(extraTags, ChatEvent.KIND, createdAt, timerSecs)) + return wrapChat(rumor, channel, authorSigner) } /** @@ -381,9 +421,10 @@ object ConcordActions { imetas: List, createdAt: Long, extraTags: Array> = emptyArray(), + timerSecs: Long? = null, ): Event { - val rumor = ChannelChat.imageMessage(authorSigner.pubKey, channelId, epoch, text, imetas, createdAt, extraTags) - return ConcordStreamEnvelope.wrap(rumor, channel, authorSigner, encrypted = true) + val rumor = ChannelChat.imageMessage(authorSigner.pubKey, channelId, epoch, text, imetas, createdAt, withTimer(extraTags, ChatEvent.KIND, createdAt, timerSecs)) + return wrapChat(rumor, channel, authorSigner) } /** Builds an encrypted-seal inline quote-reply wrap (kind-9 message quoting [parent] via `q`) on the [channel] plane. */ @@ -396,9 +437,10 @@ object ConcordActions { text: String, createdAt: Long, extraTags: Array> = emptyArray(), + timerSecs: Long? = null, ): Event { - val rumor = ChannelChat.inlineReply(authorSigner.pubKey, channelId, epoch, text, parent.id, parent.pubKey, createdAt, extraTags) - return ConcordStreamEnvelope.wrap(rumor, channel, authorSigner, encrypted = true) + val rumor = ChannelChat.inlineReply(authorSigner.pubKey, channelId, epoch, text, parent.id, parent.pubKey, createdAt, withTimer(extraTags, ChatEvent.KIND, createdAt, timerSecs)) + return wrapChat(rumor, channel, authorSigner) } /** Builds an encrypted-seal thread-reply wrap (kind-1111 NIP-22 comment on [parent]) on the [channel] plane. */ @@ -411,9 +453,10 @@ object ConcordActions { text: String, createdAt: Long, extraTags: Array> = emptyArray(), + timerSecs: Long? = null, ): Event { - val rumor = ChannelChat.reply(authorSigner.pubKey, channelId, epoch, text, parent, createdAt, extraTags) - return ConcordStreamEnvelope.wrap(rumor, channel, authorSigner, encrypted = true) + val rumor = ChannelChat.reply(authorSigner.pubKey, channelId, epoch, text, parent, createdAt, withTimer(extraTags, CommentEvent.KIND, createdAt, timerSecs)) + return wrapChat(rumor, channel, authorSigner) } /** @@ -430,9 +473,10 @@ object ConcordActions { imetas: List, createdAt: Long, extraTags: Array> = emptyArray(), + timerSecs: Long? = null, ): Event { - val rumor = ChannelChat.imageReply(authorSigner.pubKey, channelId, epoch, text, imetas, parent, createdAt, extraTags) - return ConcordStreamEnvelope.wrap(rumor, channel, authorSigner, encrypted = true) + val rumor = ChannelChat.imageReply(authorSigner.pubKey, channelId, epoch, text, imetas, parent, createdAt, withTimer(extraTags, CommentEvent.KIND, createdAt, timerSecs)) + return wrapChat(rumor, channel, authorSigner) } /** @@ -449,9 +493,10 @@ object ConcordActions { newText: String, createdAt: Long, extraTags: Array> = emptyArray(), + timerSecs: Long? = null, ): Event { - val rumor = ChannelChat.edit(authorSigner.pubKey, channelId, epoch, target.id, newText, createdAt, extraTags) - return ConcordStreamEnvelope.wrap(rumor, channel, authorSigner, encrypted = true) + val rumor = ChannelChat.edit(authorSigner.pubKey, channelId, epoch, target.id, newText, createdAt, withTimer(extraTags, ConcordChatEditEvent.KIND, createdAt, timerSecs)) + return wrapChat(rumor, channel, authorSigner) } /** @@ -469,7 +514,7 @@ object ConcordActions { createdAt: Long, ): Event { val rumor = ChannelChat.delete(authorSigner.pubKey, channelId, epoch, targets, createdAt) - return ConcordStreamEnvelope.wrap(rumor, channel, authorSigner, encrypted = true) + return wrapChat(rumor, channel, authorSigner) } /** Builds an encrypted-seal reaction wrap (kind 7 against [target]) on the [channel] plane. */ @@ -482,9 +527,10 @@ object ConcordActions { reaction: String, createdAt: Long, extraTags: Array> = emptyArray(), + timerSecs: Long? = null, ): Event { - val rumor = ChannelChat.reaction(authorSigner.pubKey, channelId, epoch, target.id, target.pubKey, target.kind, reaction, createdAt, extraTags) - return ConcordStreamEnvelope.wrap(rumor, channel, authorSigner, encrypted = true) + val rumor = ChannelChat.reaction(authorSigner.pubKey, channelId, epoch, target.id, target.pubKey, target.kind, reaction, createdAt, withTimer(extraTags, ReactionEvent.KIND, createdAt, timerSecs)) + return wrapChat(rumor, channel, authorSigner) } /** @@ -538,14 +584,28 @@ object ConcordActions { /** * Opens one channel [wrap] and returns its rumor only when it passes the Chat ingest gate * ([ChannelChat.acceptOpened]): an encrypted 20013 seal, a Chat kind (never another plane's - * kind), a strict `channel`/`epoch` binding, and a well-formed `ms`. Anything else is dropped - * here, before it can reach the store. + * kind), a strict `channel`/`epoch` binding, and a well-formed `ms`. A rumor whose own + * `expiration` is at or before [now] is refused too (CORD-08 §3: never stored). Anything else is + * dropped here, before it can reach the store. */ fun openChannelRumor( wrap: Event, channel: GroupKey, channelId: HexKey, epoch: Long, + now: Long = TimeUtils.now(), + ): Event? = openChannelRumorAnyExpiry(wrap, channel, channelId, epoch)?.takeUnless { ConcordDisappearing.isExpired(it, now) } + + /** + * [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. + */ + fun openChannelRumorAnyExpiry( + wrap: Event, + channel: GroupKey, + channelId: HexKey, + epoch: Long, ): Event? = ConcordStreamEnvelope.openOrNull(wrap, channel)?.let { ChannelChat.acceptOpened(it, channelId, epoch) } // ---- invites -------------------------------------------------------------- diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chats/publicChannels/concord/ConcordUnread.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chats/publicChannels/concord/ConcordUnread.kt index e1b694ccf7..4e4f6bb5f0 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chats/publicChannels/concord/ConcordUnread.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chats/publicChannels/concord/ConcordUnread.kt @@ -27,6 +27,7 @@ import com.vitorpamplona.amethyst.commons.model.cache.LocalCache import com.vitorpamplona.amethyst.commons.model.concord.ConcordChannel import com.vitorpamplona.amethyst.commons.model.concordChannelLastReadRoute import com.vitorpamplona.quartz.concord.cord03Channels.ConcordChannelId +import com.vitorpamplona.quartz.concord.cord03Channels.ConcordTimerNoticeEvent import com.vitorpamplona.quartz.nip22Comments.CommentEvent import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.Flow @@ -109,12 +110,15 @@ fun concordCommunityHasUnreadFlow( * Messages hub row — reuses this so none of them can disagree with the open channel's feed: * a trailing comment can't stick the badge at a count the user can never clear, nor show up as a * "last message" that isn't in the timeline. Unacceptable (muted/blocked) authors are hidden for - * the same reason. + * the same reason — and so are expired disappearing messages (CORD-08 §3). + * + * A CORD-08 timer notice renders in the feed as a system line but is not a *message*: it neither + * counts as unread nor stands in as the channel's last message (its content is empty). */ fun isConcordTimelineMessage( note: Note, account: Account, -): Boolean = note.event.let { it != null && it !is CommentEvent } && account.isAcceptable(note) +): Boolean = note.event.let { it != null && it !is CommentEvent && it !is ConcordTimerNoticeEvent } && account.isAcceptable(note) /** * The newest timeline message in this channel (see [isConcordTimelineMessage]), or null if none — diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/Account.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/Account.kt index 20ec82acb0..7c17764ec3 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/Account.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/Account.kt @@ -190,6 +190,8 @@ import com.vitorpamplona.quartz.buzz.threading.buzzThreadReply import com.vitorpamplona.quartz.buzz.threading.buzzThreadRoot import com.vitorpamplona.quartz.concord.cord03Channels.ChannelChat import com.vitorpamplona.quartz.concord.cord03Channels.ConcordChannelId +import com.vitorpamplona.quartz.concord.cord03Channels.ConcordDisappearing +import com.vitorpamplona.quartz.concord.cord03Channels.ConcordTimerNoticeEvent import com.vitorpamplona.quartz.experimental.bounties.BountyAddValueEvent import com.vitorpamplona.quartz.experimental.edits.TextNoteModificationEvent import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStoryBaseEvent @@ -363,6 +365,7 @@ import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.sample @@ -739,10 +742,57 @@ class Account( ?.value ?.authority if (authority?.isBanned(rumor.pubKey) == true) return + // CORD-08 §3: an already-expired rumor is never stored. The session refuses it first; this + // backs it up for any other caller of the sink. + if (ConcordDisappearing.isExpired(rumor)) return registerConcordEncryptedImages(rumor) cache.consumeConcordRumor(communityId, channelIdHex, rumor, seenOnRelays) } + /** + * The CORD-08 §3 purge: drops every Concord rumor whose `expiration` has passed — its note, the + * note of the wrap that carried it, and the wrap in its session's buffer (so no re-projection can + * resurrect it). The rumor's own children (a reply, a reaction) are independent events and stay, + * as on a delete; they carry their own expiration when the timer was on. Scheduled on + * [ConcordSessionManager.nextExpiry], so it only ever runs when something is due. + */ + fun sweepExpiredConcordMessages(now: Long = TimeUtils.now()) { + val expired = concordSessions.sweepExpired(now) + for (rumors in expired.values) { + for (gone in rumors) { + cache.getNoteIfExists(gone.rumorId)?.let { note -> + note.detachFromChildren() + cache.pruner.unlinkAndRemove(note) + } + cache.getNoteIfExists(gone.wrapId)?.let { cache.pruner.unlinkAndRemove(it) } + } + } + } + + /** True for a Concord rumor whose own `expiration` has passed: never displayed (CORD-08 §3). */ + private fun isConcordExpired(note: Note): Boolean { + val event = note.event ?: return false + if (note.inGatherers?.any { it is ConcordChannel } != true) return false + return ConcordDisappearing.isExpired(event) + } + + /** + * True for a Concord timer notice (CORD-08 §4) that must not be shown: malformed, or authored by + * someone who does not hold MANAGE_METADATA in the community's current fold — anyone can spell + * the tag, only staff are believed about policy. + */ + private fun isUnbelievedConcordTimerNotice(note: Note): Boolean { + val event = note.event as? ConcordTimerNoticeEvent ?: return false + val channel = note.inGatherers?.firstNotNullOfOrNull { it as? ConcordChannel } ?: return true + val authority = + concordSessions + .sessionFor(channel.channelId.communityId) + ?.state + ?.value + ?.authority ?: return true + return !ConcordDisappearing.isBelievedNotice(event, authority) + } + /** * Register any encrypted image attachments on a Concord message ([ChannelChat.encryptedImagesOf]) * so the shared media pipeline can display them: the ciphertext blob's AES-256-GCM key/nonce go @@ -3752,6 +3802,7 @@ class Account( override fun isAcceptable(note: Note): Boolean { if (isConcordBanned(note)) return false + if (isConcordExpired(note) || isUnbelievedConcordTimerNotice(note)) return false val mutedThreads = hiddenUsers.flow.value.mutedThreads if (mutedThreads.isNotEmpty() && mutedThreads.contains(resolveThreadRoot(note))) return false return note.author?.let { isAcceptable(it) } ?: true && @@ -4140,6 +4191,18 @@ class Account( } } + // 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 + if (waitMs > 0) delay(waitMs) + runCatching { sweepExpiredConcordMessages() }.onFailure { Log.w("Concord", "expired-message sweep failed", it) } + } + } + scope.launch { cache.antiSpam.flowSpam.collect { it.cache.spamMessages.snapshot().values.forEach { spammer -> diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/AccountConcordActions.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/AccountConcordActions.kt index 0ebe09d709..461eca2c43 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/AccountConcordActions.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/AccountConcordActions.kt @@ -691,19 +691,21 @@ class AccountConcordActions( .toTypedArray() val parent = replyTo?.event + // CORD-08 §2: the community timer as folded right now rides inside the signed rumor. + val timer = session.messageExpirationSecs() val wrap = when { // A minichat reply is a kind-1111 thread comment (carrying encrypted image imetas when // the user attached media); an inline reply is a kind-9 message quoting the parent; a // fresh post is a plain kind-9 message. parent != null && replyMode == ReplyMode.MINICHAT && imetas.isNotEmpty() -> - ConcordActions.buildChannelImageReply(account.signer, channelKey, channelIdHex, plane.epoch, parent, text, imetas, TimeUtils.now(), emojiTags) + ConcordActions.buildChannelImageReply(account.signer, channelKey, channelIdHex, plane.epoch, parent, text, imetas, TimeUtils.now(), emojiTags, timer) parent != null && replyMode == ReplyMode.MINICHAT -> - ConcordActions.buildChannelReply(account.signer, channelKey, channelIdHex, plane.epoch, parent, text, TimeUtils.now(), emojiTags) + ConcordActions.buildChannelReply(account.signer, channelKey, channelIdHex, plane.epoch, parent, text, TimeUtils.now(), emojiTags, timer) parent != null -> - ConcordActions.buildChannelInlineReply(account.signer, channelKey, channelIdHex, plane.epoch, parent, text, TimeUtils.now(), emojiTags) + ConcordActions.buildChannelInlineReply(account.signer, channelKey, channelIdHex, plane.epoch, parent, text, TimeUtils.now(), emojiTags, timer) else -> - ConcordActions.buildChannelMessage(account.signer, channelKey, channelIdHex, plane.epoch, text, TimeUtils.now(), emojiTags) + ConcordActions.buildChannelMessage(account.signer, channelKey, channelIdHex, plane.epoch, text, TimeUtils.now(), emojiTags, timer) } sendConcordChannelWrap(entry, channelKey, wrap) return true @@ -733,7 +735,7 @@ class AccountConcordActions( .findEmojiTags(text) .map { it.toTagArray() } .toTypedArray() - val wrap = ConcordActions.buildChannelImageMessage(account.signer, channelKey, channelIdHex, plane.epoch, text, imetas, TimeUtils.now(), emojiTags) + val wrap = ConcordActions.buildChannelImageMessage(account.signer, channelKey, channelIdHex, plane.epoch, text, imetas, TimeUtils.now(), emojiTags, session.messageExpirationSecs()) sendConcordChannelWrap(entry, channelKey, wrap) return true } @@ -767,7 +769,7 @@ class AccountConcordActions( .findEmojiTags(reaction) .map { it.toTagArray() } .toTypedArray() - val wrap = ConcordActions.buildChannelReaction(account.signer, channelKey, channelIdHex, plane.epoch, target, reaction, TimeUtils.now(), emojiTags) + val wrap = ConcordActions.buildChannelReaction(account.signer, channelKey, channelIdHex, plane.epoch, target, reaction, TimeUtils.now(), emojiTags, session.messageExpirationSecs()) publishConcordWrap(entry, wrap) return true } @@ -805,7 +807,7 @@ class AccountConcordActions( .findEmojiTags(newText) .map { it.toTagArray() } .toTypedArray() - val wrap = ConcordActions.buildChannelEdit(account.signer, channelKey, channelIdHex, plane.epoch, target, newText, TimeUtils.now(), emojiTags) + val wrap = ConcordActions.buildChannelEdit(account.signer, channelKey, channelIdHex, plane.epoch, target, newText, TimeUtils.now(), emojiTags, session.messageExpirationSecs()) publishConcordWrap(entry, wrap) return true } @@ -1731,6 +1733,45 @@ class AccountConcordActions( return true } + /** + * Set [communityId]'s disappearing-messages timer to [secs] seconds, or turn it off when null or + * below 1 (CORD-08 §1): a metadata edition under MANAGE_METADATA, laid over the folded metadata so + * nothing else changes. Then, as §4 asks, one kind-1740 timer notice goes into every channel whose + * key this account holds (a Private Channel without one simply gets none). Returns false when + * nothing was published (not authorized, no Control write key, or the timer is already [secs]). + */ + suspend fun setConcordMessageExpiration( + communityId: String, + secs: Long?, + ): Boolean { + val session = account.concordSessions.sessionFor(communityId) ?: return false + 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() + 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) + publishConcordWrap(session.entry, wrap) + postConcordTimerNotices(session, timer ?: 0) + return true + } + + /** One CORD-08 §4 timer notice per channel of [session] this account can write. */ + private suspend fun postConcordTimerNotices( + session: ConcordCommunitySession, + timerSecs: Long, + ) { + val channels = + session.state.value + ?.channels + ?.keys ?: return + val now = TimeUtils.now() + for (channelIdHex in channels) { + val plane = session.currentChannelPlane(channelIdHex) ?: continue + publishConcordWrap(session.entry, ConcordActions.buildChannelTimerNotice(account.signer, plane.key, channelIdHex, plane.epoch, timerSecs, now)) + } + } + /** * Dissolve [communityId] for good (CORD-02 §9): publish the owner-signed, `eid`-bound tombstone * at the community's dissolved address. Owner-only — every verifier ignores anyone else's — and diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/cache/EventCache.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/cache/EventCache.kt index 8fb3e1e39f..1cb4046aa0 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/cache/EventCache.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/cache/EventCache.kt @@ -141,6 +141,7 @@ import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListFrag 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.ConcordTimerNoticeEvent import com.vitorpamplona.quartz.contextvm.cep06Announcements.CvmServerAnnouncementEvent import com.vitorpamplona.quartz.contextvm.cep06Announcements.CvmToolsListEvent import com.vitorpamplona.quartz.cyberspace.CyberspaceBagEvent @@ -981,7 +982,9 @@ open class EventCache : // so the note already carries its ConcordChannel gatherer when it flows through // the Messages-list incremental filter (which routes rows by that gatherer). val messageRow = - if (rumor is ChatEvent || rumor is CommentEvent) { + // A CORD-08 timer notice is a channel row too: the inline "… set disappearing messages" line + // (the feed shows it only when its author holds MANAGE_METADATA, see Account.isAcceptable). + if (rumor is ChatEvent || rumor is CommentEvent || rumor is ConcordTimerNoticeEvent) { val ch = getOrCreateConcordChannel(ConcordChannelId(communityId, channelIdHex)) val note = getOrCreateNote(rumor.id) // Skip attaching a row for a message we already know is deleted (its kind-5 delete @@ -4029,6 +4032,7 @@ open class EventCache : is WakeUpEvent, is WelcomeEvent, is WorkoutRecordEvent, + is ConcordTimerNoticeEvent, -> consumeRegularEvent(event, relay, wasVerified) else -> { diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordCommunitySession.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordCommunitySession.kt index 864f4e808d..2540c57e68 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordCommunitySession.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordCommunitySession.kt @@ -29,6 +29,7 @@ import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityState import com.vitorpamplona.quartz.concord.cord02Community.ConcordDissolution 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.cord04Roles.ControlEdition import com.vitorpamplona.quartz.concord.cord04Roles.EditionFold import com.vitorpamplona.quartz.concord.cord04Roles.EntityFloor @@ -54,6 +55,18 @@ import kotlin.concurrent.Volatile */ typealias ConcordRumorSink = (communityId: HexKey, channelIdHex: HexKey, rumor: Event, seenOnRelays: Set) -> Unit +/** + * A disappearing Chat rumor (CORD-08) a session tracks: the [rumorId] carried by wrap [wrapId] on + * [channelIdHex], gone at [expiresAt] (unix seconds). Returned by a sweep once expired, so the + * store drops both notes. + */ +data class ExpiredConcordRumor( + val channelIdHex: HexKey, + val wrapId: HexKey, + val rumorId: HexKey, + val expiresAt: Long, +) + /** * The result of feeding one wrap to a session's [ConcordCommunitySession.ingest]. It separates * "was it ours" from "did it change structure", so only structure-changing wraps bump the session @@ -776,7 +789,17 @@ class ConcordCommunitySession( seenOnRelays: Set = emptySet(), ) { val authors = HashSet() - ConcordActions.channelRumors(wraps, key, channelIdHex, epoch).forEach { rumor -> + val now = TimeUtils.now() + for (wrap in wraps) { + val rumor = ConcordActions.openChannelRumorAnyExpiry(wrap, key, channelIdHex, epoch) ?: continue + // CORD-08 §3: only the rumor's own tag counts. A rumor carrying one is remembered so the + // sweep purges it (and its wrap) when it expires; one already expired is refused here — + // 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) + if (expiresAt <= now) continue + } authors.add(rumor.pubKey.lowercase()) onRumor(entry.id, channelIdHex, rumor, seenOnRelays) } @@ -787,6 +810,67 @@ class ConcordCommunitySession( } } + // ── Disappearing messages (CORD-08) ────────────────────────────────────── + + /** Wrap id -> the expiring rumor it carries, for every rumor with an `expiration` we emitted or refused. */ + private val expiringByWrapId = HashMap() + + private val _nextExpiry = MutableStateFlow(null) + + /** + * The earliest `expiration` (unix seconds) among the rumors this session holds, or null when none + * expires — what the account's sweep schedules itself on, so a community with no timer costs + * nothing. At or before now when an expired rumor was just refused and its wrap awaits the sweep. + */ + val nextExpiry: StateFlow = _nextExpiry + + /** + * The disappearing-messages timer (seconds) a compliant sender attaches to its next durable Chat + * rumor, read from the current fold at send time (CORD-08 §2), or null when off or not folded. + */ + fun messageExpirationSecs(): Long? = _state.value?.metadata?.messageExpirationSecs() + + private fun trackExpiring( + wrapId: HexKey, + channelIdHex: HexKey, + rumorId: HexKey, + expiresAt: Long, + ) { + lock.withLock { + expiringByWrapId[wrapId] = ExpiredConcordRumor(channelIdHex, wrapId, rumorId, expiresAt) + _nextExpiry.update { if (it == null || expiresAt < it) expiresAt else it } + } + } + + /** + * 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. + */ + fun sweepExpired(now: Long = TimeUtils.now()): List = + lock.withLock { + if (expiringByWrapId.isEmpty()) return@withLock emptyList() + val out = ArrayList() + val it = expiringByWrapId.values.iterator() + while (it.hasNext()) { + val expiring = it.next() + if (expiring.expiresAt <= now) { + channelWrapsById[expiring.channelIdHex]?.remove(expiring.wrapId) + out.add(expiring) + it.remove() + } + } + _nextExpiry.value = expiringByWrapId.values.minOfOrNull { it.expiresAt } + out + } + + /** True while [channelIdHex]'s buffer holds [wrapId] — for tests of the sweep. */ + internal fun isBuffered( + channelIdHex: HexKey, + wrapId: HexKey, + ): Boolean = lock.withLock { channelWrapsById[channelIdHex]?.containsKey(wrapId) == true } + companion object { private fun privateKeySet(e: ConcordCommunityListEntry) = e.privateChannels.mapTo(HashSet()) { Triple(it.channelId.lowercase(), it.key.lowercase(), it.epoch) } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordSessionManager.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordSessionManager.kt index 44da2aa96a..82c43a895e 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordSessionManager.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordSessionManager.kt @@ -69,6 +69,16 @@ class ConcordSessionManager( /** Monotonic counter bumped whenever the joined set or any community's fold changes. */ val revision: StateFlow = _revision + // Declared before `init`: the communities collector may run synchronously on an immediate dispatcher. + private val _nextExpiry = MutableStateFlow(null) + + /** + * The earliest disappearing-message deadline (unix seconds) across every joined community, or + * null when nothing expires (CORD-08 §3). The account schedules its [sweepExpired] on this, so + * communities without a timer cost nothing. + */ + val nextExpiry: StateFlow = _nextExpiry + private val lock = KmpLock() private val stateWatchers = HashMap() // communityId -> state collector @@ -97,13 +107,34 @@ class ConcordSessionManager( stateWatchers.remove(id)?.cancel() stateWatchers[id] = scope.launch { + // CORD-08: a rumor with an expiration moves the account-wide sweep deadline. + launch { session.nextExpiry.collect { recomputeNextExpiry() } } session.state.collect { bumpRevision() } } } } + recomputeNextExpiry() bumpRevision() } + private fun recomputeNextExpiry() { + _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 + * session buffers and returns them, per community, for the caller to purge from its store. + */ + fun sweepExpired(now: Long): Map> { + val out = HashMap>() + for (session in registry.sessions()) { + val expired = session.sweepExpired(now) + if (expired.isNotEmpty()) out[session.entry.id] = expired + } + recomputeNextExpiry() + return out + } + private fun bumpRevision() { // Called from the communities collector, every per-session state watcher, and the // ingest path — different coroutines/dispatchers — so the increment must be atomic diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordDisappearingSessionTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordDisappearingSessionTest.kt new file mode 100644 index 0000000000..4b073861e0 --- /dev/null +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordDisappearingSessionTest.kt @@ -0,0 +1,245 @@ +/* + * 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.NewConcordCommunity +import com.vitorpamplona.quartz.concord.cord03Channels.ChannelChat +import com.vitorpamplona.quartz.concord.cord03Channels.ConcordDisappearing +import com.vitorpamplona.quartz.concord.cord03Channels.ConcordTimerNoticeEvent +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.toHexKey +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 kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertIs +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * CORD-08 Disappearing Messages at the commons layer: what the chat builders tag (§2), the + * session's refusal and sweep (§3), and the timer notice (§4). + */ +class ConcordDisappearingSessionTest { + private val owner = NostrSignerInternal(KeyPair()) + private val day = 86_400L + + private fun entryFor(community: NewConcordCommunity) = + 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", + ) + + /** A community whose timer is [timerSecs], folded into a session that captures what it emits. */ + private suspend fun session( + timerSecs: Long?, + captured: MutableList = mutableListOf(), + ): Pair { + val community = ConcordCommunityFactory.create(owner, "Nostrichs", createdAt = 1L, relays = listOf("wss://r.example")) + val session = ConcordCommunitySession(entryFor(community), owner.pubKey) { _, _, rumor, _ -> captured += rumor } + community.genesisWraps.forEach { session.ingest(it) } + if (timerSecs != null) { + val standing = session.state.value!!.metadata!! + val edit = ConcordModeration.setMessageExpiration(owner, community.controlPlane, community.communityId, standing, timerSecs, session.controlEditions(), 2L, owner = community.ownerPubKey) + session.ingest(edit) + } + return community to session + } + + private fun opened( + wrap: Event, + plane: GroupKey, + ): Event = ConcordStreamEnvelope.open(wrap, plane).rumor + + private fun wrapExpiration(wrap: Event): String? = wrap.tags.firstOrNull { it[0] == "expiration" }?.get(1) + + @Test + fun theFoldedTimerIsWhatTheSessionSendsWith() = + runTest { + assertNull(session(null).second.messageExpirationSecs(), "no timer until staff set one") + assertEquals(30 * day, session(30 * day).second.messageExpirationSecs()) + } + + @Test + fun everyDurableChatRumorAndItsWrapCarryTheSameExpiration() = + runTest { + val (community, session) = session(day) + val general = community.generalChannelIdHex + val plane = session.currentChannelPlane(general)!! + val timer = session.messageExpirationSecs() + val at = 1_000_000L + val parent = ChannelChat.message(owner.pubKey, general, plane.epoch, "parent", at - 10) + val imeta = listOf(IMetaTagBuilder("https://blossom.example/x").build()) + + val durable = + listOf( + ConcordActions.buildChannelMessage(owner, plane.key, general, plane.epoch, "hi", at, timerSecs = timer), + ConcordActions.buildChannelImageMessage(owner, plane.key, general, plane.epoch, "pic", imeta, at, timerSecs = timer), + 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) { + val rumor = opened(wrap, plane.key) + assertEquals(at + day, ConcordDisappearing.expirationOf(rumor), "kind ${rumor.kind} signs the deadline") + assertEquals((at + day).toString(), wrapExpiration(wrap), "kind ${rumor.kind}'s wrap repeats it") + assertEquals("p", wrap.tags[0][0], "the random p stays first") + } + + // Exempt: deletes, timer notices, typing — neither inside nor outside. + val exempt = + listOf( + ConcordActions.buildChannelDelete(owner, plane.key, general, plane.epoch, listOf(parent), at), + ConcordActions.buildChannelTimerNotice(owner, plane.key, general, plane.epoch, day, at), + ConcordActions.buildChannelTyping(owner, plane.key, general, plane.epoch, at), + ) + for (wrap in exempt) { + assertNull(ConcordDisappearing.expirationOf(opened(wrap, plane.key))) + assertNull(wrapExpiration(wrap)) + } + + // Timer off: nothing anywhere. + val off = ConcordActions.buildChannelMessage(owner, plane.key, general, plane.epoch, "forever", at, timerSecs = null) + assertNull(ConcordDisappearing.expirationOf(opened(off, plane.key))) + assertEquals(listOf("p"), off.tags.map { it[0] }) + } + + @Test + fun anAlreadyExpiredRumorIsRefusedAndItsWrapPurged() = + runTest { + val captured = mutableListOf() + val (community, session) = session(day, captured) + val general = community.generalChannelIdHex + val plane = session.currentChannelPlane(general)!! + val longAgo = TimeUtils.now() - 2 * day + val stale = ConcordActions.buildChannelMessage(owner, plane.key, general, plane.epoch, "stale", longAgo, timerSecs = day) + + session.ingest(stale) + assertTrue(captured.none { it.content == "stale" }, "never stored (CORD-08 §3)") + // The one-shot readers refuse it too (what `amy concord read` prints). + assertTrue(ConcordActions.channelMessages(listOf(stale), plane.key, general, plane.epoch).isEmpty()) + assertNull(ConcordActions.openChannelRumor(stale, plane.key, general, plane.epoch)) + + // Queued for an immediate sweep, which takes the wrap out of the buffer. + assertTrue(session.nextExpiry.value!! <= TimeUtils.now()) + val swept = session.sweepExpired() + assertEquals(listOf(stale.id), swept.map { it.wrapId }) + assertFalse(session.isBuffered(general, stale.id)) + assertNull(session.nextExpiry.value) + } + + @Test + fun theSweepDropsALiveRumorFromTheBufferWhenItExpires() = + runTest { + val captured = mutableListOf() + val (community, session) = session(day, captured) + val general = community.generalChannelIdHex + val plane = session.currentChannelPlane(general)!! + val now = TimeUtils.now() + val live = ConcordActions.buildChannelMessage(owner, plane.key, general, plane.epoch, "for a day", now, timerSecs = day) + val forever = ConcordActions.buildChannelMessage(owner, plane.key, general, plane.epoch, "forever", now) + + session.ingest(live) + session.ingest(forever) + val rumor = captured.single { it.content == "for a day" } + assertEquals(now + day, session.nextExpiry.value) + + // Not yet due: nothing moves. + assertTrue(session.sweepExpired(now).isEmpty()) + assertTrue(session.isBuffered(general, live.id)) + + // Due: the wrap leaves the buffer (no re-projection can bring it back); the untagged one stays. + val swept = session.sweepExpired(now + day) + assertEquals(listOf(rumor.id), swept.map { it.rumorId }) + assertEquals(listOf(live.id), swept.map { it.wrapId }) + assertFalse(session.isBuffered(general, live.id)) + assertTrue(session.isBuffered(general, forever.id)) + assertNull(session.nextExpiry.value) + } + + @Test + fun theManagerSchedulesOnTheEarliestDeadlineAndSweepsPerCommunity() = + runTest { + val community = ConcordCommunityFactory.create(owner, "Nostrichs", createdAt = 1L, relays = listOf("wss://r.example")) + val manager = ConcordSessionManager(MutableStateFlow(listOf(entryFor(community))), owner.pubKey, backgroundScope) + testScheduler.runCurrent() + community.genesisWraps.forEach { manager.ingest(it) } + testScheduler.runCurrent() + assertNull(manager.nextExpiry.value, "nothing expires: the sweep never wakes") + + val general = community.generalChannelIdHex + val plane = manager.sessionFor(community.communityIdHex)!!.currentChannelPlane(general)!! + val now = TimeUtils.now() + val wrap = ConcordActions.buildChannelMessage(owner, plane.key, general, plane.epoch, "for a day", now, timerSecs = day) + manager.ingest(wrap) + testScheduler.runCurrent() + assertEquals(now + day, manager.nextExpiry.value) + + assertTrue(manager.sweepExpired(now).isEmpty()) + val swept = manager.sweepExpired(now + day) + assertEquals(listOf(wrap.id), swept[community.communityIdHex]!!.map { it.wrapId }) + testScheduler.runCurrent() + assertNull(manager.nextExpiry.value) + } + + @Test + fun aTimerNoticeIsATypedChatRumorBelievedOnlyFromStaff() = + runTest { + val captured = mutableListOf() + val (community, session) = session(null, captured) + val general = community.generalChannelIdHex + val plane = session.currentChannelPlane(general)!! + + session.ingest(ConcordActions.buildChannelTimerNotice(owner, plane.key, general, plane.epoch, 30 * day, TimeUtils.now())) + val notice = assertIs(captured.single()) + assertEquals(30 * day, notice.timerSecs()) + + val authority = session.state.value!!.authority + assertTrue(ConcordDisappearing.isBelievedNotice(notice, authority), "the owner holds MANAGE_METADATA") + + val rando = NostrSignerInternal(KeyPair()) + val forged = ConcordDisappearing.timerNotice(rando.pubKey, general, plane.epoch, 0, TimeUtils.now()) + assertFalse(ConcordDisappearing.isBelievedNotice(forged, authority), "anyone can spell the tag") + + val malformed = ChannelChat.message(owner.pubKey, general, plane.epoch, "", TimeUtils.now(), arrayOf(arrayOf("timer", "soon"))) + assertFalse(ConcordDisappearing.isBelievedNotice(malformed, authority)) + } +} diff --git a/commonsUI/src/commonMain/composeResources/values/strings.xml b/commonsUI/src/commonMain/composeResources/values/strings.xml index 1003a3ee26..03741e20de 100644 --- a/commonsUI/src/commonMain/composeResources/values/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values/strings.xml @@ -598,6 +598,12 @@ Open channel Add a banner Where this community's encrypted planes are published and read. + Disappearing messages + New messages in every channel are deleted from members' devices and from relays after this long. Changing it doesn't affect messages already sent. Anyone who can read a message could still copy it. + Off + %1$s set disappearing messages to %2$s + %1$s turned off disappearing messages + Messages disappear after %1$s This community has been dissolved and is now read-only. You can still read its history, but no new messages can be posted. This is a private channel and you don't hold its key, so you can't read it or post here. Name diff --git a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/feed/types/RenderConcordTimerNotice.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/feed/types/RenderConcordTimerNotice.kt new file mode 100644 index 0000000000..f9157784f3 --- /dev/null +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/feed/types/RenderConcordTimerNotice.kt @@ -0,0 +1,93 @@ +/* + * 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.ui.screen.loggedIn.chats.feed.types + +import androidx.compose.runtime.Composable +import com.vitorpamplona.amethyst.commons.chats.ui.ChatSystemMessage +import com.vitorpamplona.amethyst.commons.model.Note +import com.vitorpamplona.amethyst.commons.model.navigation.Route +import com.vitorpamplona.amethyst.commons.resources.Res +import com.vitorpamplona.amethyst.commons.resources.concord_timer_notice_off +import com.vitorpamplona.amethyst.commons.resources.concord_timer_notice_set +import com.vitorpamplona.amethyst.commons.resources.duration_days +import com.vitorpamplona.amethyst.commons.resources.duration_hours +import com.vitorpamplona.amethyst.commons.resources.duration_minutes +import com.vitorpamplona.amethyst.commons.resources.duration_weeks +import com.vitorpamplona.amethyst.commons.resources.duration_years +import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.commons.ui.note.UserPicture +import com.vitorpamplona.amethyst.commons.ui.pluralStringRes +import com.vitorpamplona.amethyst.commons.ui.stringRes +import com.vitorpamplona.amethyst.commons.ui.theme.Size18dp +import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel +import com.vitorpamplona.quartz.concord.cord03Channels.ConcordTimerNoticeEvent +import com.vitorpamplona.quartz.utils.TimeUtils + +/** + * A Concord timer notice (CORD-08 §4, kind 1740) as an inline system line: "Alice set disappearing + * messages to 30 days" / "Alice turned off disappearing messages", with the actor's avatar. The feed + * only reaches here for a notice whose author holds MANAGE_METADATA (see `Account.isAcceptable`). + */ +@Composable +fun RenderConcordTimerNotice( + note: Note, + accountViewModel: AccountViewModel, + nav: INav, +) { + val event = note.event as? ConcordTimerNoticeEvent ?: return + val secs = event.timerSecs() ?: return + val actor = observeUserNameByHex(event.pubKey, accountViewModel) + val text = + if (secs > 0) { + stringRes(Res.string.concord_timer_notice_set, actor, concordTimerText(secs)) + } else { + stringRes(Res.string.concord_timer_notice_off, actor) + } + + ChatSystemMessage( + text = text, + onClick = { nav.nav(Route.Profile(event.pubKey)) }, + leading = { + UserPicture( + userHex = event.pubKey, + size = Size18dp, + accountViewModel = accountViewModel, + nav = nav, + ) + }, + ) +} + +/** + * A disappearing-messages timer in words, in the largest whole unit that divides it: "1 year", + * "1 week", "30 days", "90 days" — the offered presets read the way staff picked them. + */ +@Composable +fun concordTimerText(seconds: Long): String { + val day = TimeUtils.ONE_DAY.toLong() + return when { + seconds >= 365 * day && seconds % (365 * day) == 0L -> (seconds / (365 * day)).toInt().let { pluralStringRes(Res.plurals.duration_years, it, it) } + seconds >= 7 * day && seconds % (7 * day) == 0L -> (seconds / (7 * day)).toInt().let { pluralStringRes(Res.plurals.duration_weeks, it, it) } + seconds >= day -> (seconds / day).toInt().let { pluralStringRes(Res.plurals.duration_days, it, it) } + seconds >= TimeUtils.ONE_HOUR -> (seconds / TimeUtils.ONE_HOUR).toInt().let { pluralStringRes(Res.plurals.duration_hours, it, it) } + else -> (seconds / TimeUtils.ONE_MINUTE).toInt().coerceAtLeast(1).let { pluralStringRes(Res.plurals.duration_minutes, it, it) } + } +} diff --git a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/publicChannels/concord/ConcordTimerPicker.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/publicChannels/concord/ConcordTimerPicker.kt new file mode 100644 index 0000000000..f365cd2968 --- /dev/null +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/publicChannels/concord/ConcordTimerPicker.kt @@ -0,0 +1,68 @@ +/* + * 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.ui.screen.loggedIn.chats.publicChannels.concord + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.selection.selectable +import androidx.compose.material3.RadioButton +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.commons.resources.Res +import com.vitorpamplona.amethyst.commons.resources.concord_timer_off +import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.feed.types.concordTimerText +import com.vitorpamplona.amethyst.commons.ui.stringRes +import com.vitorpamplona.quartz.concord.cord03Channels.ConcordDisappearing + +/** + * The staff picker for a community's disappearing-messages timer (CORD-08): Off plus the offered + * presets ([ConcordDisappearing.PRESET_SECS] — 1 day, 1 week, 30 days, 90 days, 1 year; never under a + * day). A timer another client set to a non-preset value is listed too, so the current choice is + * always visible. [selected] is in seconds, `0` = off. + */ +@Composable +fun ConcordTimerPicker( + selected: Long, + onSelect: (Long) -> Unit, + enabled: Boolean = true, +) { + val options = if (selected in ConcordDisappearing.PRESET_SECS) ConcordDisappearing.PRESET_SECS else (ConcordDisappearing.PRESET_SECS + selected).sorted() + Column(Modifier.fillMaxWidth()) { + options.forEach { secs -> + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = + Modifier + .fillMaxWidth() + .height(44.dp) + .selectable(selected = secs == selected, enabled = enabled, onClick = { onSelect(secs) }), + ) { + RadioButton(selected = secs == selected, onClick = { onSelect(secs) }, enabled = enabled) + Text(if (secs > 0) concordTimerText(secs) else stringRes(Res.string.concord_timer_off)) + } + } + } +} diff --git a/quartz/plans/2026-09-29-concord-spec-conformance.md b/quartz/plans/2026-09-29-concord-spec-conformance.md index b4a9ec4eb9..ca19145ccf 100644 --- a/quartz/plans/2026-09-29-concord-spec-conformance.md +++ b/quartz/plans/2026-09-29-concord-spec-conformance.md @@ -83,7 +83,7 @@ Ranked security > interop > feature inside each group. | # | Spec | Finding | Status | |---|---|---|---| | F1 | 04 §7 | Pins | open → pins batch | -| F2 | 08 | Disappearing Messages (sender tags, reader refusal/hiding/purge, 1740 notice, settings UI) | metadata field + parse **fixed**; the rest open → chat-plane batch | +| 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) | | F5 | 05 §5 | Invite Registry (vsk 8) not published or folded | open | diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord03Channels/ConcordDisappearing.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord03Channels/ConcordDisappearing.kt index 45ae8bd11b..95ac1bf5a3 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord03Channels/ConcordDisappearing.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord03Channels/ConcordDisappearing.kt @@ -20,6 +20,8 @@ */ package com.vitorpamplona.quartz.concord.cord03Channels +import com.vitorpamplona.quartz.concord.cord04Roles.AuthorityResolver +import com.vitorpamplona.quartz.concord.cord04Roles.ConcordPermissions import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.TagArray @@ -84,6 +86,24 @@ object ConcordDisappearing { return tags.filterNot { it.isNotEmpty() && it[0] == ExpirationTag.TAG_NAME }.toTypedArray() + ExpirationTag.assemble(expiration) } + /** + * The outer tags [rumor]'s kind-1059 wrap must carry (§2): the rumor's own expiration, repeated + * with the same value so NIP-40 relays delete the ciphertext, or nothing when the rumor carries + * none. Hand it to [com.vitorpamplona.quartz.concord.envelope.ConcordStreamEnvelope.wrap]'s + * `outerTags`. + */ + fun wrapTagsFor(rumor: Event): TagArray = expirationOf(rumor)?.let { arrayOf(ExpirationTag.assemble(it)) } ?: emptyArray() + + /** One day, the shortest timer a client should offer (§3: it dwarfs any honest clock skew). */ + const val MIN_OFFERED_SECS = 86_400L + + /** + * The timers a client offers staff, in seconds (`0` = off): off, 1 day, 1 week, 30 days, 90 days + * and 1 year — the reference client's presets. Nothing shorter than [MIN_OFFERED_SECS]. + */ + val PRESET_SECS: List = + listOf(0L, MIN_OFFERED_SECS, 7 * MIN_OFFERED_SECS, 30 * MIN_OFFERED_SECS, 90 * MIN_OFFERED_SECS, 365 * MIN_OFFERED_SECS) + /** The rumor's own expiration, the only one a reader judges by (§3). */ fun expirationOf(rumor: Event): Long? = rumor.tags.firstNotNullOfOrNull(ExpirationTag::parse) @@ -112,6 +132,16 @@ object ConcordDisappearing { }, ) + /** + * True when a reader may display timer notice [rumor] (§4): a well-formed 1740 whose author holds + * MANAGE_METADATA in the folded [authority] (the owner always does; a banned member never). Anyone + * can spell the tag; only staff are believed about policy, so everything else is dropped. + */ + fun isBelievedNotice( + rumor: Event, + authority: AuthorityResolver, + ): Boolean = noticeTimerSecs(rumor) != null && authority.hasPermission(rumor.pubKey, ConcordPermissions.MANAGE_METADATA) + /** * The timer a notice announces, in seconds (`0` = turned off), or null when [rumor] isn't a * well-formed notice — a malformed value is dropped, never guessed at. diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord03Channels/ConcordTimerNoticeEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord03Channels/ConcordTimerNoticeEvent.kt new file mode 100644 index 0000000000..7f04d3a629 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord03Channels/ConcordTimerNoticeEvent.kt @@ -0,0 +1,50 @@ +/* + * 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 androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey + +/** + * A Concord **timer notice** (CORD-08 §4, `kind:1740`): the inline "Alice set disappearing messages + * to 30 days" line an actor posts into each channel after changing the community's timer. Empty + * content, the channel binding, and one `["timer", ""]` tag (`0` = turned off). + * + * Informational only — the folded metadata is the authority — and believed only when its author + * holds MANAGE_METADATA in the fold; readers drop it otherwise. A notice never expires (§2). + */ +@Immutable +class ConcordTimerNoticeEvent( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, +) : Event(id, pubKey, createdAt, KIND, tags, content, sig) { + /** The announced timer in seconds (`0` = off), or null when the tag is missing or malformed. */ + fun timerSecs(): Long? = ConcordDisappearing.noticeTimerSecs(this) + + companion object { + const val KIND = ChannelChat.KIND_TIMER_NOTICE + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/envelope/ConcordStreamEnvelope.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/envelope/ConcordStreamEnvelope.kt index a2bda26d86..cad9d70478 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/envelope/ConcordStreamEnvelope.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/envelope/ConcordStreamEnvelope.kt @@ -93,13 +93,18 @@ object ConcordStreamEnvelope { * address, signed by the stream key and encrypted under its conversation key. * Adds a fresh ephemeral `["p", …]` tag. Use [KIND_WRAP_EPHEMERAL] via * [ephemeral] for transient traffic (typing, voice presence). + * + * [outerTags] are appended after the `p` tag. The only sanctioned one is the CORD-08 §2 + * `["expiration", …]` that a disappearing Chat rumor's wrap repeats for NIP-40 relays + * ([com.vitorpamplona.quartz.concord.cord03Channels.ConcordDisappearing.wrapTagsFor]). */ fun wrapSeal( seal: Event, stream: GroupKey, ephemeral: Boolean = false, createdAt: Long = TimeUtils.now(), - ): Event = wrapSeal(seal, stream, stream.conversationKey, ephemeral, createdAt) + outerTags: Array> = EMPTY_TAGS, + ): Event = wrapSeal(seal, stream, stream.conversationKey, ephemeral, createdAt, outerTags) /** * Write-restricted variant (CORD-01, Write-Restricted Streams): the wrap is @@ -114,12 +119,13 @@ object ConcordStreamEnvelope { readConversationKey: ByteArray, ephemeral: Boolean = false, createdAt: Long = TimeUtils.now(), + outerTags: Array> = EMPTY_TAGS, ): Event { val streamSigner = NostrSignerSync(KeyPair(privKey = signerKey.secretKey)) val content = encryptChecked(seal.toJson(), readConversationKey) val ephemeralP = KeyPair().pubKey.toHexKey() val kind = if (ephemeral) KIND_WRAP_EPHEMERAL else KIND_WRAP - return streamSigner.signNormal(createdAt, kind, arrayOf(arrayOf("p", ephemeralP)), content) + return streamSigner.signNormal(createdAt, kind, arrayOf(arrayOf("p", ephemeralP)) + outerTags, content) } /** @@ -138,7 +144,7 @@ object ConcordStreamEnvelope { return wrapSeal(seal, signer, keys.readKey.conversationKey, ephemeral, createdAt) } - /** Convenience: [seal] then [wrapSeal] in one call. */ + /** Convenience: [seal] then [wrapSeal] in one call. [outerTags] ride the wrap after its `p` tag. */ suspend fun wrap( rumor: Event, stream: GroupKey, @@ -146,7 +152,8 @@ object ConcordStreamEnvelope { encrypted: Boolean, ephemeral: Boolean = false, createdAt: Long = TimeUtils.now(), - ): Event = wrapSeal(seal(rumor, stream, authorSigner, encrypted), stream, ephemeral, createdAt) + outerTags: Array> = EMPTY_TAGS, + ): Event = wrapSeal(seal(rumor, stream, authorSigner, encrypted), stream, ephemeral, createdAt, outerTags) /** * Convenience for the Control Plane: seals under [keys]' read key (an encrypted diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt index 0c6598867e..60fbb1f759 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt @@ -101,6 +101,7 @@ import com.vitorpamplona.quartz.buzz.wpWorkspaceProfile.SetWorkspaceProfileEvent import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEvent import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListFragmentEvent import com.vitorpamplona.quartz.concord.cord03Channels.ConcordChatEditEvent +import com.vitorpamplona.quartz.concord.cord03Channels.ConcordTimerNoticeEvent import com.vitorpamplona.quartz.concord.cord04Roles.control.ControlEditionEvent import com.vitorpamplona.quartz.concord.cord05Invites.ConcordInviteListEvent import com.vitorpamplona.quartz.concord.cord05Invites.bundle.ConcordInviteBundleEvent @@ -467,6 +468,7 @@ class EventFactory { when (kind) { AcceptedBadgeSetEvent.KIND -> AcceptedBadgeSetEvent(id, pubKey, createdAt, tags, content, sig) ConcordChatEditEvent.KIND -> ConcordChatEditEvent(id, pubKey, createdAt, tags, content, sig) + ConcordTimerNoticeEvent.KIND -> ConcordTimerNoticeEvent(id, pubKey, createdAt, tags, content, sig) AdvertisedRelayListEvent.KIND -> AdvertisedRelayListEvent(id, pubKey, createdAt, tags, content, sig) CvmServerAnnouncementEvent.KIND -> CvmServerAnnouncementEvent(id, pubKey, createdAt, tags, content, sig) CvmToolsListEvent.KIND -> CvmToolsListEvent(id, pubKey, createdAt, tags, content, sig) diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord03Channels/ConcordDisappearingTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord03Channels/ConcordDisappearingTest.kt index 25352275fa..5151430887 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord03Channels/ConcordDisappearingTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord03Channels/ConcordDisappearingTest.kt @@ -20,11 +20,16 @@ */ package com.vitorpamplona.quartz.concord.cord03Channels +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.assertFalse +import kotlin.test.assertIs import kotlin.test.assertNull import kotlin.test.assertTrue @@ -69,4 +74,41 @@ class ConcordDisappearingTest { val bad = ChannelChat.message(author, channel, 4, "", 10, extraTags = arrayOf(arrayOf("timer", "04"))) assertNull(ConcordDisappearing.noticeTimerSecs(bad), "not a 1740") } + + @Test + fun timerNoticeParsesIntoItsOwnType() { + // A 1740 must land in the store as a typed event: an untyped Event is refused as unsupported. + val notice = ConcordDisappearing.timerNotice(author, channel, 4, 604_800, 10) + val parsed = Event.fromJson(notice.toJson()) + assertIs(parsed) + assertEquals(604_800L, parsed.timerSecs()) + } + + @Test + fun wrapCarriesTheRumorsExpirationBesideItsRandomP() = + runTest { + val alice = NostrSignerInternal(KeyPair()) + val plane = ConcordChannelKeys.publicChannel(ByteArray(32) { 0x5A }, ByteArray(32) { 0x42 }, 0) + val channelIdHex = ByteArray(32) { 0x42 }.toHexKey() + val exp = ConcordDisappearing.expirationFor(9, 1_000, 86_400) + val rumor = ChannelChat.message(alice.pubKey, channelIdHex, 0, "gm", 1_000, extraTags = ConcordDisappearing.withExpiration(emptyArray(), exp)) + + val wrap = ConcordStreamEnvelope.wrap(rumor, plane, alice, encrypted = true, outerTags = ConcordDisappearing.wrapTagsFor(rumor)) + // CORD-08 §2: the same value outside as inside, and the ephemeral `p` is still there. + assertEquals(listOf("p", "expiration"), wrap.tags.map { it[0] }) + assertEquals(64, wrap.tags[0][1].length) + assertEquals("87400", wrap.tags[1][1]) + assertEquals(87_400L, ConcordDisappearing.expirationOf(ConcordStreamEnvelope.open(wrap, plane).rumor)) + + // A rumor with no expiration gets a plain wrap: only the `p`. + val plain = ChannelChat.message(alice.pubKey, channelIdHex, 0, "forever", 1_000) + assertEquals(0, ConcordDisappearing.wrapTagsFor(plain).size) + assertEquals(listOf("p"), ConcordStreamEnvelope.wrap(plain, plane, alice, encrypted = true).tags.map { it[0] }) + } + + @Test + fun presetsNeverOfferLessThanADay() { + assertEquals(0L, ConcordDisappearing.PRESET_SECS.first()) + assertTrue(ConcordDisappearing.PRESET_SECS.drop(1).all { it >= ConcordDisappearing.MIN_OFFERED_SECS }) + } } From e36ccdf4b7487c83d7259c526cb5346330de7981 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 29 Sep 2026 16:39:45 +0000 Subject: [PATCH 3/7] docs(concord): mark the fragmented Community List done in the review Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01PmuzkD5qdqgv6dMeRMoC5N --- quartz/plans/2026-09-29-concord-spec-conformance.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/quartz/plans/2026-09-29-concord-spec-conformance.md b/quartz/plans/2026-09-29-concord-spec-conformance.md index ca19145ccf..9a6a041e16 100644 --- a/quartz/plans/2026-09-29-concord-spec-conformance.md +++ b/quartz/plans/2026-09-29-concord-spec-conformance.md @@ -59,7 +59,7 @@ Ranked security > interop > feature inside each group. | # | Spec | Finding | Status | |---|---|---|---| -| I1 | 02 §8 | Community List on retired 13302, hex, no fragments, no tombstones | in progress (this branch) | +| I1 | 02 §8 | Community List on retired 13302, hex, no fragments, no tombstones | **fixed** — fragmented kind 33302 (`ConcordListFragments`/`ConcordListFragmentSet`), unpadded base64url, seed/current rules, tombstone on leave, byte-identical to Armada's `listFrag.ts` (golden tests), 13302 read as a rescue source and migrated on the next write | | I2 | 02 §6 | Metadata/Channel edits rebuilt from scratch, wiping `custom`, `message_expiration` (CORD-08), `av_brokers` | **fixed** — `ConcordJson.encodePreserving` lays every edit over the authorized head; metadata/channel forms start from the folded entity | | I3 | 03 §2 | Per-channel `voice` flag still modeled and rendered (every Channel is callable since `23dcea5`) | **fixed** — field removed (rides through as an unknown key), Mic icon and blank-preview special case removed | | I4 | 04 §1/§5 | `vac` never written or verified — Armada drops every non-owner edition we author | **fixed** — `ConcordModeration` stamps every non-owner edition with `AuthorityCitations.forActor` (own grant coordinate, folded head version + hash); every fold gate (roles, grants, banlist, metadata, channels, unmodeled kinds, floors/compaction) requires it per Armada `citationSatisfied` | From 1ac131d6069c22fdc881fb2adfb19e5fa139ffb0 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 29 Sep 2026 16:50:21 +0000 Subject: [PATCH 4/7] feat(concord): pins UI, amy concord pins/pin/unpin, review row F1 App: Pin/Unpin tile in the Concord message action sheet for PIN_MESSAGES holders who can write the Control Plane; a pin badge in the channel header opens the pinned-messages sheet (author, time, text with edited marker, unavailable notice for a list sealed under an unheld key, budget line, jump-to-message when the rumor is held locally, unpin). While a channel is open, the delayed (3-15 s) re-read-then-publish duty drops author-erased entries and attaches newer Edit proofs. Refusals surface as toasts. CLI: amy concord pins COMMUNITY CHANNEL, pin/unpin COMMUNITY CHANNEL RUMOR_ID as thin wrappers over ConcordPinning. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01PmuzkD5qdqgv6dMeRMoC5N --- .../chats/feed/ChatMessageActionSheet.kt | 14 + .../concord/ConcordChannelScreen.kt | 27 ++ cli/README.md | 2 + .../cli/commands/ConcordChannelCommands.kt | 2 +- .../amethyst/cli/commands/ConcordCommands.kt | 9 +- .../cli/commands/ConcordModCommands.kt | 6 +- .../cli/commands/ConcordPinCommands.kt | 235 +++++++++++++ .../composeResources/values/strings.xml | 11 + .../concord/ConcordPinnedMessages.kt | 319 ++++++++++++++++++ .../commons/viewmodels/AccountViewModel.kt | 35 ++ .../2026-09-29-concord-spec-conformance.md | 2 +- 11 files changed, 656 insertions(+), 6 deletions(-) create mode 100644 cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ConcordPinCommands.kt create mode 100644 commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/publicChannels/concord/ConcordPinnedMessages.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatMessageActionSheet.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatMessageActionSheet.kt index 90a35e9cc8..2248fe8e99 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatMessageActionSheet.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatMessageActionSheet.kt @@ -376,6 +376,20 @@ fun ChatMessageActionSheet( if (relayGroup != null && !note.isDraft()) { RelayGroupPinTile(note, relayGroup, onDismiss, accountViewModel) } + + // 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) } + if (concordPinned != null && !note.isDraft()) { + SectionDivider() + TileRow { + val label = if (concordPinned) Res.string.relay_group_unpin_message else Res.string.relay_group_pin_message + ActionTile(MaterialSymbols.PushPin, stringRes(label)) { + accountViewModel.toggleConcordPin(note) + onDismiss() + } + } + } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordChannelScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordChannelScreen.kt index 92a08474f5..21e1cc4ccd 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordChannelScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordChannelScreen.kt @@ -42,6 +42,7 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableLongStateOf +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue @@ -83,7 +84,11 @@ import com.vitorpamplona.amethyst.commons.ui.feeds.WatchLifecycleAndUpdateModel 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.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 +import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.publicChannels.concord.rememberConcordChannelPins import com.vitorpamplona.amethyst.commons.ui.stringRes import com.vitorpamplona.amethyst.commons.ui.text.MentionPreservingInputTransformation import com.vitorpamplona.amethyst.commons.ui.theme.DoubleVertSpacer @@ -179,9 +184,29 @@ 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. + val pins by rememberConcordChannelPins(communityId, channelId, accountViewModel) + ConcordPinDuties(communityId, channelId, pins, accountViewModel) + var showPins by remember { mutableStateOf(false) } + val jumpToNoteId = remember { mutableStateOf(null) } + pins?.let { current -> + if (showPins) { + ConcordPinnedMessagesSheet( + communityId = communityId, + channelId = channelId, + pins = current, + accountViewModel = accountViewModel, + onJumpToMessage = { jumpToNoteId.value = it }, + onDismiss = { showPins = false }, + ) + } + } + Scaffold( topBar = { TopAppBar( + actions = { ConcordPinnedButton(pins) { showPins = true } }, title = { Column { Text(channel.toBestDisplayName(), maxLines = 1) @@ -218,6 +243,8 @@ fun ConcordChannelScreen( onWantsToReply = { newMessageModel.reply(it) }, onWantsToEditDraft = {}, onWantsToEditChatMessage = { newMessageModel.editConcordMessage(it) }, + jumpToNoteId = jumpToNoteId, + onJumpHandled = { jumpToNoteId.value = null }, // A status card at the oldest end: shows what it's reaching for while it pages and // crossfades to "All caught up" when every relay runs dry. olderBoundary = { diff --git a/cli/README.md b/cli/README.md index 519e277554..eddea0f582 100644 --- a/cli/README.md +++ b/cli/README.md @@ -689,6 +689,8 @@ also carried on-relay as the encrypted, fragmented kind:33302 Community List | `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 ban COMMUNITY USER` / `unban COMMUNITY USER` | Ban / unban a member. | +| `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 dissolve COMMUNITY --yes` | Owner only, irreversible: publish the `eid`-bound dissolution tombstone that seals the community read-only (CORD-02 §9). | ### cordn (MLS over an MCP coordinator) diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ConcordChannelCommands.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ConcordChannelCommands.kt index 312c3b88d3..f0fc54c3a5 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ConcordChannelCommands.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ConcordChannelCommands.kt @@ -198,7 +198,7 @@ object ConcordChannelCommands { } /** Resolve a channel handle: the `general` shortcut, a full hex id, or a folded name/id-prefix match. */ - private suspend fun resolve( + internal suspend fun resolve( ctx: Context, sc: StoredCommunity, ref: String, diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ConcordCommands.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ConcordCommands.kt index dd7a23c1f4..2b4664a17a 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ConcordCommands.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ConcordCommands.kt @@ -90,6 +90,10 @@ object ConcordCommands { | 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 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 + | its original seal, capped at 25 / 32 KiB + | concord unpin COMMUNITY CHANNEL RUMOR_ID unpin a message (the next edition without it) | concord unban COMMUNITY USER unban a member | concord refound COMMUNITY --remove U[,U] CORD-06 Refounding: rotate the root (and the | control_root) so removed members lose every @@ -105,7 +109,7 @@ object ConcordCommands { route( "concord", tail, - "concord ", + "concord ", help = USAGE, routes = mapOf( @@ -125,6 +129,9 @@ object ConcordCommands { "grant" to { rest -> ConcordModCommands.grant(dataDir, rest) }, "ban" to { rest -> ConcordModCommands.ban(dataDir, rest) }, "unban" to { rest -> ConcordModCommands.unban(dataDir, rest) }, + "pins" to { rest -> ConcordPinCommands.pins(dataDir, rest) }, + "pin" to { rest -> ConcordPinCommands.pin(dataDir, rest) }, + "unpin" to { rest -> ConcordPinCommands.unpin(dataDir, rest) }, "refound" to { rest -> ConcordModCommands.refound(dataDir, rest) }, "dissolve" to { rest -> ConcordModCommands.dissolve(dataDir, rest) }, ), diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ConcordModCommands.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ConcordModCommands.kt index 8e5102440c..81202d6fc8 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ConcordModCommands.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ConcordModCommands.kt @@ -240,7 +240,7 @@ object ConcordModCommands { * rewrites the stored record — a caller that kept the pre-load copy would then fail to pass the * secret on in its own Grant (CORD-04 §3). */ - private class LoadedControl( + internal class LoadedControl( val community: StoredCommunity, val keys: ControlPlaneKeys, val editions: List, @@ -551,7 +551,7 @@ object ConcordModCommands { } /** Drain the control plane and return its keys + current editions to chain onto. */ - private suspend fun load( + internal suspend fun load( ctx: Context, sc: StoredCommunity, dataDir: DataDir? = null, @@ -584,7 +584,7 @@ object ConcordModCommands { * a spam gate, never authority — holding the key still does not make the action * honored, which the Roster decides at fold (CORD-04 §5). */ - private fun writeGuard(cp: ControlPlaneKeys): Int? { + internal fun writeGuard(cp: ControlPlaneKeys): Int? { if (cp.canWrite) return null Output.error("forbidden", "this account holds no control_root for the community, so it cannot publish Control Plane editions (CORD-02 §2) — ask a staff member to grant you a Control-writing role") return 1 diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ConcordPinCommands.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ConcordPinCommands.kt new file mode 100644 index 0000000000..bbbf4e946c --- /dev/null +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ConcordPinCommands.kt @@ -0,0 +1,235 @@ +/* + * 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.ChannelPlane +import com.vitorpamplona.amethyst.commons.actions.ConcordActions +import com.vitorpamplona.amethyst.commons.actions.ConcordChannelPins +import com.vitorpamplona.amethyst.commons.actions.ConcordPinContext +import com.vitorpamplona.amethyst.commons.actions.ConcordPinEvidence +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.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.utils.TimeUtils + +/** + * `amy concord pins|pin|unpin` — a Channel's Pin List (CORD-04 §7). Thin assembly: the drain is + * here, the reading, verification, gating, caps and edition building are [ConcordPinning]'s. + */ +object ConcordPinCommands { + private val HEX64 = Regex("^[0-9a-f]{64}$") + + /** One channel's drained view: its planes, the wraps on them, and the evidence they carry. */ + private class ChannelView( + val planes: List, + val wraps: List, + val evidence: ConcordPinEvidence, + ) + + /** Drains every plane of [channelIdHex] this account holds (current + held prior epochs). */ + private suspend fun drainChannel( + ctx: Context, + sc: StoredCommunity, + state: ConcordCommunityState, + channelIdHex: String, + ): ChannelView { + val entry = ConcordCommands.entryFor(sc) + val isPrivate = state.channels[channelIdHex]?.definition?.private == true + val planes = listOfNotNull(ConcordActions.currentChannelPlane(entry, channelIdHex, isPrivate)) + ConcordActions.historicalChannelPlanes(entry, channelIdHex, isPrivate) + if (planes.isEmpty()) return ChannelView(planes, emptyList(), ConcordPinEvidence(emptyList())) + val relays = ConcordCommands.relaysFor(ctx, sc) + ctx.registerConcordStreamKeys(relays, planes.map { it.key.secretKey }) + val filter = ConcordActions.planeFilterFor(planes.map { it.key.publicKeyHex }) + val wraps = ctx.drain(relays.associateWith { listOf(filter) }, pendingOnAuthRequired = true).map { it.second } + val byAddress = planes.associateBy { it.key.publicKeyHex } + val rumors = wraps.mapNotNull { wrap -> byAddress[wrap.pubKey]?.let { ConcordActions.openChannelRumor(wrap, it.key, channelIdHex, it.epoch) } } + return ChannelView(planes, wraps, ConcordPinEvidence(rumors)) + } + + private fun read( + sc: StoredCommunity, + editions: List, + channelIdHex: String, + view: ChannelView, + ): ConcordChannelPins { + val head = ConcordPinning.headFor(editions, sc.communityId, sc.owner, channelIdHex) + return ConcordPinning.read( + head, + channelIdHex, + unsealKey = { epoch -> + view.planes + .firstOrNull { it.epoch == epoch } + ?.key + ?.conversationKey + }, + isKilled = view.evidence::isKilled, + newestEdit = view.evidence::newestEdit, + ) + } + + private fun render(pins: ConcordChannelPins): Map = + mapOf( + "channel" to pins.channelIdHex, + "version" to pins.head?.version, + "count" to pins.count, + // Unreadable is not empty: the list is sealed under an epoch key this account never held. + "sealed_unavailable" to pins.sealedUnavailable, + "sealed" to pins.sealedForm, + "violating" to pins.violating, + "invalid_entries" to pins.invalidEntries, + "deleted" to pins.killed.map { it.rumorId }, + "pins" to + pins.pins.map { + mapOf( + "rumor_id" to it.rumorId, + "author" to it.author, + "kind" to it.pin.kind, + "content" to it.content, + "created_at" to it.pin.createdAt, + "edited" to it.edited, + // A newer Edit this account holds but the entry cannot prove yet. + "stale_edit" to (it.newerEdit != null), + "epoch" to it.pin.epoch, + "wrap" to it.pin.wrapHint, + ) + }, + ) + + /** `concord pins COMMUNITY CHANNEL` — the verified Pin List. */ + suspend fun pins( + dataDir: DataDir, + rest: Array, + ): 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 loaded = ConcordModCommands.load(ctx, sc) + val state = ConcordCommunityState.fold(loaded.editions, sc.communityId.hexToByteArray(), sc.owner) + val channelId = ConcordChannelCommands.resolve(ctx, sc, channelRef) ?: return Output.error("not_found", "no channel '$channelRef'") + if (channelId !in state.channels) return Output.error("not_found", "channel '$channelRef' is not folded") + val view = drainChannel(ctx, sc, state, channelId) + Output.emit(render(read(sc, loaded.editions, channelId, view))) + return 0 + } + } + + /** `concord pin COMMUNITY CHANNEL RUMOR_ID` */ + suspend fun pin( + dataDir: DataDir, + rest: Array, + ): Int = write(dataDir, rest, pin = true) + + /** `concord unpin COMMUNITY CHANNEL RUMOR_ID` */ + suspend fun unpin( + dataDir: DataDir, + rest: Array, + ): Int = write(dataDir, rest, pin = false) + + private suspend fun write( + dataDir: DataDir, + rest: Array, + pin: Boolean, + ): Int { + val args = Args(rest) + val handle = args.positional(0, "community") + val channelRef = args.positional(1, "channel") + val rumorId = args.positional(2, "rumor_id").lowercase() + 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) + + Context.open(dataDir).use { ctx -> + ctx.prepare() + // CORD-02 §9: after Dissolution no edition can land. + if (ConcordCommands.isDissolved(ctx, stored)) return Output.error("dissolved", "community '$handle' has been dissolved and is read-only (CORD-02 §9)") + val loaded = ConcordModCommands.load(ctx, stored, dataDir) + val sc = loaded.community + ConcordModCommands.writeGuard(loaded.keys)?.let { return it } + val communityId = sc.communityId.hexToByteArray() + val state = ConcordCommunityState.fold(loaded.editions, communityId, sc.owner) + val channelId = ConcordChannelCommands.resolve(ctx, sc, channelRef) ?: return Output.error("not_found", "no channel '$channelRef'") + val definition = state.channels[channelId]?.definition ?: return Output.error("not_found", "channel '$channelRef' is not folded") + val view = drainChannel(ctx, sc, state, channelId) + val me = ctx.signer.pubKey + val pinCtx = + ConcordPinContext( + actor = ctx.signer, + controlPlane = loaded.keys, + communityId = communityId, + owner = sc.owner, + current = loaded.editions, + channelIdHex = channelId, + channelIsPrivate = definition.private, + currentPlane = ConcordActions.currentChannelPlane(ConcordCommands.entryFor(sc), state, channelId), + pins = read(sc, loaded.editions, channelId, view), + authorized = sc.owner.equals(me, ignoreCase = true) || state.authority.hasPermission(me, ConcordPermissions.PIN_MESSAGES), + ) + val result = + if (pin) { + val refused = ConcordPinning.refusal(pinCtx) + val source = if (refused == null) ConcordPinning.sourceFrom(view.wraps, view.planes, rumorId) else null + when { + refused != null -> ConcordPinWrite(refused) + source == null -> ConcordPinWrite(ConcordPinOutcome.MESSAGE_UNAVAILABLE) + else -> ConcordPinning.pin(pinCtx, source, TimeUtils.now()) + } + } else { + ConcordPinning.unpin(pinCtx, rumorId, TimeUtils.now()) + } + val wrap = result.wrap ?: return Output.error(result.outcome.name.lowercase(), refusalDetail(result.outcome)) + val ack = ctx.publish(wrap, ConcordCommands.relaysFor(ctx, sc)) + RawEventSupport.publishGuard(ack, wrap.id)?.let { return it } + Output.emit(mapOf("channel" to channelId, "rumor_id" to rumorId, "pinned" to pin, "entries" to result.entries.size, "event_id" to wrap.id) + RawEventSupport.ackFields(ack)) + return 0 + } + } + + private fun refusalDetail(outcome: ConcordPinOutcome): String = + when (outcome) { + ConcordPinOutcome.ALREADY_PINNED -> "that message is already pinned" + ConcordPinOutcome.NOT_PINNED -> "that message is not pinned" + ConcordPinOutcome.NOT_AUTHORIZED -> "pinning takes PIN_MESSAGES (or ownership) (CORD-04 §3)" + ConcordPinOutcome.NO_WRITE_KEY -> "no control_root held for this epoch (CORD-02 §2)" + ConcordPinOutcome.NO_CHANNEL_KEY -> "private channel and this account holds no key for it, so the list cannot be sealed" + ConcordPinOutcome.LIST_UNAVAILABLE -> "the Pin List is sealed under a key this account never held; writing would drop pins it cannot see (CORD-04 §7)" + ConcordPinOutcome.MESSAGE_UNAVAILABLE -> "no held wrap carries that rumor, so its seal cannot be proven" + ConcordPinOutcome.UNVERIFIABLE -> "the message would not verify as a pin (only kind 9 / 1111 messages can be pinned)" + ConcordPinOutcome.TOO_MANY_PINS -> "the list already has 25 pins; unpin one first" + ConcordPinOutcome.TOO_LARGE -> "the list would exceed 32,768 bytes; unpin one first" + else -> outcome.name.lowercase() + } +} diff --git a/commonsUI/src/commonMain/composeResources/values/strings.xml b/commonsUI/src/commonMain/composeResources/values/strings.xml index 1003a3ee26..a1bfc9dab4 100644 --- a/commonsUI/src/commonMain/composeResources/values/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values/strings.xml @@ -600,6 +600,17 @@ Where this community's encrypted planes are published and read. This community has been dissolved and is now read-only. You can still read its history, but no new messages can be posted. This is a private channel and you don't hold its key, so you can't read it or post here. + Pinned messages + No pinned messages in this channel yet. + 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. + %1$d of %2$d pins · %3$d% of the size budget used + Tap a pin to jump to it + Pins + The pinned list is sealed under a key you don't hold. Changing it now would drop pins you can't see. + This channel already has 25 pins. Unpin one first. + The pinned list is out of room. Unpin a message first. + This message's original signature isn't held here, so it can't be proven and pinned. + You can't change this channel's pins right now. Name About (optional) Ban diff --git a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/publicChannels/concord/ConcordPinnedMessages.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/publicChannels/concord/ConcordPinnedMessages.kt new file mode 100644 index 0000000000..9bf91f3b60 --- /dev/null +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/publicChannels/concord/ConcordPinnedMessages.kt @@ -0,0 +1,319 @@ +/* + * 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.ui.screen.loggedIn.chats.publicChannels.concord + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +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.Badge +import androidx.compose.material3.BadgedBox +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.Text +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.produceState +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +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 com.vitorpamplona.amethyst.commons.actions.ConcordChannelPins +import com.vitorpamplona.amethyst.commons.actions.ConcordPinnedMessage +import com.vitorpamplona.amethyst.commons.actions.ConcordPinning +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.relayClient.user.observeUserInfo +import com.vitorpamplona.amethyst.commons.resources.Res +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 +import com.vitorpamplona.amethyst.commons.resources.concord_pinned_title +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.note.timeAgoNoDot +import com.vitorpamplona.amethyst.commons.ui.stringRes +import com.vitorpamplona.amethyst.commons.ui.theme.placeholderText +import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel +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 kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.merge +import kotlinx.coroutines.withContext +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. + */ +@Composable +fun rememberConcordChannelPins( + communityId: String, + channelId: String, + accountViewModel: AccountViewModel, +): State { + val account = accountViewModel.account + return produceState(null, account, communityId, channelId) { + val session = account.concordSessions.sessionFor(communityId) ?: return@produceState + val evidence = + account.cache.live.newEventBundles.filter { notes -> + notes.any { it.event is DeletionRequestEvent || it.event is ConcordChatEditEvent } + } + 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. + */ +@Composable +fun ConcordPinDuties( + communityId: String, + channelId: String, + 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() } + 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) } + } +} + +/** The channel header's pinned-messages entry point: a pin with a count badge. Hidden when there is nothing to show. */ +@Composable +fun ConcordPinnedButton( + pins: ConcordChannelPins?, + onClick: () -> Unit, +) { + if (pins == null || (pins.count == 0 && !pins.sealedUnavailable)) return + IconButton(onClick = onClick) { + BadgedBox( + badge = { + if (pins.count > 0) Badge { Text(pins.count.toString()) } + }, + ) { + Icon(symbol = MaterialSymbols.PushPin, contentDescription = stringRes(Res.string.relay_group_pinned_content_description)) + } + } +} + +/** + * 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. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ConcordPinnedMessagesSheet( + communityId: String, + channelId: String, + pins: ConcordChannelPins, + accountViewModel: AccountViewModel, + onJumpToMessage: (HexKey) -> Unit, + onDismiss: () -> Unit, +) { + val canPin = remember(pins) { accountViewModel.account.concord.canPinConcord(communityId) } + val session = remember(communityId) { accountViewModel.account.concordSessions.sessionFor(communityId) } + + ModalBottomSheet( + onDismissRequest = onDismiss, + sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true), + ) { + Column(Modifier.fillMaxWidth().padding(bottom = 24.dp)) { + Text( + text = stringRes(Res.string.concord_pinned_title), + style = MaterialTheme.typography.titleMedium, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp), + ) + if (canPin && !pins.sealedUnavailable) { + // Bytes, not the count, are the real ceiling for a sealed list (§7 Limits): surface both. + val bytes = + remember(pins) { + pins.head + ?.content + ?.encodeToByteArray() + ?.size ?: 0 + } + Text( + text = stringRes(Res.string.concord_pinned_budget, pins.count, ConcordPins.MAX_ENTRIES, bytes * 100 / ConcordPins.MAX_CONTENT_BYTES), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.placeholderText, + modifier = Modifier.padding(horizontal = 16.dp), + ) + } + if (pins.count > 0) { + Text( + text = stringRes(Res.string.concord_pinned_open_hint), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.placeholderText, + modifier = Modifier.padding(horizontal = 16.dp), + ) + } + Spacer(Modifier.height(8.dp)) + + if (pins.sealedUnavailable) { + PinNotice(Res.string.concord_pinned_unavailable, MaterialSymbols.Lock) + } else if (pins.count == 0) { + PinNotice(Res.string.concord_pinned_empty, MaterialSymbols.PushPin) + } + + LazyColumn { + items(pins.pins, key = { it.rumorId }) { pinned -> + val jumpable = remember(pinned.rumorId, session) { session?.holdsRumor(pinned.rumorId) == true } + PinnedRow( + pinned = pinned, + accountViewModel = accountViewModel, + onClick = + if (jumpable) { + { + onJumpToMessage(pinned.rumorId) + onDismiss() + } + } else { + null + }, + onUnpin = if (canPin) ({ accountViewModel.unpinConcordRumor(communityId, channelId, pinned.rumorId) }) else null, + ) + HorizontalDivider() + } + } + } + } +} + +@Composable +private fun PinNotice( + text: StringResource, + symbol: MaterialSymbol, +) { + Row( + modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp), + ) { + Icon(symbol = symbol, contentDescription = null, tint = MaterialTheme.colorScheme.placeholderText, modifier = Modifier.size(20.dp)) + Text(text = stringRes(text), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.placeholderText) + } +} + +@Composable +private fun PinnedRow( + pinned: ConcordPinnedMessage, + accountViewModel: AccountViewModel, + onClick: (() -> Unit)?, + onUnpin: (() -> Unit)?, +) { + Row( + modifier = + Modifier + .fillMaxWidth() + .let { if (onClick != null) it.clickable(onClick = onClick) else it } + .padding(start = 16.dp, end = 4.dp, top = 10.dp, bottom = 10.dp), + verticalAlignment = Alignment.Top, + ) { + Column(Modifier.weight(1f)) { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp)) { + Text( + text = rememberPinAuthorName(pinned.author, accountViewModel), + style = MaterialTheme.typography.labelLarge, + fontWeight = FontWeight.Bold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f, fill = false), + ) + Text( + text = timeAgoNoDot(pinned.pin.createdAt), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.placeholderText, + ) + if (pinned.edited) { + // §7: a revised message is never shown as if its words were the original, current ones. + Text( + text = stringRes(Res.string.message_edited), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.placeholderText, + ) + } + } + Text( + text = pinned.content, + style = MaterialTheme.typography.bodyMedium, + maxLines = 6, + overflow = TextOverflow.Ellipsis, + ) + } + if (onUnpin != null) { + IconButton(onClick = onUnpin) { + Icon(symbol = MaterialSymbols.Close, contentDescription = stringRes(Res.string.relay_group_unpin_message), modifier = Modifier.size(18.dp)) + } + } + } +} + +/** [hex]'s best display name, reactively, falling back to a short hex. */ +@Composable +private fun rememberPinAuthorName( + hex: HexKey, + accountViewModel: AccountViewModel, +): String { + val user = remember(hex) { LocalCache.checkGetOrCreateUser(hex) } ?: return remember(hex) { hex.take(8) } + val info by observeUserInfo(user, accountViewModel) + return info?.info?.bestName() ?: remember(user) { user.pubkeyDisplayHex() } +} diff --git a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/AccountViewModel.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/AccountViewModel.kt index d29cb0a26a..b72017d6d4 100644 --- a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/AccountViewModel.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/AccountViewModel.kt @@ -26,6 +26,7 @@ import androidx.compose.runtime.Stable import androidx.compose.runtime.rememberCoroutineScope import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.vitorpamplona.amethyst.commons.actions.ConcordPinOutcome import com.vitorpamplona.amethyst.commons.audio.VisualizerStyle import com.vitorpamplona.amethyst.commons.cashu.ops.describeMintError import com.vitorpamplona.amethyst.commons.chats.rooms.markRoomNoteAsRead @@ -76,6 +77,12 @@ 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_members_roles_failed import com.vitorpamplona.amethyst.commons.resources.concord_members_roles_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 +import com.vitorpamplona.amethyst.commons.resources.concord_pin_failed_too_large +import com.vitorpamplona.amethyst.commons.resources.concord_pin_failed_too_many +import com.vitorpamplona.amethyst.commons.resources.concord_pin_failed_unavailable import com.vitorpamplona.amethyst.commons.resources.draft_note import com.vitorpamplona.amethyst.commons.resources.error_dialog_zap_error import com.vitorpamplona.amethyst.commons.resources.it_s_not_possible_to_quote_to_a_draft_note @@ -597,6 +604,34 @@ class AccountViewModel( } } + /** Pin or unpin Concord message [note] (CORD-04 §7, PIN_MESSAGES holders); a refusal surfaces as a toast. */ + fun toggleConcordPin(note: Note) { + val pinned = account.concord.concordPinState(note) ?: return + launchSigner { + toastConcordPinOutcome(if (pinned) account.concord.unpinConcordMessage(note) else account.concord.pinConcordMessage(note)) + } + } + + /** Unpin the entry [rumorId] from [channelIdHex]'s Pin List — also for a pin whose message this account never held. */ + fun unpinConcordRumor( + communityId: String, + channelIdHex: String, + rumorId: HexKey, + ) = launchSigner { toastConcordPinOutcome(account.concord.unpinConcordRumor(communityId, channelIdHex, rumorId)) } + + private fun toastConcordPinOutcome(outcome: ConcordPinOutcome) { + val message = + when (outcome) { + ConcordPinOutcome.PUBLISHED, ConcordPinOutcome.ALREADY_PINNED, ConcordPinOutcome.NOT_PINNED, ConcordPinOutcome.NOTHING_TO_DO -> return + ConcordPinOutcome.LIST_UNAVAILABLE -> Res.string.concord_pin_failed_unavailable + ConcordPinOutcome.TOO_MANY_PINS -> Res.string.concord_pin_failed_too_many + ConcordPinOutcome.TOO_LARGE -> Res.string.concord_pin_failed_too_large + ConcordPinOutcome.MESSAGE_UNAVAILABLE, ConcordPinOutcome.UNVERIFIABLE -> Res.string.concord_pin_failed_message + else -> Res.string.concord_pin_failed_generic + } + toastManager.toast(Res.string.concord_pin_failed_title, message) + } + /** Promote/demote [member] as an Admin of [communityId] (from the Members roster; owner only takes effect). */ fun setConcordAdmin( communityId: String, diff --git a/quartz/plans/2026-09-29-concord-spec-conformance.md b/quartz/plans/2026-09-29-concord-spec-conformance.md index b4a9ec4eb9..6b75920a45 100644 --- a/quartz/plans/2026-09-29-concord-spec-conformance.md +++ b/quartz/plans/2026-09-29-concord-spec-conformance.md @@ -82,7 +82,7 @@ Ranked security > interop > feature inside each group. | # | Spec | Finding | Status | |---|---|---|---| -| F1 | 04 §7 | Pins | open → pins batch | +| 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 | | F2 | 08 | Disappearing Messages (sender tags, reader refusal/hiding/purge, 1740 notice, settings UI) | metadata field + parse **fixed**; the rest open → chat-plane batch | | 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) | From d73348d19bd8e0b620fd7bca20de0eb0e9cb202e Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 29 Sep 2026 18:11:18 +0000 Subject: [PATCH 5/7] =?UTF-8?q?feat(concord):=20CORD-05=20=C2=A76=20Direct?= =?UTF-8?q?=20Invites=20=E2=80=94=20wire=20fixes,=20send,=20and=20a=20head?= =?UTF-8?q?less=20inbox?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit quartz: - ConcordDirectInvite: NIP-59 timestamp tweak (seal and wrap backdated up to 2 days, rumor keeps the send time), NIP-40 expiration on the wrap matching expires_at, open()/openSeal() returning the seal-verified sender, rejecting a rumor whose pubkey differs from the seal's (anti-spoofing), an unverified seal, a non-3313 rumor, and bundles failing the §1 bounds or the owner proof. - ConcordInviteVend: the vend rule (a link gets no Private Channel keys, a member exactly what their channel-scoped Roles entitle) and the catch-up rule for an already-joined community (new keys only on the same root, epoch and control_pk; the base never moves). commons: - ConcordActions.directInviteFor/buildDirectInvite/openDirectInvite, directInviteDeliveryRelays (10050, else NIP-65 read, else stock), and a since parameter on directInvitesFilter. - ConcordDirectInviteInbox: dedupe by wrap id, skip expired wraps, park validated invites, remember declines; visible() hides joined communities but keeps catch-ups; acceptPlan() decides join / catch-up / refuse. - AccountConcordActions: sendConcordDirectInvite, refreshConcordDirectInvites, acceptConcordDirectInvite (shares the link join path, now factored into joinValidatedConcordInvite), declineConcordDirectInvite. - The NIP-17 seal handler routes a kind-3313 rumor to the inbox instead of the cache/chat feeds; declined wrap ids persist per account. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01PmuzkD5qdqgv6dMeRMoC5N --- .../com/vitorpamplona/amethyst/AppModules.kt | 3 + .../commons/actions/ConcordActions.kt | 77 ++++- .../commons/model/AccountConcordActions.kt | 219 +++++++++++++- .../model/ConcordDirectInviteSendResult.kt | 45 +++ .../commons/model/DecryptAndIndexProcessor.kt | 12 + .../model/concord/ConcordDirectInviteInbox.kt | 278 ++++++++++++++++++ .../ConcordDirectInviteDeclineStore.kt | 82 ++++++ .../actions/ConcordDirectInviteActionsTest.kt | 158 ++++++++++ .../concord/ConcordDirectInviteInboxTest.kt | 263 +++++++++++++++++ .../cord02Community/ConcordCommunityList.kt | 23 ++ .../cord05Invites/ConcordDirectInvite.kt | 154 ++++++++-- .../cord05Invites/ConcordInviteVend.kt | 141 +++++++++ .../cord05Invites/ConcordDirectInviteTest.kt | 188 +++++++++++- .../cord05Invites/ConcordInviteVendTest.kt | 117 ++++++++ 14 files changed, 1711 insertions(+), 49 deletions(-) create mode 100644 commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/ConcordDirectInviteSendResult.kt create mode 100644 commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordDirectInviteInbox.kt create mode 100644 commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/ConcordDirectInviteDeclineStore.kt create mode 100644 commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordDirectInviteActionsTest.kt create mode 100644 commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordDirectInviteInboxTest.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordInviteVend.kt create mode 100644 quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordInviteVendTest.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt index 0e857e8564..c68b4cfb12 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt @@ -43,6 +43,7 @@ import com.vitorpamplona.amethyst.commons.model.preferences.AppPreferenceStores import com.vitorpamplona.amethyst.commons.model.preferences.BuzzAttestationStore import com.vitorpamplona.amethyst.commons.model.preferences.BuzzChannelStarStore import com.vitorpamplona.amethyst.commons.model.preferences.BuzzWorkspaceStore +import com.vitorpamplona.amethyst.commons.model.preferences.ConcordDirectInviteDeclineStore import com.vitorpamplona.amethyst.commons.model.preferences.DrawerSectionCollapsePreferences import com.vitorpamplona.amethyst.commons.model.preferences.NamecoinSettingsStore import com.vitorpamplona.amethyst.commons.model.preferences.OtsSettingsStore @@ -1070,6 +1071,8 @@ class AppModules( // Eager like the rest, so a held NIP-OA attestation is loaded before this account's // first Buzz-relay AUTH rather than after it. BuzzAttestationStore(sharedSettingsStore, account.scope, account.pubKey, account.buzzAttestation) + // Concord Direct Invites the user declined (CORD-05 §6) stay declined across restarts. + ConcordDirectInviteDeclineStore(sharedSettingsStore, account.scope, account.pubKey, account.concord.directInviteInbox) }, ) diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordActions.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordActions.kt index 40ff004467..86f7a9d918 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordActions.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordActions.kt @@ -33,14 +33,18 @@ import com.vitorpamplona.quartz.concord.cord02Community.PrivateChannelKey import com.vitorpamplona.quartz.concord.cord03Channels.ChannelChat import com.vitorpamplona.quartz.concord.cord03Channels.ConcordChannelKeys 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.cord05Invites.CommunityInvite import com.vitorpamplona.quartz.concord.cord05Invites.ConcordDirectInvite import com.vitorpamplona.quartz.concord.cord05Invites.ConcordInviteBundle import com.vitorpamplona.quartz.concord.cord05Invites.ConcordInviteLink +import com.vitorpamplona.quartz.concord.cord05Invites.ConcordInviteVend import com.vitorpamplona.quartz.concord.cord05Invites.ConcordStrandedRecovery import com.vitorpamplona.quartz.concord.cord05Invites.InviteBundleStatus +import com.vitorpamplona.quartz.concord.cord05Invites.InviteRelayDictionary import com.vitorpamplona.quartz.concord.cord05Invites.MintedInviteLink +import com.vitorpamplona.quartz.concord.cord05Invites.OpenedDirectInvite import com.vitorpamplona.quartz.concord.cord05Invites.ParsedInviteLink import com.vitorpamplona.quartz.concord.cord05Invites.bundle.ConcordInviteBundleEvent import com.vitorpamplona.quartz.concord.cord06Rekey.ConcordRefounding @@ -50,11 +54,15 @@ import com.vitorpamplona.quartz.concord.crypto.ConcordKeyDerivation import com.vitorpamplona.quartz.concord.crypto.ControlPlaneKeys import com.vitorpamplona.quartz.concord.crypto.GroupKey import com.vitorpamplona.quartz.concord.envelope.ConcordStreamEnvelope +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.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent import com.vitorpamplona.quartz.nip92IMeta.IMetaTag import com.vitorpamplona.quartz.nipC7Chats.ChatEvent import com.vitorpamplona.quartz.utils.TimeUtils @@ -316,8 +324,15 @@ object ConcordActions { */ fun bundlesFilter(linkSignerPubKeyHexes: List): Filter = Filter(kinds = listOf(ConcordInviteBundleEvent.KIND), authors = linkSignerPubKeyHexes) - /** Pending direct invites addressed to the given member (indexed by k=3313). */ - fun directInvitesFilter(memberPubKeyHex: HexKey): Filter = Filter(kinds = listOf(ConcordStreamEnvelope.KIND_WRAP), tags = mapOf("p" to listOf(memberPubKeyHex), "k" to listOf(ConcordDirectInvite.KIND.toString()))) + /** + * Pending direct invites addressed to the given member (indexed by k=3313, CORD-05 §6). [since] + * should come from [ConcordDirectInvite.inboxSince]: wraps are backdated up to two days, so a + * cursor at the newest wrap seen would miss invites published after it. + */ + fun directInvitesFilter( + memberPubKeyHex: HexKey, + since: Long? = null, + ): Filter = Filter(kinds = listOf(ConcordStreamEnvelope.KIND_WRAP), tags = mapOf("p" to listOf(memberPubKeyHex), "k" to listOf(ConcordDirectInvite.KIND.toString())), since = since) // ---- community lifecycle -------------------------------------------------- @@ -583,6 +598,64 @@ object ConcordActions { label = label, ) + /** + * The §1 bundle a Direct Invite hands [recipient] for the community [entry] holds (CORD-05 §6): + * the current base, epoch and `control_pk`, the relays, a name/icon preview, the optional + * [expiresAtMs] (unix ms) and [creator] attribution — and exactly the Private Channel keys the + * recipient's Roles entitle them to in [authority] ([ConcordInviteVend.vendableChannels], Armada's + * `VendAudience` "member" rule). A key the recipient isn't entitled to is never whispered, even + * though nothing on the wire could stop it. + */ + fun directInviteFor( + entry: ConcordCommunityListEntry, + authority: AuthorityResolver, + recipient: HexKey, + creator: HexKey, + expiresAtMs: Long? = null, + name: String = entry.name, + icon: ImagePointer? = null, + ): CommunityInvite = + CommunityInvite( + communityId = entry.id, + owner = entry.owner, + ownerSalt = entry.ownerSalt, + communityRoot = entry.root, + rootEpoch = entry.rootEpoch, + controlPk = entry.controlPk, + channels = ConcordInviteVend.toInviteChannels(ConcordInviteVend.vendableChannels(entry.privateChannels, authority, recipient)), + relays = entry.relays.take(ConcordInviteBundle.MAX_COMMUNITY_RELAYS), + name = name.ifBlank { entry.name }, + icon = icon, + expiresAt = expiresAtMs, + creatorNpub = creator, + ) + + /** Giftwraps [invite] to [recipient] as a Direct Invite (see [ConcordDirectInvite.build]). */ + suspend fun buildDirectInvite( + senderSigner: NostrSigner, + recipient: HexKey, + invite: CommunityInvite, + createdAt: Long = TimeUtils.now(), + ): GiftWrapEvent = ConcordDirectInvite.build(senderSigner, recipient, invite, createdAt) + + /** Opens + validates a Direct Invite wrap addressed to [recipientSigner] (see [ConcordDirectInvite.open]). */ + suspend fun openDirectInvite( + wrap: Event, + recipientSigner: NostrSigner, + ): OpenedDirectInvite? = ConcordDirectInvite.open(wrap, recipientSigner) + + /** + * Where a Direct Invite reaches a member, and where that member scans for one (CORD-05 §6): + * their kind-10050 DM relays, else their NIP-65 read relays, else the stock Concord set every + * client ships (Armada `inviteDeliveryRelays`). Send and scan share this so both sides meet. The + * stock set is fallback-only: a curated private inbox is never also fanned out to public relays. + */ + fun directInviteDeliveryRelays(lists: RecipientRelayFetcher.Lists?): Set { + val inbox = lists?.dmInboxOrFallback().orEmpty() + if (inbox.isNotEmpty()) return inbox.toSet() + return InviteRelayDictionary.STOCK.mapNotNullTo(LinkedHashSet()) { RelayUrlNormalizer.normalizeOrNull(it) } + } + /** Mints a shareable public invite link + bundle event (see [ConcordInviteBundle.mintLink]). */ fun mintInviteLink( base: String, diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/AccountConcordActions.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/AccountConcordActions.kt index 0ebe09d709..e844c8b791 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/AccountConcordActions.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/AccountConcordActions.kt @@ -24,12 +24,16 @@ import com.vitorpamplona.amethyst.commons.actions.ConcordActions import com.vitorpamplona.amethyst.commons.actions.ConcordModeration import com.vitorpamplona.amethyst.commons.actions.ConcordReceive import com.vitorpamplona.amethyst.commons.actions.ConcordSubscriptionPlanner +import com.vitorpamplona.amethyst.commons.defaults.DefaultDmIndexerRelays import com.vitorpamplona.amethyst.commons.model.ConcordInviteResult import com.vitorpamplona.amethyst.commons.model.Note import com.vitorpamplona.amethyst.commons.model.cache.filter import com.vitorpamplona.amethyst.commons.model.concord.ConcordChannel 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.DirectInviteAcceptPlan import com.vitorpamplona.amethyst.commons.model.concordChannelLastReadRoute import com.vitorpamplona.amethyst.commons.util.ConcurrentSet import com.vitorpamplona.amethyst.commons.viewmodels.ReplyMode @@ -65,6 +69,7 @@ import com.vitorpamplona.quartz.concord.cord06Rekey.ReceivedRefounding import com.vitorpamplona.quartz.concord.crypto.ControlPlaneKeys import com.vitorpamplona.quartz.concord.crypto.GroupKey import com.vitorpamplona.quartz.concord.envelope.ConcordStreamEnvelope +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 @@ -90,8 +95,11 @@ import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.stateIn /** Name of the default Concord community Admin role minted by "Make admin". */ private const val CONCORD_ADMIN_ROLE = "Admin" @@ -559,6 +567,38 @@ class AccountConcordActions( InviteBundleStatus.Absent -> return ConcordInviteResult.NotReachable } + return joinValidatedConcordInvite( + bundle = bundle, + servedBy = relays, + // Anchor for stranded recovery: keep the link we joined through, domain-agnostic, so a + // Refounding that leaves us out of the recipient set is recoverable later. See + // recoverStrandedConcordCommunities(). + inviteRef = ConcordActions.bareInviteRef(url), + // Invite attribution (CORD-05 §1): the joiner echoes the link's creator + label in their + // Guestbook Join, which is what makes per-link usage counters possible. + inviteCreator = bundle.creatorNpub, + inviteLabel = bundle.label, + ) + } + + /** + * The join half shared by every redeem path (link [joinConcordViaInvite], Direct Invite + * [acceptConcordDirectInvite]): [bundle] is already opened, bounded and owner-proof validated, + * and not expired. An already-held community only moves forward through a stranded rejoin (a + * Refounding left us behind and the user re-accepted); otherwise it refuses a community whose + * roster bans us (fails closed on an unreadable Control Plane, fetched over [servedBy] ∪ the + * bundle's relays), then stores the secret-bearing entry and announces the Guestbook Join with + * [inviteCreator]/[inviteLabel] attribution. + */ + private suspend fun joinValidatedConcordInvite( + bundle: CommunityInvite, + servedBy: Set, + inviteRef: String?, + inviteCreator: HexKey?, + inviteLabel: String?, + ): ConcordInviteResult { + val relays = servedBy + // Already a member? Just take the user to the community. Re-following and re-announcing a // Guestbook JOIN (kind 3306) would spam the community relays with a fresh join every time an // old invite is reopened, so short-circuit to Joined — the screen forwards to the community @@ -620,15 +660,14 @@ class AccountConcordActions( return ConcordInviteResult.Banned } - // Invite attribution (CORD-05 §1): the joiner echoes the link's creator + label in their - // Guestbook Join, which is what makes per-link usage counters possible. - val inviteCreator = bundle.creatorNpub?.lowercase()?.takeIf { HEX64.matches(it) } - val inviteLabel = bundle.label?.takeIf { inviteCreator != null && it.isNotBlank() } + // Invite attribution (CORD-05 §1), echoed in the Guestbook Join; a label only rides with a creator. + val creator = inviteCreator?.lowercase()?.takeIf { HEX64.matches(it) } + val label = inviteLabel?.takeIf { creator != null && it.isNotBlank() } if (rejoined != null) { if (!adoptedConcordRotations.add("${rejoined.id}:${rejoined.rootEpoch}")) return ConcordInviteResult.Joined(bundle.communityId) Log.i("Concord") { "Stranded rejoin by explicit invite: ${rejoined.id} -> epoch ${rejoined.rootEpoch}" } - joinConcordCommunity(rejoined, inviteCreator, inviteLabel) + joinConcordCommunity(rejoined, creator, label) _strandedConcordCommunities.value -= rejoined.id return ConcordInviteResult.Joined(bundle.communityId) } @@ -649,15 +688,175 @@ class AccountConcordActions( relays = bundle.relays, name = bundle.name, addedAt = TimeUtils.nowMillis(), - // Anchor for stranded recovery: keep the link we joined through, domain-agnostic, so a - // Refounding that leaves us out of the recipient set is recoverable later. See - // recoverStrandedConcordCommunities(). - inviteRef = ConcordActions.bareInviteRef(url), + // Anchor for stranded recovery (null for a Direct Invite, which has no link). + inviteRef = inviteRef, ) - joinConcordCommunity(entry, inviteCreator, inviteLabel) + joinConcordCommunity(entry, creator, label) return ConcordInviteResult.Joined(bundle.communityId) } + // ---- CORD-05 §6 Direct Invites --------------------------------------------- + + /** + * 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) + + /** + * 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]). + */ + val pendingConcordDirectInvites: StateFlow> = + combine(directInviteInbox.pending, account.concordChannelList.liveCommunities) { pending, joined -> + ConcordDirectInviteInbox.visible(pending.values, joined) + }.stateIn(account.scope, SharingStarted.WhileSubscribed(5_000), emptyList()) + + /** + * Where this account scans for Direct Invites — where senders deliver them (CORD-05 §6): our DM + * inbox relays (kind 10050, plus the NIP-65 read and private/local relays the DM feed already + * reads), else the stock Concord set. + */ + private fun concordDirectInviteScanRelays(): Set = + account.dmRelays.flow.value.ifEmpty { + ConcordActions.directInviteDeliveryRelays(null) + } + + /** + * Sweeps our inbox relays for Direct Invite wraps + * (`{"kinds":[1059],"#p":[me],"#k":["3313"]}` since the inbox cursor, rewound by NIP-59's backdate + * window) and offers each to the inbox. Returns how many new invites were parked. Read-only: it + * decrypts, it never joins or contacts a community's relays. + */ + suspend fun refreshConcordDirectInvites(): Int { + val relays = concordDirectInviteScanRelays() + if (relays.isEmpty()) return 0 + val before = directInviteInbox.pending.value.keys + 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) } + return (directInviteInbox.pending.value.keys - before).size + } + + /** + * 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. + */ + private suspend fun concordDirectInviteDeliveryRelays(recipient: HexKey): Set { + val user = account.cache.getOrCreateUser(recipient) + val dmInbox = user.dmInboxRelayList()?.relays().orEmpty() + val cached = + if (dmInbox.isNotEmpty() || user.authorRelayList() != null) { + RecipientRelayFetcher.Lists(dmInbox = dmInbox, keyPackage = emptyList(), nip65 = user.authorRelayList()) + } else { + null + } + val lists = + cached ?: run { + val seed = DefaultDmIndexerRelays.RELAYS.mapNotNullTo(mutableSetOf()) { RelayUrlNormalizer.normalizeOrNull(it) } + account.outboxRelays.flow.value + RecipientRelayFetcher.fetchRelayLists(account.client, recipient, seed) + } + return ConcordActions.directInviteDeliveryRelays(lists) + } + + /** + * Hands the keys of [communityId] straight to [recipientPubKey] as a Direct Invite (CORD-05 §6): + * the §1 bundle — carrying only the Private Channel keys the recipient's Roles entitle them to — + * sealed by our real key inside an ephemeral, `k`-tagged giftwrap, published to the recipient's + * inbox relays. It appears in no Registry and never flips the community Public; it cannot be + * revoked once it lands. [expiresAtMs] (unix ms) bounds its shelf life. + * + * No community permission gates it — none could (CORD-05 §6) — but a banned member is refused, + * like minting, and so is a banned recipient, whom the join would refuse anyway. + */ + suspend fun sendConcordDirectInvite( + communityId: String, + recipientPubKey: HexKey, + expiresAtMs: Long? = null, + ): ConcordDirectInviteSendResult { + if (!account.isWriteable()) return ConcordDirectInviteSendResult.NOT_WRITEABLE + val recipient = recipientPubKey.lowercase() + if (!HEX64.matches(recipient)) return ConcordDirectInviteSendResult.INVALID_RECIPIENT + val entry = + account.concordChannelList.liveCommunities.value + .firstOrNull { it.id == communityId } ?: return ConcordDirectInviteSendResult.NOT_MEMBER + val state = + account.concordSessions + .sessionFor(communityId) + ?.state + ?.value ?: return ConcordDirectInviteSendResult.ROSTER_NOT_LOADED + if (state.dissolved) return ConcordDirectInviteSendResult.NOT_MEMBER + if (state.authority.isBanned(account.signer.pubKey)) return ConcordDirectInviteSendResult.NOT_MEMBER + if (state.authority.isBanned(recipient)) return ConcordDirectInviteSendResult.RECIPIENT_BANNED + + val invite = + ConcordActions.directInviteFor( + entry = entry, + authority = state.authority, + recipient = recipient, + creator = account.signer.pubKey, + expiresAtMs = expiresAtMs, + name = state.metadata?.name ?: entry.name, + icon = state.metadata?.icon, + ) + val wrap = ConcordActions.buildDirectInvite(account.signer, recipient, invite) + val relays = concordDirectInviteDeliveryRelays(recipient) + if (relays.isEmpty()) return ConcordDirectInviteSendResult.NOT_DELIVERED + val delivered = + runCatching { account.client.publishAndConfirm(wrap, relays) } + .onFailure { Log.w("Concord", "direct invite publish failed for $communityId", it) } + .getOrDefault(false) + return if (delivered) ConcordDirectInviteSendResult.SENT else ConcordDirectInviteSendResult.NOT_DELIVERED + } + + /** + * Accepts the parked Direct Invite [wrapId] (CORD-05 §6) through the same join path as a link: + * refused once `expires_at` has passed, refused when the roster bans us, and — for a community + * we already hold — only a catch-up adopting newly granted Private Channel keys on the same base. + * The Guestbook Join is attributed to the seal-verified sender. **Only from an explicit user + * action**: this is the first moment anything contacts the community's relays. + */ + suspend fun acceptConcordDirectInvite(wrapId: HexKey): ConcordInviteResult { + if (!account.isWriteable()) return ConcordInviteResult.InvalidLink + val opened = directInviteInbox.get(wrapId) ?: return ConcordInviteResult.InvalidLink + val bundle = opened.invite + val held = + account.concordChannelList.liveCommunities.value + .firstOrNull { it.id.equals(bundle.communityId, ignoreCase = true) } + val heldState = + held?.let { + account.concordSessions + .sessionFor(it.id) + ?.state + ?.value + } + val result = + when (val plan = ConcordDirectInviteInbox.acceptPlan(opened, held, heldState, account.signer.pubKey)) { + DirectInviteAcceptPlan.Expired -> ConcordInviteResult.Expired + DirectInviteAcceptPlan.Banned -> ConcordInviteResult.Banned + // 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 + DirectInviteAcceptPlan.Join -> + joinValidatedConcordInvite( + bundle = bundle, + servedBy = emptySet(), + inviteRef = null, + // Attributed to the seal-verified sender (Armada), never the bundle's claim. + inviteCreator = opened.sender, + inviteLabel = bundle.label, + ) + } + if (result is ConcordInviteResult.Joined) directInviteInbox.resolve(opened.wrapId) + return result + } + + /** Declines the parked Direct Invite [wrapId]: its keys are discarded and it never resurfaces. */ + fun declineConcordDirectInvite(wrapId: HexKey): Boolean = directInviteInbox.decline(wrapId) + /** * Post [text] to a Concord channel: derive the channel plane key, build an * encrypted-seal kind-1059 wrap authored by that plane key (not our identity), diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/ConcordDirectInviteSendResult.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/ConcordDirectInviteSendResult.kt new file mode 100644 index 0000000000..d82eb09e5c --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/ConcordDirectInviteSendResult.kt @@ -0,0 +1,45 @@ +/* + * 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 + +/** The outcome of sending a Concord Direct Invite (CORD-05 §6), so the UI can say why it failed. */ +enum class ConcordDirectInviteSendResult { + /** At least one of the recipient's inbox relays accepted the wrap. */ + SENT, + + /** This account can't sign (read-only key). */ + NOT_WRITEABLE, + + /** The recipient isn't a valid 32-byte pubkey. */ + INVALID_RECIPIENT, + + /** We don't hold this community, it was dissolved, or its roster bans us. */ + NOT_MEMBER, + + /** The community's Control Plane hasn't folded yet, so which keys the recipient may receive is unknown. */ + ROSTER_NOT_LOADED, + + /** The community's roster bans the recipient; their join would be refused anyway. */ + RECIPIENT_BANNED, + + /** No inbox relay accepted the wrap. */ + NOT_DELIVERED, +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/DecryptAndIndexProcessor.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/DecryptAndIndexProcessor.kt index ccea35dc44..5ca3c6f8f7 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/DecryptAndIndexProcessor.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/DecryptAndIndexProcessor.kt @@ -27,6 +27,7 @@ import com.vitorpamplona.amethyst.commons.model.chatMessageMarksRoomAsRead import com.vitorpamplona.amethyst.commons.model.privateChatLastReadRoute import com.vitorpamplona.amethyst.commons.model.privateChats.ChatroomList import com.vitorpamplona.amethyst.commons.nipACWebRtcCalls.CallManager +import com.vitorpamplona.quartz.concord.cord05Invites.ConcordDirectInvite import com.vitorpamplona.quartz.experimental.ephemChat.chat.EphemeralChatEvent import com.vitorpamplona.quartz.marmot.GroupEventResult import com.vitorpamplona.quartz.marmot.MarmotInboundProcessor @@ -536,6 +537,17 @@ class SealEventHandler( ) { val innerRumor = event.unsealOrNull(account.signer) ?: return + // 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. + if (innerRumor.kind == ConcordDirectInvite.KIND) { + account.concord.directInviteInbox.offerSeal(publicNote.event ?: event, event) + eventNote.event = event.copyNoContent() + return + } + eventNote.event = event.copyNoContent() cache.justConsume(innerRumor, null, true) diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordDirectInviteInbox.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordDirectInviteInbox.kt new file mode 100644 index 0000000000..10e74ca258 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordDirectInviteInbox.kt @@ -0,0 +1,278 @@ +/* + * 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 androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEntry +import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityState +import com.vitorpamplona.quartz.concord.cord02Community.ImagePointer +import com.vitorpamplona.quartz.concord.cord05Invites.CommunityInvite +import com.vitorpamplona.quartz.concord.cord05Invites.ConcordDirectInvite +import com.vitorpamplona.quartz.concord.cord05Invites.ConcordInviteVend +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.wraps.GiftWrapEvent +import com.vitorpamplona.quartz.utils.TimeUtils +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlin.concurrent.Volatile + +/** + * One parked Direct Invite as the UI renders it (CORD-05 §6): who sent it (seal-verified), what it + * opens (name/icon preview from the bundle), whether its `expires_at` has passed, and whether it + * is a [catchUp] — a Private Channel key for a community this account already holds on the same + * base, which accepting merges in without moving the base or announcing a new Join. + */ +@Immutable +class ConcordDirectInviteView( + val opened: OpenedDirectInvite, + val catchUp: Boolean, + val expired: Boolean, +) { + val wrapId: HexKey get() = opened.wrapId + val sender: HexKey get() = opened.sender + val invite: CommunityInvite get() = opened.invite + val communityId: HexKey get() = opened.invite.communityId + 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 get() = + opened.invite.channels + .filter { it.key.isNotBlank() } + .map { it.name } +} + +/** What accepting a Direct Invite does; see [ConcordDirectInviteInbox.acceptPlan]. */ +sealed interface DirectInviteAcceptPlan { + /** `expires_at` has passed: the preview renders, joining refuses. */ + data object Expired : 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. */ + class CatchUp( + val entry: ConcordCommunityListEntry, + ) : DirectInviteAcceptPlan + + /** A held community the bundle adds nothing to (or can't: a different base, or dissolved). */ + data object NothingNew : DirectInviteAcceptPlan + + /** The held community's roster bans us. */ + data object Banned : DirectInviteAcceptPlan + + /** The held community's roster isn't folded yet, so the ban verdict is unknown: wait. */ + data object RosterNotLoaded : DirectInviteAcceptPlan +} + +/** + * The Direct Invite inbox (CORD-05 §6) — headless, shared by the app and `amy`. + * + * 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. + * + * 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. + */ +class ConcordDirectInviteInbox( + private val signer: NostrSigner, +) { + private val mutex = Mutex() + + /** Wrap ids already handled this session (opened, refused, or expired), oldest first. */ + private val seen = LinkedHashSet() + + private val _pending = MutableStateFlow>(emptyMap()) + + /** Parked invites by wrap id, as opened. See [visible] for what a UI should show. */ + val pending: StateFlow> = _pending.asStateFlow() + + private val _declined = MutableStateFlow>(emptySet()) + + /** Wrap ids the user declined; persisted by the front end so they stay declined across restarts. */ + val declined: StateFlow> = _declined.asStateFlow() + + /** The newest wrap `created_at` offered so far (the sweep cursor), or null on a cold inbox. */ + @Volatile + var newestWrapCreatedAt: Long? = null + private set + + /** 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) { + _declined.value = 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. + */ + suspend fun offer( + wrap: Event, + nowSecs: Long = TimeUtils.now(), + ): OpenedDirectInvite? = admit(wrap, nowSecs) { ConcordDirectInvite.open(wrap, signer) } + + /** + * [offer] for a pipeline that already peeled [wrap] down to its kind-13 [seal] (the NIP-17 + * giftwrap inbox). [wrap] only lends its id, `created_at` and tags, so a content-stripped copy + * is fine; the seal is re-opened with the anti-spoofing check the generic unseal skips. + */ + suspend fun offerSeal( + wrap: Event, + seal: Event, + nowSecs: Long = TimeUtils.now(), + ): OpenedDirectInvite? = admit(wrap, nowSecs) { ConcordDirectInvite.openSeal(wrap.id, seal, signer) } + + private suspend fun admit( + wrap: Event, + nowSecs: Long, + open: suspend () -> OpenedDirectInvite?, + ): OpenedDirectInvite? { + if (wrap.kind != GiftWrapEvent.KIND) return null + mutex.withLock { + val newest = newestWrapCreatedAt + if (newest == null || wrap.createdAt > newest) newestWrapCreatedAt = wrap.createdAt + _pending.value[wrap.id]?.let { return it } + if (wrap.id in _declined.value || wrap.id in seen) return null + remember(wrap.id) + } + // 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) } + } + return opened + } + + /** The parked invite behind [wrapId], if any. */ + fun get(wrapId: HexKey): OpenedDirectInvite? = _pending.value[wrapId.lowercase()] ?: _pending.value[wrapId] + + /** Discards [wrapId] for good (CORD-05 §6 "declining means discarding them"). False if not pending. */ + fun decline(wrapId: HexKey): Boolean { + val id = get(wrapId)?.wrapId ?: return false + _pending.update { it - id } + _declined.update { it + id } + return true + } + + /** Drops [wrapId] after it was accepted; this session will not re-park it. */ + fun resolve(wrapId: HexKey) { + _pending.update { it - wrapId } + } + + private fun remember(wrapId: HexKey) { + if (seen.size >= SEEN_CAP) { + val drop = seen.take(SEEN_CAP / 2) + seen.removeAll(drop.toSet()) + } + seen.add(wrapId) + } + + companion object { + /** Cap on remembered wrap ids; the oldest half is shed past it (a sweep re-dedupes deeper). */ + const val SEEN_CAP = 4096 + + /** + * What accepting [opened] should do (CORD-05 §6), given the community entry this account + * already [held] (if any) and its folded [heldState]: + * - past `expires_at` → [DirectInviteAcceptPlan.Expired] ("`expires_at` refuses a late join"); + * - 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]); + * - held otherwise (nothing new, a different base, dissolved) → [DirectInviteAcceptPlan.NothingNew]. + */ + fun acceptPlan( + opened: OpenedDirectInvite, + held: ConcordCommunityListEntry?, + heldState: ConcordCommunityState?, + me: HexKey, + nowMs: Long = TimeUtils.nowMillis(), + ): 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 (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) + } + + /** + * What a UI shows out of [pending], given the communities this account already holds + * ([joined]): 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 + * channel set too since each may vend a key no other wrap carries (Armada + * `dedupeParkedInvites`). + */ + fun visible( + pending: Collection, + joined: List, + nowMs: Long = TimeUtils.nowMillis(), + ): List { + val heldById = joined.associateBy { it.id.lowercase() } + val byKey = LinkedHashMap() + for (opened in pending) { + val communityId = opened.invite.communityId.lowercase() + val held = heldById[communityId] + val newChannels = ConcordInviteVend.catchUpChannelIds(held, opened.invite) + 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 existing = byKey[key] + if (existing == null || + opened.sentAt > existing.opened.sentAt || + (opened.sentAt == existing.opened.sentAt && opened.wrapId < existing.opened.wrapId) + ) { + byKey[key] = view + } + } + return byKey.values.sortedWith(compareByDescending { it.opened.sentAt }.thenBy { it.wrapId }) + } + } +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/ConcordDirectInviteDeclineStore.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/ConcordDirectInviteDeclineStore.kt new file mode 100644 index 0000000000..0fecf0b490 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/ConcordDirectInviteDeclineStore.kt @@ -0,0 +1,82 @@ +/* + * 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.preferences + +import androidx.compose.runtime.Stable +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.stringSetPreferencesKey +import com.vitorpamplona.amethyst.commons.model.concord.ConcordDirectInviteInbox +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.utils.Log +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.drop +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch +import kotlin.coroutines.cancellation.CancellationException + +/** + * Per-account persistence for the Concord Direct Invites the user declined (CORD-05 §6), so a + * declined invite's wrap — which relays keep re-delivering until its NIP-40 expiration — never + * resurfaces after a restart. Mirrors [BuzzChannelStarStore]: loads this account's saved wrap ids + * into [inbox] on construction, then writes every later change back. Construct once per account. + */ +@Stable +class ConcordDirectInviteDeclineStore( + private val store: DataStore, + private val scope: CoroutineScope, + private val pubKeyHex: HexKey, + private val inbox: ConcordDirectInviteInbox, +) { + private val key = stringSetPreferencesKey("$KEY_PREFIX$pubKeyHex") + + init { + scope.launch { + restoreFromDisk() + // drop(1) skips the value present at collection start, which restoreFromDisk already wrote. + inbox.declined.drop(1).collect { persist(it) } + } + } + + private suspend fun restoreFromDisk() { + try { + val raw = store.data.first()[key] ?: return + if (raw.isNotEmpty()) inbox.restoreDeclined(raw + inbox.declined.value) + } catch (e: Exception) { + if (e is CancellationException) throw e + Log.e("ConcordDirectInvites") { "Error reading declined invites: ${e.message}" } + } + } + + private suspend fun persist(ids: Set) { + try { + store.edit { prefs -> prefs[key] = ids } + } catch (e: Exception) { + if (e is CancellationException) throw e + Log.e("ConcordDirectInvites") { "Error writing declined invites: ${e.message}" } + } + } + + companion object { + private const val KEY_PREFIX = "concord.declinedDirectInvites." + } +} diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordDirectInviteActionsTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordDirectInviteActionsTest.kt new file mode 100644 index 0000000000..05296629f5 --- /dev/null +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordDirectInviteActionsTest.kt @@ -0,0 +1,158 @@ +/* + * 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.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.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.InviteRelayDictionary +import com.vitorpamplona.quartz.marmot.RecipientRelayFetcher +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.relay.normalizer.RelayUrlNormalizer +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 + +/** + * CORD-05 §6 send side: a Direct Invite carries exactly the Private Channel keys the recipient's + * Roles entitle them to (Armada `vendableChannels`, audience "member"), and goes to the + * recipient's 10050 → NIP-65 read → stock relays. + */ +class ConcordDirectInviteActionsTest { + private val owner = NostrSignerInternal(KeyPair()) + private val mod = NostrSignerInternal(KeyPair()) + private val member = NostrSignerInternal(KeyPair()) + + private val modsChannel = "a1".repeat(32) + private val vipChannel = "b2".repeat(32) + private val modsRoleId = ByteArray(32) { 7 } + + private fun entryOf(community: NewConcordCommunity) = + 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(), + privateChannels = + listOf( + PrivateChannelKey(modsChannel, "ca".repeat(32), 2, "mods"), + PrivateChannelKey(vipChannel, "db".repeat(32), 0, "vip"), + ), + relays = listOf("wss://relay.example"), + name = "Nostrichs", + ) + + /** A community where [mod] holds a Role scoped to [modsChannel]; nobody is scoped to [vipChannel]. */ + private suspend fun foldWithModsRole(community: NewConcordCommunity): ConcordCommunityState { + val cp = community.controlPlane + val editions = ConcordActions.controlEditions(community.genesisWraps, cp).toMutableList() + + fun add(wrap: Event) { + editions += ConcordActions.controlEditions(listOf(wrap), cp) + } + val role = + RoleEntity( + roleId = modsRoleId.toHexKey(), + name = "Mods", + position = 5, + permissions = ConcordPermissions.of(ConcordPermissions.MENTION_EVERYONE).toWire(), + scope = RoleScope(kind = "channel", channelId = modsChannel), + ) + add(ConcordModeration.defineRole(owner, cp, community.communityId, modsRoleId, role, editions, createdAt = 2L, owner = community.ownerPubKey)) + add(ConcordModeration.grant(owner, cp, community.communityId, mod.pubKey, listOf(modsRoleId.toHexKey()), editions, createdAt = 3L, owner = community.ownerPubKey)) + return ConcordCommunityState.fold(editions, community.communityId, community.ownerPubKey) + } + + @Test + fun aDirectInviteCarriesOnlyTheChannelsTheRecipientIsEntitledTo() = + runTest { + val community = ConcordCommunityFactory.create(owner, "Nostrichs", createdAt = 1L, relays = listOf("wss://relay.example")) + val state = foldWithModsRole(community) + assertTrue(modsRoleId.toHexKey() in state.authority.rolesOf(mod.pubKey)) + val entry = entryOf(community) + + // A plain member holds no channel-scoped Role: no Private Channel keys. + val toMember = ConcordActions.directInviteFor(entry, state.authority, member.pubKey, creator = owner.pubKey) + assertTrue(toMember.channels.isEmpty()) + + // The mod gets #mods (their Role's scope) and nothing else. + val toMod = ConcordActions.directInviteFor(entry, state.authority, mod.pubKey, creator = owner.pubKey, expiresAtMs = 1_900_000_000_000L) + assertEquals(listOf(modsChannel), toMod.channels.map { it.id }) + assertEquals("ca".repeat(32), toMod.channels.single().key) + assertEquals(2L, toMod.channels.single().epoch) + assertEquals(1_900_000_000_000L, toMod.expiresAt) + assertEquals(owner.pubKey, toMod.creatorNpub) + + // The owner is entitled to every channel. + val toOwner = ConcordActions.directInviteFor(entry, state.authority, owner.pubKey, creator = mod.pubKey) + assertEquals(setOf(modsChannel, vipChannel), toOwner.channels.map { it.id }.toSet()) + + // The bundle is the held base, and it validates as a fetched one would. + assertEquals(entry.root, toMember.communityRoot) + assertEquals(entry.rootEpoch, toMember.rootEpoch) + assertEquals(entry.controlPk, toMember.controlPk) + } + + @Test + fun theBuiltWrapOpensForTheRecipient() = + runTest { + val community = ConcordCommunityFactory.create(owner, "Nostrichs", createdAt = 1L, relays = listOf("wss://relay.example")) + val state = foldWithModsRole(community) + val invite = ConcordActions.directInviteFor(entryOf(community), state.authority, mod.pubKey, creator = owner.pubKey) + val wrap = ConcordActions.buildDirectInvite(owner, mod.pubKey, invite) + + // The indexed lookup a recipient runs matches the wrap's tags. + val filter = ConcordActions.directInvitesFilter(mod.pubKey, since = 5L) + assertEquals(listOf(mod.pubKey), filter.tags?.get("p")) + assertEquals(listOf("3313"), filter.tags?.get("k")) + assertEquals(5L, filter.since) + assertTrue(filter.match(wrap)) + + val opened = assertNotNull(ConcordActions.openDirectInvite(wrap, mod)) + assertEquals(owner.pubKey, opened.sender) + assertEquals(listOf(modsChannel), ConcordActions.privateChannelKeysOf(opened.invite).map { it.channelId }) + } + + @Test + fun deliveryGoesTo10050ThenNip65ReadThenStock() { + val dm = RelayUrlNormalizer.normalizeOrNull("wss://dm.example")!! + val withDm = RecipientRelayFetcher.Lists(dmInbox = listOf(dm), keyPackage = emptyList(), nip65 = null) + assertEquals(setOf(dm), ConcordActions.directInviteDeliveryRelays(withDm)) + + val stock = InviteRelayDictionary.STOCK.mapNotNull { RelayUrlNormalizer.normalizeOrNull(it) }.toSet() + assertEquals(stock, ConcordActions.directInviteDeliveryRelays(null)) + assertEquals(stock, ConcordActions.directInviteDeliveryRelays(RecipientRelayFetcher.Lists(emptyList(), emptyList(), null))) + } +} diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordDirectInviteInboxTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordDirectInviteInboxTest.kt new file mode 100644 index 0000000000..d50201fabb --- /dev/null +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordDirectInviteInboxTest.kt @@ -0,0 +1,263 @@ +/* + * 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.NewConcordCommunity +import com.vitorpamplona.quartz.concord.cord02Community.PrivateChannelKey +import com.vitorpamplona.quartz.concord.cord05Invites.CommunityInvite +import com.vitorpamplona.quartz.concord.cord05Invites.InviteChannel +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.nip59Giftwrap.seals.SealEvent +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.assertSame +import kotlin.test.assertTrue + +/** + * The headless Direct Invite inbox (CORD-05 §6): collects wraps, dedupes by wrap id, skips expired + * handoffs, validates, parks — and never joins. Plus the accept decision (expired → refuse; held → + * catch-up keys only on the same base, never a base move). + */ +class ConcordDirectInviteInboxTest { + private val owner = NostrSignerInternal(KeyPair()) + private val sender = NostrSignerInternal(KeyPair()) + private val me = NostrSignerInternal(KeyPair()) + private val stranger = NostrSignerInternal(KeyPair()) + + private val vip = "b2".repeat(32) + + private suspend fun community(): NewConcordCommunity = ConcordCommunityFactory.create(owner, "Nostrichs", createdAt = 1L, relays = listOf("wss://relay.example")) + + private fun inviteFor( + c: NewConcordCommunity, + expiresAt: Long? = null, + channels: List = emptyList(), + root: String = c.communityRoot.toHexKey(), + ) = CommunityInvite( + communityId = c.communityIdHex, + owner = c.ownerPubKey, + ownerSalt = c.ownerSalt.toHexKey(), + communityRoot = root, + rootEpoch = c.rootEpoch, + controlPk = c.controlPkHex, + channels = channels, + relays = listOf("wss://relay.example"), + name = "Nostrichs", + expiresAt = expiresAt, + ) + + private fun heldEntryOf(c: NewConcordCommunity) = + 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", + inviteRef = "anchor", + ) + + private fun stateOf(c: NewConcordCommunity): ConcordCommunityState = ConcordCommunityState.fold(ConcordActions.controlEditions(c.genesisWraps, c.controlPlane), c.communityId, c.ownerPubKey) + + @Test + fun aValidWrapIsParkedWithItsVerifiedSenderAndDedupedByWrapId() = + runTest { + val c = community() + val inbox = ConcordDirectInviteInbox(me) + val wrap = ConcordActions.buildDirectInvite(sender, me.pubKey, inviteFor(c)) + + val first = assertNotNull(inbox.offer(wrap)) + assertEquals(sender.pubKey, first.sender) + assertEquals(c.communityIdHex, first.invite.communityId) + assertEquals(setOf(wrap.id), inbox.pending.value.keys) + + // The same wrap again (a re-delivery, or the DM pipeline seeing it too) is the same entry. + assertSame(first, inbox.offer(wrap)) + assertEquals(1, inbox.pending.value.size) + assertEquals(wrap.createdAt, inbox.newestWrapCreatedAt) + } + + @Test + fun wrapsForSomeoneElseOrForgedOrExpiredAreNotParked() = + runTest { + val c = community() + val inbox = ConcordDirectInviteInbox(me) + // Addressed to someone else. + assertNull(inbox.offer(ConcordActions.buildDirectInvite(sender, stranger.pubKey, inviteFor(c)))) + // A bundle whose owner proof fails. + assertNull(inbox.offer(ConcordActions.buildDirectInvite(sender, me.pubKey, inviteFor(c).copy(owner = stranger.pubKey)))) + // A handoff whose NIP-40 expiration passed is never decrypted. + val expired = ConcordActions.buildDirectInvite(sender, me.pubKey, inviteFor(c, expiresAt = 1_000_000L)) + assertNull(inbox.offer(expired, nowSecs = 1_000L)) + assertTrue(inbox.pending.value.isEmpty()) + } + + @Test + fun theDmPipelineSealPathParksTheSameInvite() = + runTest { + val c = community() + val inbox = ConcordDirectInviteInbox(me) + val wrap = ConcordActions.buildDirectInvite(sender, me.pubKey, inviteFor(c)) + val seal = assertIs(wrap.unwrapOrNull(me)) + val opened = assertNotNull(inbox.offerSeal(wrap.copyNoContent(), seal)) + assertEquals(sender.pubKey, opened.sender) + assertEquals(wrap.id, opened.wrapId) + // The sweep delivering the full wrap later doesn't duplicate it. + assertSame(opened, inbox.offer(wrap)) + } + + @Test + fun declineDiscardsAndTheWrapNeverResurfaces() = + runTest { + val c = community() + val inbox = ConcordDirectInviteInbox(me) + val wrap = ConcordActions.buildDirectInvite(sender, me.pubKey, inviteFor(c)) + inbox.offer(wrap) + + assertTrue(inbox.decline(wrap.id)) + assertTrue(inbox.pending.value.isEmpty()) + assertEquals(setOf(wrap.id), inbox.declined.value) + assertNull(inbox.offer(wrap)) + assertFalse(inbox.decline(wrap.id)) + + // After a restart the persisted declines are restored and still win. + val fresh = ConcordDirectInviteInbox(me) + fresh.restoreDeclined(inbox.declined.value) + assertNull(fresh.offer(wrap)) + assertTrue(fresh.pending.value.isEmpty()) + } + + @Test + fun sinceRewindsTheCursorByTheBackdateWindow() = + runTest { + val c = community() + val inbox = ConcordDirectInviteInbox(me) + assertNull(inbox.since()) + val wrap = ConcordActions.buildDirectInvite(sender, me.pubKey, inviteFor(c)) + inbox.offer(wrap) + assertEquals(wrap.createdAt - 2 * 24 * 60 * 60L, inbox.since()) + } + + @Test + fun visibleHidesJoinedCommunitiesButKeepsCatchUpsAndFlagsExpiry() = + runTest { + val joinedCommunity = community() + val newCommunity = community() + val inbox = ConcordDirectInviteInbox(me) + + val toNew = assertNotNull(inbox.offer(ConcordActions.buildDirectInvite(sender, me.pubKey, inviteFor(newCommunity, expiresAt = 5_000L)), nowSecs = 1L)) + val plainForJoined = assertNotNull(inbox.offer(ConcordActions.buildDirectInvite(sender, me.pubKey, inviteFor(joinedCommunity)))) + val catchUp = assertNotNull(inbox.offer(ConcordActions.buildDirectInvite(sender, me.pubKey, inviteFor(joinedCommunity, channels = listOf(InviteChannel(vip, "db".repeat(32), 0, "vip")))))) + val baseMove = assertNotNull(inbox.offer(ConcordActions.buildDirectInvite(sender, me.pubKey, inviteFor(joinedCommunity, root = "99".repeat(32), channels = listOf(InviteChannel(vip, "db".repeat(32), 0, "vip")))))) + + val views = ConcordDirectInviteInbox.visible(inbox.pending.value.values, listOf(heldEntryOf(joinedCommunity)), nowMs = 10_000L) + val byWrap = views.associateBy { it.wrapId } + assertEquals(setOf(toNew.wrapId, catchUp.wrapId), byWrap.keys) + assertFalse(plainForJoined.wrapId in byWrap) + assertFalse(baseMove.wrapId in byWrap) + assertTrue(byWrap.getValue(catchUp.wrapId).catchUp) + 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) + } + + @Test + fun visibleKeepsOneInvitePerCommunity() = + 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 views = ConcordDirectInviteInbox.visible(inbox.pending.value.values, emptyList()) + assertEquals(listOf(newer.wrapId), views.map { it.wrapId }) + assertFalse(older.wrapId in views.map { it.wrapId }) + } + + @Test + fun acceptRefusesAnExpiredInvite() = + runTest { + val c = community() + val opened = assertNotNull(ConcordActions.openDirectInvite(ConcordActions.buildDirectInvite(sender, me.pubKey, inviteFor(c, expiresAt = 5_000L)), me)) + assertEquals(DirectInviteAcceptPlan.Expired, ConcordDirectInviteInbox.acceptPlan(opened, null, null, me.pubKey, nowMs = 5_001L)) + assertEquals(DirectInviteAcceptPlan.Join, ConcordDirectInviteInbox.acceptPlan(opened, null, null, me.pubKey, nowMs = 4_999L)) + } + + @Test + fun acceptOnAHeldCommunityOnlyAddsKeysAndNeverMovesTheBase() = + runTest { + val c = community() + val held = heldEntryOf(c) + val state = stateOf(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)) + val plan = assertIs(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 }) + + // No fold yet: the ban verdict is unknown, so it waits. + assertEquals(DirectInviteAcceptPlan.RosterNotLoaded, ConcordDirectInviteInbox.acceptPlan(catchUp, held, null, me.pubKey)) + + // Already holding that key: nothing new. + 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)) + + // 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)) + + // A dissolved community takes no new keys. + assertEquals(DirectInviteAcceptPlan.NothingNew, ConcordDirectInviteInbox.acceptPlan(catchUp, held, state.withDissolved(true), me.pubKey)) + } + + @Test + fun acceptRefusesACatchUpWhenTheHeldRosterBansUs() = + runTest { + val c = community() + val editions = ConcordActions.controlEditions(c.genesisWraps, c.controlPlane).toMutableList() + editions += ConcordActions.controlEditions(listOf(ConcordModeration.ban(owner, c.controlPlane, c.communityId, me.pubKey, editions, createdAt = 2L, owner = c.ownerPubKey)), c.controlPlane) + val banned = ConcordCommunityState.fold(editions, c.communityId, c.ownerPubKey) + assertTrue(banned.authority.isBanned(me.pubKey)) + + val catchUp = assertNotNull(ConcordActions.openDirectInvite(ConcordActions.buildDirectInvite(sender, me.pubKey, inviteFor(c, channels = listOf(InviteChannel(vip, "db".repeat(32), 0, "vip")))), me)) + assertEquals(DirectInviteAcceptPlan.Banned, ConcordDirectInviteInbox.acceptPlan(catchUp, heldEntryOf(c), banned, me.pubKey)) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord02Community/ConcordCommunityList.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord02Community/ConcordCommunityList.kt index ceda69c3cd..7f4d0d6661 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord02Community/ConcordCommunityList.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord02Community/ConcordCommunityList.kt @@ -704,4 +704,27 @@ object ConcordCommunityList { excludedAtEpoch = excludedAtEpoch, residue = residue, ) + + /** + * Copy of this entry holding [privateChannels] — e.g. after a Direct Invite catch-up delivered a + * Private Channel key (CORD-05 §6). Every other field, the base included, untouched. + */ + fun ConcordCommunityListEntry.withPrivateChannels(privateChannels: List) = + 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, + ) } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordDirectInvite.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordDirectInvite.kt index ac0ce7bd16..d3c74c06ba 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordDirectInvite.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordDirectInvite.kt @@ -24,18 +24,51 @@ 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.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.nip40Expiration.ExpirationTag +import com.vitorpamplona.quartz.nip40Expiration.isExpirationBefore +import com.vitorpamplona.quartz.nip59Giftwrap.rumors.Rumor import com.vitorpamplona.quartz.nip59Giftwrap.rumors.RumorAssembler 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 /** - * Direct invites (CORD-05): for a known npub, the invite skips the public bundle - * and is delivered as a standard NIP-59 giftwrap — a kind-3313 rumor carrying the - * [CommunityInvite], sealed (kind 13) to the recipient and wrapped (kind 1059) - * with `["p", recipient]` and a `["k", "3313"]` index tag so the recipient can - * query for pending invites without decrypting every giftwrap. + * A Direct Invite opened by its recipient (CORD-05 §6): the bundle plus the seal-verified [sender]. + * + * [invite] is already bounded and owner-proof validated ([ConcordInviteBundle.validate]); expiry is + * NOT enforced here — a parked invite still renders, only joining refuses ([isExpired]). + * [sentAt] is the rumor's `created_at` (unix seconds), the sender's word: fine for ordering, never + * for authority. + */ +class OpenedDirectInvite( + val wrapId: HexKey, + val sender: HexKey, + val invite: CommunityInvite, + val sentAt: Long, +) { + /** True when the bundle's `expires_at` (unix ms) has passed: the preview renders, joining refuses. */ + fun isExpired(nowMs: Long = TimeUtils.nowMillis()): Boolean = ConcordInviteBundle.isExpired(invite, nowMs) +} + +/** + * Direct invites (CORD-05 §6): for a known npub, the invite skips the public bundle + * and is delivered as a *standard* NIP-59 giftwrap — a kind-3313 rumor carrying the + * [CommunityInvite], sealed (kind 13, signed by the inviter's real key) to the recipient and + * wrapped (kind 1059, ephemeral single-use author) with `["p", recipient]` and a `["k", "3313"]` + * index tag so the recipient can query for pending invites without decrypting every giftwrap. + * Not the reversed stream wrap of CORD-01. + * + * Wire details pinned to Armada's `directInvite.ts`: + * - seal and wrap `created_at` are each tweaked into the past by up to [MAX_BACKDATE_SECS] + * (NIP-59), so the wrap leaks only "roughly when"; the rumor keeps the real send time; + * - when the bundle has an `expires_at` (unix ms) the wrap carries the matching NIP-40 + * `["expiration", expires_at / 1000]`, so relays can prune a handoff that can no longer be used; + * - opening requires the rumor's claimed author to equal the seal's author (NIP-59 anti-spoofing), + * and the seal's signature to verify — the seal is what proves who invited. * * It cannot be revoked — the recipient holds the keys the moment it lands. */ @@ -44,46 +77,125 @@ object ConcordDirectInvite { const val TAG_P = "p" const val TAG_K = "k" + /** NIP-59: outer (seal + wrap) timestamps are tweaked into the past by up to two days. */ + const val MAX_BACKDATE_SECS: Long = 2 * 24 * 60 * 60L + private fun json(invite: CommunityInvite) = ConcordJson.instance.encodeToString(CommunityInvite.serializer(), invite) + /** [now] minus a uniformly random `0 until` [MAX_BACKDATE_SECS] seconds (NIP-59's timestamp tweak). */ + fun tweakedPast(now: Long = TimeUtils.now()): Long = now - RandomInstance.int(MAX_BACKDATE_SECS.toInt()) + /** * Builds a giftwrapped direct invite from [senderSigner] to [recipientPubKey]. - * Returns the kind-1059 wrap to publish to the recipient's inbox relays. + * Returns the kind-1059 wrap to publish to the recipient's inbox relays (their kind-10050 DM + * relays, else their NIP-65 read relays). [createdAt] is the rumor's real send time; the seal and + * the wrap are each backdated from it independently ([tweakedPast]). */ suspend fun build( senderSigner: NostrSigner, recipientPubKey: HexKey, invite: CommunityInvite, - createdAt: Long, + createdAt: Long = TimeUtils.now(), ): GiftWrapEvent { val rumor = RumorAssembler.assembleRumor(senderSigner.pubKey, createdAt, KIND, emptyArray(), json(invite)) - val seal = SealEvent.create(rumor, recipientPubKey, senderSigner, createdAt = createdAt) + val seal = SealEvent.create(rumor, recipientPubKey, senderSigner, createdAt = tweakedPast(createdAt)) - // Wrap with a random ephemeral key, adding the ["k","3313"] index tag. + // Wrap with a random single-use key, adding the ["k","3313"] index tag and, when the bundle + // expires, the NIP-40 expiration matching it. val wrapSigner = NostrSignerInternal(KeyPair()) val content = wrapSigner.nip44Encrypt(seal.toJson(), recipientPubKey) + val tags = + listOfNotNull( + arrayOf(TAG_P, recipientPubKey), + arrayOf(TAG_K, KIND.toString()), + invite.expiresAt?.let { arrayOf(ExpirationTag.TAG_NAME, (it / 1000).toString()) }, + ).toTypedArray() return wrapSigner.sign( - createdAt = createdAt, + createdAt = tweakedPast(createdAt), kind = GiftWrapEvent.KIND, - tags = arrayOf(arrayOf(TAG_P, recipientPubKey), arrayOf(TAG_K, KIND.toString())), + tags = tags, content = content, ) } + /** + * True when [wrap]'s NIP-40 `expiration` (unix seconds) is at or before [nowSecs]: an expired + * handoff is never decrypted or surfaced. + */ + fun isWrapExpired( + wrap: Event, + nowSecs: Long = TimeUtils.now(), + ): Boolean = wrap.tags.isExpirationBefore(nowSecs) + + /** + * The `since` to query invite wraps from, given the newest wrap `created_at` already seen: + * rewound by [MAX_BACKDATE_SECS] because wraps are backdated (a wrap published after the last + * sweep can carry an older timestamp). Null on a cold inbox — fetch everything. + */ + fun inboxSince(newestWrapCreatedAt: Long?): Long? = newestWrapCreatedAt?.takeIf { it > MAX_BACKDATE_SECS }?.let { it - MAX_BACKDATE_SECS } + + /** + * Opens a direct-invite giftwrap addressed to [recipientSigner]. Null — never a throw — unless + * every layer checks out: a kind-1059 wrap that decrypts to a kind-13 seal with a valid + * signature, whose rumor claims the seal's author (anti-spoofing), is kind 3313 (the rumor kind + * is the authority, not the outer `k` hint), and carries a [CommunityInvite] that passes the §1 + * bounds and the owner proof ([ConcordInviteBundle.validate]). + */ + suspend fun open( + wrap: Event, + recipientSigner: NostrSigner, + ): OpenedDirectInvite? { + if (wrap.kind != GiftWrapEvent.KIND) return null + val seal = + try { + Event.fromJson(recipientSigner.nip44Decrypt(wrap.content, wrap.pubKey)) + } catch (_: Exception) { + return null + } + return openSeal(wrap.id, seal, recipientSigner) + } + + /** + * [open] from the kind-13 [seal] down, for a pipeline that already peeled the wrap [wrapId] + * (e.g. the general NIP-17 giftwrap inbox, which honours an untagged invite all the same). + */ + suspend fun openSeal( + wrapId: HexKey, + seal: Event, + recipientSigner: NostrSigner, + ): 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. + val claimed = rumor.pubKey ?: return null + if (!claimed.equals(seal.pubKey, ignoreCase = true)) return null + if (rumor.kind != KIND) return null + // Bounded like a fetched bundle (CORD-05 §6: "the §1 bounds apply"), and validated + // exactly as one: the community_id must self-certify the owner. + val content = rumor.content ?: return null + val invite = + ConcordJson + .decodeOrNull(content) + ?.let { ConcordInviteBundle.bound(it) } + ?.takeIf { ConcordInviteBundle.validate(it) } + ?: return null + OpenedDirectInvite(wrapId, seal.pubKey.lowercase(), invite, rumor.createdAt ?: seal.createdAt) + } 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. - * Callers should still [ConcordInviteBundle.validate] the result. + * [CommunityInvite], or null if it isn't a valid direct invite for this user. See [open], which + * also returns the verified sender. */ suspend fun parse( wrap: GiftWrapEvent, recipientSigner: NostrSigner, - ): CommunityInvite? { - val seal = wrap.unwrapOrNull(recipientSigner) ?: return null - if (seal !is SealEvent) return null - val rumor = seal.unsealOrNull(recipientSigner) ?: return null - if (rumor.kind != KIND) return null - // Bounded like a fetched bundle (CORD-05 §6: "the §1 bounds apply"). - return ConcordJson.decodeOrNull(rumor.content)?.let { ConcordInviteBundle.bound(it) } - } + ): CommunityInvite? = open(wrap, recipientSigner)?.invite } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordInviteVend.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordInviteVend.kt new file mode 100644 index 0000000000..c0cb4df4d9 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordInviteVend.kt @@ -0,0 +1,141 @@ +/* + * 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.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.cord04Roles.AuthorityResolver +import com.vitorpamplona.quartz.nip01Core.core.HexKey + +/** + * Which Private Channel keys an invite bundle may carry (CORD-05 §1, CORD-03 §1, CORD-04 §2), and + * what a bundle for an already-joined community may contribute. Pinned to Armada's + * `channelAccess.ts` (`isEntitled`, `vendableChannels`) and `directInvite.ts` (`catchUpChannelIds`). + * + * The Roles scoped to a channel (`scope: {kind:"channel", channel_id}`) ARE its access list. Read + * access is enforced by key possession alone; this decides who a key is delivered TO. + */ +object ConcordInviteVend { + private const val SCOPE_CHANNEL = "channel" + + /** The live Role ids conferring read access to [channelIdHex] (Roles scoped to that channel). */ + fun channelRoleIds( + authority: AuthorityResolver, + channelIdHex: HexKey, + ): Set = + authority + .roles() + .filter { (_, role) -> !role.deleted && role.scope?.kind == SCOPE_CHANNEL && role.scope.channelId.equals(channelIdHex, ignoreCase = true) } + .keys + + /** + * Is [memberHex] entitled to Private Channel [channelIdHex]'s key? The owner always is + * (CORD-04 §2); anyone else must hold a Role scoped to that channel. + */ + fun isEntitled( + authority: AuthorityResolver, + memberHex: HexKey, + channelIdHex: HexKey, + ): Boolean { + if (authority.isOwner(memberHex)) return true + val held = authority.rolesOf(memberHex) + if (held.isEmpty()) return false + return channelRoleIds(authority, channelIdHex).any { it in held } + } + + /** + * The held Private Channel keys a bundle may carry for its audience (CORD-05 §1): + * - a **link** ([memberHex] null) has no recipient and holds no Role, so it gets none; + * - a **member** (a Direct Invite's recipient) gets exactly the channels their Roles entitle + * them to ([isEntitled]) — that CORD-05 §6 can't *prevent* an unentitled whisper doesn't make + * one right. + * + * Keyless listings are never vended. + */ + fun vendableChannels( + held: List, + authority: AuthorityResolver, + memberHex: HexKey?, + ): List { + if (memberHex == null) return emptyList() + return held.filter { it.key.isNotBlank() && isEntitled(authority, memberHex, it.channelId) } + } + + /** The [held] keys as bundle channel grants (lowercase hex, as Armada writes them). */ + fun toInviteChannels(held: List): List = held.map { InviteChannel(it.channelId.lowercase(), it.key.lowercase(), it.epoch, it.name) } + + /** + * The Private Channel ids (lowercase hex) a [bundle] for an already-joined community would NEWLY + * contribute to [held] — empty when it is not a catch-up. Armada `catchUpChannelIds`. + * + * A catch-up may never move the base: nothing binds `community_root` to `community_id` + * (CORD-02 §1/§2), so a hostile bundle carrying a real id/owner/salt could otherwise relocate + * 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. + */ + fun catchUpChannelIds( + held: ConcordCommunityListEntry?, + bundle: CommunityInvite, + ): List { + if (held == null) return emptyList() + if (!bundle.communityId.equals(held.id, ignoreCase = true)) return emptyList() + 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 } + 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() } + .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. + */ + fun adoptCatchUp( + held: ConcordCommunityListEntry, + bundle: CommunityInvite, + ): ConcordCommunityListEntry? { + val newIds = catchUpChannelIds(held, bundle).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) + } + + private val HEX64 = Regex("^[0-9a-fA-F]{64}$") + + private fun sameOptionalHex( + a: String?, + b: String?, + ): Boolean = a?.lowercase() == b?.lowercase() +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordDirectInviteTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordDirectInviteTest.kt index 396ffbad1b..985da96538 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordDirectInviteTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordDirectInviteTest.kt @@ -20,47 +20,203 @@ */ package com.vitorpamplona.quartz.concord.cord05Invites +import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityFactory +import com.vitorpamplona.quartz.concord.cord02Community.NewConcordCommunity +import com.vitorpamplona.quartz.concord.cord04Roles.ConcordJson +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.NostrSigner import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal +import com.vitorpamplona.quartz.nip40Expiration.expiration +import com.vitorpamplona.quartz.nip59Giftwrap.rumors.RumorAssembler +import com.vitorpamplona.quartz.nip59Giftwrap.seals.SealEvent +import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent 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 class ConcordDirectInviteTest { + private val owner = NostrSignerInternal(KeyPair()) private val sender = NostrSignerInternal(KeyPair()) private val recipient = NostrSignerInternal(KeyPair()) private val stranger = NostrSignerInternal(KeyPair()) - private val invite = - CommunityInvite( - communityId = "11".repeat(32), - owner = "0f".repeat(32), - ownerSalt = "aa".repeat(32), - communityRoot = "bb".repeat(32), - name = "Nostrichs", + private suspend fun community(): NewConcordCommunity = ConcordCommunityFactory.create(owner, "Nostrichs", createdAt = 1L, relays = listOf("wss://relay.example")) + + private fun inviteFor( + community: NewConcordCommunity, + expiresAt: Long? = null, + relays: List = listOf("wss://relay.example"), + channels: List = emptyList(), + ) = CommunityInvite( + communityId = community.communityIdHex, + owner = community.ownerPubKey, + ownerSalt = community.ownerSalt.toHexKey(), + communityRoot = community.communityRoot.toHexKey(), + rootEpoch = community.rootEpoch, + controlPk = community.controlPkHex, + channels = channels, + relays = relays, + name = "Nostrichs", + expiresAt = expiresAt, + ) + + /** Wraps an arbitrary [seal] to [to] exactly like [ConcordDirectInvite.build] does (ephemeral author, p + k tags). */ + private suspend fun wrapSeal( + seal: Event, + to: String, + ): GiftWrapEvent { + val eph = NostrSignerInternal(KeyPair()) + return eph.sign( + createdAt = seal.createdAt, + kind = GiftWrapEvent.KIND, + tags = arrayOf(arrayOf("p", to), arrayOf("k", "3313")), + content = eph.nip44Encrypt(seal.toJson(), to), ) + } + + /** A seal from [sealer] carrying a kind-[kind] rumor that CLAIMS [claimedAuthor]. */ + private suspend fun forgedSeal( + sealer: NostrSigner, + claimedAuthor: String, + content: String, + kind: Int = ConcordDirectInvite.KIND, + ): SealEvent { + val rumor = RumorAssembler.assembleRumor(claimedAuthor, 1_700_000_000L, kind, emptyArray(), content) + return SealEvent.create(rumor, recipient.pubKey, sealer, createdAt = 1_700_000_000L) + } + + private fun json(invite: CommunityInvite) = ConcordJson.instance.encodeToString(CommunityInvite.serializer(), invite) @Test - fun directInviteRoundTripsToTheRecipient() = + fun directInviteRoundTripsWithTheVerifiedSender() = runTest { - val wrap = ConcordDirectInvite.build(sender, recipient.pubKey, invite, createdAt = 1_700_000_000L) + val c = community() + val wrap = ConcordDirectInvite.build(sender, recipient.pubKey, inviteFor(c), createdAt = 1_700_000_000L) - // Wrap is a giftwrap tagged for the recipient and indexable by k=3313. + // Wrap is a giftwrap tagged for the recipient and indexable by k=3313, from an ephemeral author. + assertEquals(GiftWrapEvent.KIND, wrap.kind) assertEquals(recipient.pubKey, wrap.tags.first { it[0] == "p" }[1]) assertEquals("3313", wrap.tags.first { it[0] == "k" }[1]) + assertFalse(wrap.pubKey == sender.pubKey) - val parsed = ConcordDirectInvite.parse(wrap, recipient) - assertNotNull(parsed) - assertEquals("Nostrichs", parsed.name) - assertEquals("11".repeat(32), parsed.communityId) + val opened = ConcordDirectInvite.open(wrap, recipient) + assertNotNull(opened) + assertEquals(sender.pubKey, opened.sender) + assertEquals(wrap.id, opened.wrapId) + assertEquals(1_700_000_000L, opened.sentAt) + assertEquals("Nostrichs", opened.invite.name) + assertEquals(c.communityIdHex, opened.invite.communityId) + assertEquals(c.controlPkHex, opened.invite.controlPk) + + // The legacy parse keeps working. + assertEquals(c.communityIdHex, ConcordDirectInvite.parse(wrap, recipient)?.communityId) } @Test fun strangersCannotOpenIt() = runTest { - val wrap = ConcordDirectInvite.build(sender, recipient.pubKey, invite, createdAt = 1L) - assertNull(ConcordDirectInvite.parse(wrap, stranger)) + val wrap = ConcordDirectInvite.build(sender, recipient.pubKey, inviteFor(community()), createdAt = 1L) + assertNull(ConcordDirectInvite.open(wrap, stranger)) } + + @Test + fun aRumorClaimingSomeoneElseIsRefused() = + runTest { + // The attacker seals (and so is the verified sender) a rumor claiming the owner wrote it. + val c = community() + val spoofed = wrapSeal(forgedSeal(stranger, claimedAuthor = owner.pubKey, content = json(inviteFor(c))), recipient.pubKey) + assertNull(ConcordDirectInvite.open(spoofed, recipient)) + + // The very same rumor claiming its real sealer opens. + val honest = wrapSeal(forgedSeal(stranger, claimedAuthor = stranger.pubKey, content = json(inviteFor(c))), recipient.pubKey) + assertEquals(stranger.pubKey, ConcordDirectInvite.open(honest, recipient)?.sender) + } + + @Test + fun theRumorKindIsTheAuthorityNotTheKTag() = + runTest { + // A k=3313-tagged wrap whose rumor is a kind-14 DM is not an invite. + val c = community() + val dm = wrapSeal(forgedSeal(sender, claimedAuthor = sender.pubKey, content = json(inviteFor(c)), kind = 14), recipient.pubKey) + assertNull(ConcordDirectInvite.open(dm, recipient)) + } + + @Test + fun wrapCarriesNip40ExpirationMatchingExpiresAt() = + runTest { + val c = community() + val expiresAtMs = 1_800_000_123_456L + val wrap = ConcordDirectInvite.build(sender, recipient.pubKey, inviteFor(c, expiresAt = expiresAtMs), createdAt = 1_700_000_000L) + assertEquals(1_800_000_123L, wrap.tags.expiration()) + + assertFalse(ConcordDirectInvite.isWrapExpired(wrap, nowSecs = 1_800_000_122L)) + assertTrue(ConcordDirectInvite.isWrapExpired(wrap, nowSecs = 1_800_000_123L)) + + // No expires_at, no expiration tag. + val open = ConcordDirectInvite.build(sender, recipient.pubKey, inviteFor(c), createdAt = 1_700_000_000L) + assertNull(open.tags.expiration()) + assertFalse(ConcordDirectInvite.isWrapExpired(open, nowSecs = Long.MAX_VALUE)) + + // An expired bundle still opens (a parked invite renders), but reports itself expired. + val opened = ConcordDirectInvite.open(wrap, recipient) + assertNotNull(opened) + assertTrue(opened.isExpired(nowMs = expiresAtMs + 1)) + assertFalse(opened.isExpired(nowMs = expiresAtMs - 1)) + } + + @Test + fun sealAndWrapAreBackdatedWithinTwoDaysButTheRumorKeepsTheRealTime() = + runTest { + val c = community() + val now = 1_700_000_000L + val outer = mutableListOf() + repeat(6) { + val wrap = ConcordDirectInvite.build(sender, recipient.pubKey, inviteFor(c), createdAt = now) + val seal = wrap.unwrapOrNull(recipient) + assertIs(seal) + for (t in listOf(wrap.createdAt, seal.createdAt)) { + assertTrue(t <= now, "outer timestamp $t is in the future") + assertTrue(t > now - ConcordDirectInvite.MAX_BACKDATE_SECS, "outer timestamp $t is backdated past two days") + outer += t + } + assertEquals(now, ConcordDirectInvite.open(wrap, recipient)?.sentAt) + } + // Twelve independent draws over a two-day range are not all "now". + assertTrue(outer.any { it < now }) + } + + @Test + fun theSection1BoundsApply() = + runTest { + val c = community() + val sixRelays = (1..6).map { "wss://r$it.example" } + val bounded = ConcordDirectInvite.open(ConcordDirectInvite.build(sender, recipient.pubKey, inviteFor(c, relays = sixRelays), createdAt = 1L), recipient) + assertEquals(sixRelays.take(ConcordInviteBundle.MAX_COMMUNITY_RELAYS), bounded?.invite?.relays) + + val tooMany = (0..ConcordInviteBundle.MAX_BUNDLE_CHANNELS).map { InviteChannel(id = it.toString(16).padStart(64, '0'), key = "cd".repeat(32), epoch = 0) } + assertNull(ConcordDirectInvite.open(ConcordDirectInvite.build(sender, recipient.pubKey, inviteFor(c, channels = tooMany), createdAt = 1L), recipient)) + } + + @Test + fun aBundleWhoseOwnerProofFailsIsRefused() = + runTest { + // A real community's id with someone else's owner: the id does not self-certify it. + val c = community() + val forged = inviteFor(c).copy(owner = stranger.pubKey) + assertNull(ConcordDirectInvite.open(ConcordDirectInvite.build(sender, recipient.pubKey, forged, createdAt = 1L), recipient)) + } + + @Test + fun inboxSinceRewindsByTheBackdateWindow() { + assertNull(ConcordDirectInvite.inboxSince(null)) + assertNull(ConcordDirectInvite.inboxSince(100L)) + assertEquals(1_700_000_000L - ConcordDirectInvite.MAX_BACKDATE_SECS, ConcordDirectInvite.inboxSince(1_700_000_000L)) + } } diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordInviteVendTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordInviteVendTest.kt new file mode 100644 index 0000000000..fc7577a4c6 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordInviteVendTest.kt @@ -0,0 +1,117 @@ +/* + * 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.cord05Invites + +import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEntry +import com.vitorpamplona.quartz.concord.cord02Community.PrivateChannelKey +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * A Direct Invite for an already-joined community is a catch-up: it may only add Private Channel + * keys on the SAME base (root, epoch, control_pk) — never move the base (Armada `catchUpChannelIds`). + */ +class ConcordInviteVendTest { + private val communityId = "11".repeat(32) + private val root = "22".repeat(32) + private val controlPk = "33".repeat(32) + private val chanA = "a1".repeat(32) + private val chanB = "b2".repeat(32) + private val keyA = "ca".repeat(32) + private val keyB = "db".repeat(32) + + private val held = + ConcordCommunityListEntry( + id = communityId, + owner = "44".repeat(32), + ownerSalt = "55".repeat(32), + root = root, + rootEpoch = 3, + controlPk = controlPk, + privateChannels = listOf(PrivateChannelKey(chanA, keyA, 1, "mods")), + relays = listOf("wss://relay.example"), + name = "Nostrichs", + inviteRef = "naddr1ref", + ) + + private fun bundle( + root: String = this.root, + epoch: Long = 3, + controlPk: String? = this.controlPk, + channels: List, + ) = CommunityInvite( + communityId = communityId, + owner = held.owner, + ownerSalt = held.ownerSalt, + communityRoot = root, + rootEpoch = epoch, + controlPk = controlPk, + channels = channels, + name = "Nostrichs", + ) + + @Test + fun aNewPrivateChannelKeyOnTheSameBaseIsACatchUp() { + val b = bundle(channels = listOf(InviteChannel(chanA, keyA, 1, "mods"), InviteChannel(chanB.uppercase(), keyB, 0, "vip"))) + assertEquals(listOf(chanB), ConcordInviteVend.catchUpChannelIds(held, b)) + + val adopted = ConcordInviteVend.adoptCatchUp(held, b) + assertNotNull(adopted) + // The base never moves. + assertEquals(root, adopted.root) + assertEquals(3, adopted.rootEpoch) + assertEquals(controlPk, adopted.controlPk) + assertEquals(held.inviteRef, adopted.inviteRef) + assertEquals(setOf(chanA to keyA, chanB to keyB), adopted.privateChannels.map { it.channelId to it.key }.toSet()) + } + + @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()) + } + + @Test + fun aBundleOnAnotherBaseIsNeverACatchUp() { + val grant = listOf(InviteChannel(chanB, keyB, 0, "vip")) + assertTrue(ConcordInviteVend.catchUpChannelIds(held, bundle(root = "99".repeat(32), channels = grant)).isEmpty()) + assertTrue(ConcordInviteVend.catchUpChannelIds(held, bundle(epoch = 4, channels = grant)).isEmpty()) + assertTrue(ConcordInviteVend.catchUpChannelIds(held, bundle(controlPk = "98".repeat(32), channels = grant)).isEmpty()) + assertTrue(ConcordInviteVend.catchUpChannelIds(held, bundle(controlPk = null, channels = grant)).isEmpty()) + assertNull(ConcordInviteVend.adoptCatchUp(held, bundle(root = "99".repeat(32), channels = grant))) + } + + @Test + fun nothingHeldMeansNoCatchUpAndKeylessGrantsDeliverNothing() { + assertTrue(ConcordInviteVend.catchUpChannelIds(null, bundle(channels = listOf(InviteChannel(chanB, keyB, 0)))).isEmpty()) + assertTrue(ConcordInviteVend.catchUpChannelIds(held, bundle(channels = listOf(InviteChannel(chanB, "", 0)))).isEmpty()) + assertNull(ConcordInviteVend.adoptCatchUp(held, bundle(channels = emptyList()))) + } +} From 6b8c3658d7e2bebc3d947f10deeff80d63a3d176 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 29 Sep 2026 18:20:46 +0000 Subject: [PATCH 6/7] =?UTF-8?q?feat(concord):=20Invite=20Registry=20(vsk?= =?UTF-8?q?=208)=20and=20the=20Public/Private=20mode=20(CORD-05=20=C2=A75)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - quartz: ConcordInviteRegistry (edition builder at invite_links_locator(cid, creator), strict JSON-array decode keeping 64-hex entries, nextLinks pruning expired/tombstoned Invite List links); the registry gate now also requires a JSON array; the fold adds per-creator inviteRegistries (CREATE_INVITE-gated, cited) and liveInviteLinks / isPublic / isPublic(excluding) / hasForeignLiveLinks / banRequiresRefounding / retiringWouldPrivatize. - commons: ConcordModeration.setInviteRegistry; mint and revoke publish the creator's registry; a Private ban Refounds (a Public ban is the Banlist alone); retiring the last live link runs a privatizing Refounding (privatizeConcordCommunity) and revoke returns ConcordRevokeResult. - UI: Public/Private subtitle in the community server view; the revoke dialog warns when the link is the last live one. - amy: roles reports public / live_invite_links; invite and revoke publish the registry and report the mode; ban reports refound_required; refound --privatize. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01PmuzkD5qdqgv6dMeRMoC5N --- .../concord/ConcordChannelListScreen.kt | 22 +- .../concord/ConcordInviteLinksScreen.kt | 35 ++- cli/README.md | 10 +- .../amethyst/cli/commands/ConcordCommands.kt | 44 ++- .../cli/commands/ConcordModCommands.kt | 80 ++++- .../commons/actions/ConcordModeration.kt | 23 ++ .../commons/model/AccountConcordActions.kt | 144 +++++++-- .../commons/model/ConcordRevokeResult.kt | 48 +++ .../ConcordInviteRegistryPublishTest.kt | 121 ++++++++ .../composeResources/values/strings.xml | 8 + .../2026-09-29-concord-spec-conformance.md | 2 +- .../cord02Community/ConcordCommunityState.kt | 74 +++++ .../concord/cord04Roles/AuthorityResolver.kt | 6 +- .../cord05Invites/ConcordInviteRegistry.kt | 174 +++++++++++ .../ConcordInviteRegistryTest.kt | 273 ++++++++++++++++++ 15 files changed, 1013 insertions(+), 51 deletions(-) create mode 100644 commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/ConcordRevokeResult.kt create mode 100644 commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordInviteRegistryPublishTest.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordInviteRegistry.kt create mode 100644 quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordInviteRegistryTest.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordChannelListScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordChannelListScreen.kt index 9f67f7629f..8d35e51270 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordChannelListScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordChannelListScreen.kt @@ -96,6 +96,8 @@ import com.vitorpamplona.amethyst.commons.resources.concord_leave_message import com.vitorpamplona.amethyst.commons.resources.concord_leave_owner_warning import com.vitorpamplona.amethyst.commons.resources.concord_leave_title import com.vitorpamplona.amethyst.commons.resources.concord_members_title +import com.vitorpamplona.amethyst.commons.resources.concord_mode_private +import com.vitorpamplona.amethyst.commons.resources.concord_mode_public import com.vitorpamplona.amethyst.commons.resources.concord_typing_many import com.vitorpamplona.amethyst.commons.resources.concord_typing_one import com.vitorpamplona.amethyst.commons.resources.concord_typing_two @@ -108,6 +110,7 @@ import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav import com.vitorpamplona.amethyst.commons.ui.navigation.topbars.ShorterTopAppBar import com.vitorpamplona.amethyst.commons.ui.note.timeAgo import com.vitorpamplona.amethyst.commons.ui.platform.AppBottomBar +import com.vitorpamplona.amethyst.commons.ui.pluralStringRes import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.publicChannels.concord.datasource.ConcordChannelPreviewLoader import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.publicChannels.concord.datasource.ConcordChannelSubscription import com.vitorpamplona.amethyst.commons.ui.stringRes @@ -265,7 +268,24 @@ fun ConcordChannelListScreen( Scaffold( topBar = { ShorterTopAppBar( - title = { Text(communityName, maxLines = 1) }, + title = { + Column { + Text(communityName, maxLines = 1) + // The Public/Private mode (CORD-05 §5): any live invite link in the folded + // registries makes the community Public; none makes it Private, where a ban + // rotates the keys (CORD-06 §3). Unknown until the Control Plane has folded. + state?.let { folded -> + val links = folded.liveInviteLinks.size + Text( + if (folded.isPublic) pluralStringRes(Res.plurals.concord_mode_public, links, links) else stringRes(Res.string.concord_mode_private), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } + }, navigationIcon = { // Back arrow only when pushed from elsewhere; as a bottom-nav tab the bar takes its place. if (canPop) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordInviteLinksScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordInviteLinksScreen.kt index 9b03cc9e9f..76190050de 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordInviteLinksScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordInviteLinksScreen.kt @@ -56,7 +56,9 @@ import androidx.compose.ui.platform.LocalClipboard 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.icons.symbols.MaterialSymbols +import com.vitorpamplona.amethyst.commons.model.ConcordRevokeResult import com.vitorpamplona.amethyst.commons.resources.Res import com.vitorpamplona.amethyst.commons.resources.back import com.vitorpamplona.amethyst.commons.resources.cancel @@ -67,9 +69,12 @@ import com.vitorpamplona.amethyst.commons.resources.concord_invite_links_unreada import com.vitorpamplona.amethyst.commons.resources.concord_invite_revoke_action import com.vitorpamplona.amethyst.commons.resources.concord_invite_revoke_confirm import com.vitorpamplona.amethyst.commons.resources.concord_invite_revoke_explainer +import com.vitorpamplona.amethyst.commons.resources.concord_invite_revoke_privatize_warning import com.vitorpamplona.amethyst.commons.resources.concord_invite_revoke_title import com.vitorpamplona.amethyst.commons.resources.concord_invite_revoked_failed import com.vitorpamplona.amethyst.commons.resources.concord_invite_revoked_ok +import com.vitorpamplona.amethyst.commons.resources.concord_invite_revoked_privatize_pending +import com.vitorpamplona.amethyst.commons.resources.concord_invite_revoked_privatized import com.vitorpamplona.amethyst.commons.resources.copy_to_clipboard import com.vitorpamplona.amethyst.commons.resources.more_options import com.vitorpamplona.amethyst.commons.ui.components.util.setText @@ -77,6 +82,7 @@ import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav import com.vitorpamplona.amethyst.commons.ui.stringRes import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel import com.vitorpamplona.quartz.concord.cord05Invites.ConcordInviteListEntry +import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.launch import java.text.DateFormat import java.util.Date @@ -125,6 +131,12 @@ fun ConcordInviteLinksScreen( var confirming by remember { mutableStateOf(null) } var revoking by remember { mutableStateOf(false) } + // The folded Control Plane, for the Public/Private mode (CORD-05 §5): revoking the community's + // last live link flips it Private, which is a Refounding, so the dialog says so before it happens. + val revision by account.concordSessions.revision.collectAsStateWithLifecycle() + val session = remember(account, communityId, revision) { account.concordSessions.sessionFor(communityId) } + val communityState by (session?.state ?: remember { MutableStateFlow(null) }).collectAsStateWithLifecycle() + LaunchedEffect(communityId, reloads) { state = LinksState.Loading state = account.concord.listConcordInviteLinks(communityId)?.let { LinksState.Loaded(it) } ?: LinksState.Unreadable @@ -185,10 +197,22 @@ fun ConcordInviteLinksScreen( } confirming?.let { link -> + val privatizes = + remember(link, communityState) { + val signer = runCatching { link.signerPubKeyHex() }.getOrNull() + signer != null && communityState?.retiringWouldPrivatize(listOf(signer)) == true + } AlertDialog( onDismissRequest = { if (!revoking) confirming = null }, title = { Text(stringRes(Res.string.concord_invite_revoke_title)) }, - text = { Text(stringRes(Res.string.concord_invite_revoke_explainer)) }, + text = { + Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { + Text(stringRes(Res.string.concord_invite_revoke_explainer)) + if (privatizes) { + Text(stringRes(Res.string.concord_invite_revoke_privatize_warning), color = MaterialTheme.colorScheme.error) + } + } + }, confirmButton = { TextButton( enabled = !revoking, @@ -196,10 +220,15 @@ fun ConcordInviteLinksScreen( revoking = true scope.launch { try { - val ok = account.concord.revokeConcordInvite(communityId, link.token) + val result = account.concord.revokeConcordInvite(communityId, link.token) accountViewModel.toastManager.toast( Res.string.concord_invite_links_title, - if (ok) Res.string.concord_invite_revoked_ok else Res.string.concord_invite_revoked_failed, + when (result) { + ConcordRevokeResult.FAILED -> Res.string.concord_invite_revoked_failed + ConcordRevokeResult.REVOKED -> Res.string.concord_invite_revoked_ok + ConcordRevokeResult.PRIVATIZED -> Res.string.concord_invite_revoked_privatized + ConcordRevokeResult.PRIVATIZED_REFOUND_PENDING -> Res.string.concord_invite_revoked_privatize_pending + }, ) // Re-read either way: on success the link is gone from the list, and on // failure the list is the only thing that can say whether it changed. diff --git a/cli/README.md b/cli/README.md index 519e277554..edb94b4c6c 100644 --- a/cli/README.md +++ b/cli/README.md @@ -679,16 +679,16 @@ also carried on-relay as the encrypted, fragmented kind:33302 Community List | `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 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). | -| `amy concord revoke COMMUNITY TOKEN\|URL` | Retire a link you minted: publishes a `vsk=9` tombstone at its coordinate, then records it in your Invite List. | +| `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`. | +| `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 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…]` | 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. | -| `amy concord roles COMMUNITY` | List live roles + the current banlist (CORD-04). | +| `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 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 ban COMMUNITY USER` / `unban COMMUNITY USER` | Ban / unban a member. | +| `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 dissolve COMMUNITY --yes` | Owner only, irreversible: publish the `eid`-bound dissolution tombstone that seals the community read-only (CORD-02 §9). | ### cordn (MLS over an MCP coordinator) diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ConcordCommands.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ConcordCommands.kt index dd7a23c1f4..f2b723867e 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ConcordCommands.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ConcordCommands.kt @@ -86,7 +86,9 @@ object ConcordCommands { | --rejoin re-accepts that link (a bundle never | moves the base on its own, CORD-06 §2); | refuses if that epoch banned us - | concord roles COMMUNITY list live roles + current banlist (CORD-04) + | concord roles COMMUNITY list live roles + current banlist (CORD-04), + | 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 ban COMMUNITY USER ban a member @@ -94,6 +96,9 @@ object ConcordCommands { | concord refound COMMUNITY --remove U[,U] CORD-06 Refounding: rotate the root (and the | control_root) so removed members lose every | key — the hard removal a ban cannot give + | concord refound COMMUNITY --privatize a Refounding that removes nobody: converts a + | Public community to Private (owed after the + | last live invite link is revoked, CORD-05 §2) | concord dissolve COMMUNITY --yes CORD-02 §9: owner-only, IRREVERSIBLE tombstone | that seals the community read-only for everyone """.trimMargin() @@ -313,7 +318,7 @@ object ConcordCommands { ), ), ) - if (!recorded) { + if (recorded == null) { return Output.error( "invite_unrecordable", "could not record the link signer in your invite list (kind 13303), so this link could never be refreshed after a Refounding — not minting it", @@ -323,12 +328,15 @@ object ConcordCommands { val ack = ctx.publish(minted.bundleEvent, relaysFor(ctx, sc)) RawEventSupport.publishGuard(ack, minted.bundleEvent.id)?.let { return it } + // "A Registry edit accompanies every mint" (CORD-05 §5): the link now makes the community Public. + val registry = ConcordModCommands.publishInviteRegistry(ctx, sc, dataDir, recorded, minted = listOf(minted.linkSignerPubKey)) + Output.emit( mapOf( "url" to minted.url, "bundle_event_id" to minted.bundleEvent.id, "link_signer" to minted.linkSignerPubKey, - ) + RawEventSupport.ackFields(ack), + ) + registry + RawEventSupport.ackFields(ack), ) return 0 } @@ -397,21 +405,29 @@ object ConcordCommands { ctx, ConcordInviteListDocument(tombstones = listOf(ConcordInviteListTombstone(token = token, communityId = sc.communityId))), ) - if (!recorded) { + if (recorded == null) { System.err.println( "[concord] the link is revoked on the wire but the tombstone could not be recorded in your invite list (kind 13303); re-run this command once your outbox relays are reachable", ) } + // "...and every retire" (CORD-05 §5). Retiring the last live link flips the community + // Private, which is a Refounding (CORD-05 §2): reported, and run with `refound --privatize`. + val signer = entry.signerPubKeyHex().lowercase() + val registry = ConcordModCommands.publishInviteRegistry(ctx, sc, dataDir, recorded ?: list, retired = listOf(signer)) + if (registry["privatized"] == true) { + System.err.println("[concord] that was the community's last live invite link, so it is Private now: run `amy concord refound ${sc.communityId} --privatize` to rotate its keys (CORD-06 §3)") + } + Output.emit( mapOf( "revoked" to true, "token" to token, "community_id" to sc.communityId, - "link_signer" to entry.signerPubKeyHex(), + "link_signer" to signer, "tombstone_event_id" to tombstone.id, - "tombstoned_in_list" to recorded, - ) + RawEventSupport.ackFields(ack), + "tombstoned_in_list" to (recorded != null), + ) + registry + RawEventSupport.ackFields(ack), ) return 0 } @@ -826,7 +842,8 @@ object ConcordCommands { } /** - * Merges [patch] into the published list and republishes it, returning whether it landed. + * Merges [patch] into the published list and republishes it, returning the merged document when + * it landed and null when it did not. * * Read-merge-write, and **aborts rather than overwriting** when the read fails: kind 13303 is * replaceable, so writing a patch-only document over a list we could not read deletes every @@ -839,12 +856,13 @@ object ConcordCommands { suspend fun publishInviteList( ctx: Context, patch: ConcordInviteListDocument, - ): Boolean { + ): ConcordInviteListDocument? { val relays = ctx.outboxRelays() - if (relays.isEmpty()) return false - val base = readInviteList(ctx) ?: return false - val event = ConcordInviteListEvent.create(ctx.signer, ConcordInviteList.merge(base, patch), TimeUtils.now()) - return ctx.publish(event, relays).values.any { it.accepted } + if (relays.isEmpty()) return null + val base = readInviteList(ctx) ?: return null + val merged = ConcordInviteList.merge(base, patch) + val event = ConcordInviteListEvent.create(ctx.signer, merged, TimeUtils.now()) + return if (ctx.publish(event, relays).values.any { it.accepted }) merged else null } fun notFound(handle: String): Int { diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ConcordModCommands.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ConcordModCommands.kt index 8e5102440c..f34c9d2852 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ConcordModCommands.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ConcordModCommands.kt @@ -36,6 +36,8 @@ 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.cord05Invites.ConcordInviteListDocument +import com.vitorpamplona.quartz.concord.cord05Invites.ConcordInviteRegistry import com.vitorpamplona.quartz.concord.cord05Invites.InviteBundleStatus import com.vitorpamplona.quartz.concord.cord06Rekey.ConcordRefounding import com.vitorpamplona.quartz.concord.cord06Rekey.IncompleteControlPlaneException @@ -80,12 +82,57 @@ object ConcordModCommands { ) }, "banned" to ConcordModeration.currentBanned(editions, sc.communityId.hexToByteArray(), sc.owner).toList(), + // CORD-05 §5: the folded Invite Registries are the Public/Private source of truth. + "public" to state.isPublic, + "live_invite_links" to state.liveInviteLinks.size, + "invite_registries" to state.inviteRegistries.mapValues { it.value.size }, ), ) return 0 } } + /** + * Publishes this account's Invite Registry (CORD-05 §5, `vsk 8`) after a mint or a retire of + * [sc]'s links, and reports the Public/Private mode around it. Best-effort, like Amethyst's: a + * link works without its registry, so a missing permission or `control_root` only skips the edit. + * + * [list] is the Invite List as just written (null when unreadable); the next registry is + * [ConcordInviteRegistry.nextLinks] over this account's honored head, so expired and tombstoned + * links drop out and links minted before any registry existed are re-listed. + */ + internal suspend fun publishInviteRegistry( + ctx: Context, + sc: StoredCommunity, + dataDir: DataDir, + list: ConcordInviteListDocument?, + minted: List = emptyList(), + retired: List = emptyList(), + ): Map { + val (cp, editions) = load(ctx, sc, dataDir) + val cid = sc.communityId.hexToByteArray() + val before = ConcordCommunityState.fold(editions, cid, sc.owner) + val me = ctx.signer.pubKey + val privatizes = before.retiringWouldPrivatize(retired) + val authorized = before.authority.isOwner(me) || before.authority.hasPermission(me, ConcordPermissions.CREATE_INVITE) + val next = ConcordInviteRegistry.nextLinks(before.registryOf(me), list, sc.communityId, TimeUtils.now(), minted, retired) + val wrap = + if (authorized && cp.canWrite) { + ConcordModeration.setInviteRegistry(ctx.signer, cp, cid, next, editions, TimeUtils.now(), owner = sc.owner) + } else { + System.err.println("[concord] invite registry not published: this account ${if (!authorized) "does not hold CREATE_INVITE" else "holds no control_root"} (CORD-05 §5)") + null + } + val published = wrap != null && ctx.publish(wrap, ConcordCommands.relaysFor(ctx, sc)).values.any { it.accepted } + // The mode as it reads once the edition lands: the same fold, with it. + val after = if (published && wrap != null) ConcordCommunityState.fold(editions + ConcordActions.controlEditions(listOf(wrap), cp), cid, sc.owner) else before + return mapOf( + "registry_published" to published, + "public" to after.isPublic, + "live_invite_links" to after.liveInviteLinks.size, + ) + (if (privatizes) mapOf("privatized" to true, "refound_required" to true) else emptyMap()) + } + /** Defines a new role: `role PERM...` (perms by name, e.g. BAN KICK). */ suspend fun defineRole( dataDir: DataDir, @@ -229,7 +276,19 @@ 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, "banned" to ban) + RawEventSupport.ackFields(ack)) + // CORD-06 §3 / CORD-05 §5: a Public ban is the Banlist alone; a ban from a Private + // community owes a Refounding (`concord refound COMMUNITY --remove USER`). Judged with + // the target's own invite registry left out, since the ban stops honoring it. + val mode = + if (ban) { + val state = ConcordCommunityState.fold(editions, cid, sc.owner) + val refound = state.banRequiresRefounding(listOf(member)) + if (refound) System.err.println("[concord] the community is Private: run `amy concord refound ${sc.communityId} --remove $member` to sever the banned member's keys (CORD-06 §3)") + mapOf("public" to !refound, "refound_required" to refound) + } else { + emptyMap() + } + Output.emit(mapOf("member" to member, "banned" to ban) + mode + RawEventSupport.ackFields(ack)) return 0 } } @@ -275,7 +334,11 @@ object ConcordModCommands { ): Int { val args = Args(rest) val handle = args.positional(0, "community") - val removeArg = args.flag("remove") ?: return Output.error("bad_args", "refound --remove USER[,USER…]").let { 2 } + val removeArg = args.flag("remove") + // CORD-06 §3 "converting a Public Community to Private": a Refounding that removes nobody, + // owed when the last live invite link is retired (CORD-05 §2/§5). + val privatize = args.bool("privatize") + if (removeArg == null && !privatize) return Output.error("bad_args", "refound --remove USER[,USER…] | --privatize").let { 2 } args.rejectUnknown() val sc = ConcordStore(dataDir.concordFile).find(handle) ?: return ConcordCommands.notFound(handle) @@ -283,12 +346,13 @@ object ConcordModCommands { ctx.prepare() val removed = removeArg - .split(',') - .map { it.trim() } - .filter { it.isNotEmpty() } - .map { ctx.requireUserHex(it).lowercase() } - .toSet() - if (removed.isEmpty()) return Output.error("bad_args", "--remove needs at least one user") + ?.split(',') + ?.map { it.trim() } + ?.filter { it.isNotEmpty() } + ?.map { ctx.requireUserHex(it).lowercase() } + ?.toSet() + .orEmpty() + if (removed.isEmpty() && !privatize) return Output.error("bad_args", "--remove needs at least one user") // Death wins every race (CORD-02 §9): no epoch advance past a tombstone is honored. if (ConcordCommands.isDissolved(ctx, sc)) { diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordModeration.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordModeration.kt index 8881ee593b..105444342d 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordModeration.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordModeration.kt @@ -35,6 +35,7 @@ import com.vitorpamplona.quartz.concord.cord04Roles.ControlRootWrap import com.vitorpamplona.quartz.concord.cord04Roles.GrantEntity import com.vitorpamplona.quartz.concord.cord04Roles.MetadataEntity import com.vitorpamplona.quartz.concord.cord04Roles.RoleEntity +import com.vitorpamplona.quartz.concord.cord05Invites.ConcordInviteRegistry import com.vitorpamplona.quartz.concord.crypto.ConcordKeyDerivation import com.vitorpamplona.quartz.concord.crypto.ControlPlaneKeys import com.vitorpamplona.quartz.concord.envelope.ConcordStreamEnvelope @@ -344,6 +345,28 @@ object ConcordModeration { owner: HexKey, ): Set = AuthorityResolver.resolve(current, communityId, owner).bannedMembers() + /** + * Publishes [actor]'s Invite Registry (CORD-05 §5, `vsk 8`) listing [linkSigners] — the + * link-signer pubkeys of their live public links, locators only. The entity sits at + * `invite_links_locator(community_id, actor)`, so it chains onto [actor]'s own registry head and + * can never touch another creator's; it is honored at fold only while [actor] holds + * CREATE_INVITE (or is the owner). Compute [linkSigners] with [ConcordInviteRegistry.nextLinks]. + */ + suspend fun setInviteRegistry( + actor: NostrSigner, + controlPlane: ControlPlaneKeys, + communityId: ByteArray, + linkSigners: Collection, + current: List, + createdAt: Long, + citation: AuthorityCitation? = null, + owner: HexKey, + ): Event { + val entityId = ConcordInviteRegistry.coordinate(communityId, actor.pubKey) + val head = headOf(current, communityId, entityId, owner) + return wrap(actor, controlPlane, communityId, ControlEntityKind.INVITE_REGISTRY, entityId, head, ConcordInviteRegistry.encode(linkSigners), current, createdAt, citation, owner) + } + private suspend fun setBanlist( actor: NostrSigner, controlPlane: ControlPlaneKeys, diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/AccountConcordActions.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/AccountConcordActions.kt index 0ebe09d709..4b9eb93f9d 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/AccountConcordActions.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/AccountConcordActions.kt @@ -56,6 +56,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.ConcordInviteRegistry import com.vitorpamplona.quartz.concord.cord05Invites.InviteBundleStatus import com.vitorpamplona.quartz.concord.cord05Invites.InviteRelayDictionary import com.vitorpamplona.quartz.concord.cord06Rekey.ConcordRefounding @@ -278,7 +279,8 @@ class AccountConcordActions( } /** - * Merges [patch] into the published Invite List and republishes it, returning whether it landed. + * Merges [patch] into the published Invite List and republishes it, returning the merged document + * when it landed and null when it did not. * * Read-merge-write, and **aborts rather than overwriting** when the read fails: the list is * replaceable, so publishing a patch-only document over an unread list deletes every other @@ -286,20 +288,62 @@ class AccountConcordActions( * rotation. A momentarily unreachable relay or a bunker signer that declines one decrypt is * enough to trigger that, which is exactly how the kind-13302 community list was once emptied. */ - private suspend fun publishConcordInviteList(patch: ConcordInviteListDocument): Boolean { + private suspend fun publishConcordInviteList(patch: ConcordInviteListDocument): ConcordInviteListDocument? { val publishTo = account.outboxRelays.flow.value - if (publishTo.isEmpty()) return false + if (publishTo.isEmpty()) return null val base = readConcordInviteList() ?: run { Log.w("Concord") { "Refusing to write the invite list: could not read the current one (would drop other links' signer_sk)" } - return false + return null } + val merged = ConcordInviteList.merge(base, patch) // publishAndConfirm, never publish: `INostrClient.publish` returns Unit — it queues the event // and never reports acceptance — so a `runCatching { publish(); true }` is true whenever // local signing worked, and every caller's "did the record land?" gate becomes decorative. - return runCatching { - account.client.publishAndConfirm(ConcordInviteListEvent.create(account.signer, ConcordInviteList.merge(base, patch), TimeUtils.now()), publishTo) - }.onFailure { Log.w("Concord", "invite list publish failed", it) }.getOrDefault(false) + val landed = + runCatching { + account.client.publishAndConfirm(ConcordInviteListEvent.create(account.signer, merged, TimeUtils.now()), publishTo) + }.onFailure { Log.w("Concord", "invite list publish failed", it) }.getOrDefault(false) + return if (landed) merged else null + } + + /** + * Publishes this account's Invite Registry for [entry]'s community (CORD-05 §5, `vsk 8`): "a + * Registry edit accompanies every mint and every retire". The list is this account's honored + * registry plus the live links its Invite List [list] holds plus [minted], minus [retired] and + * minus every tombstoned or expired link ([ConcordInviteRegistry.nextLinks]), so an elapsed link + * stops keeping the community Public. Returns whether an edition was published. + * + * Best-effort, like the reference client's: the registry never gates a link working. It is + * skipped when there is no session to chain onto, when this account no longer holds + * CREATE_INVITE (every reader would drop the edition), when the `control_root` is not held + * (CORD-02 §2), and when the next list equals the one already honored. + */ + private suspend fun publishConcordInviteRegistry( + entry: ConcordCommunityListEntry, + list: ConcordInviteListDocument?, + minted: List = emptyList(), + retired: List = emptyList(), + ): Boolean { + val session = account.concordSessions.sessionFor(entry.id) ?: 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 next = ConcordInviteRegistry.nextLinks(published, list, entry.id, TimeUtils.now(), minted, retired) + val hasHead = state?.inviteRegistries?.containsKey(me.lowercase()) == true + if (next == published.sorted() && (hasHead || next.isEmpty())) return false + // The writer chains off the same authorized head the fold honors (ConcordModeration.headOf). + val wrap = + try { + ConcordModeration.setInviteRegistry(account.signer, cp, entry.id.hexToByteArray(), next, session.controlEditions(), TimeUtils.now(), owner = entry.owner) + } catch (e: Exception) { + Log.w("Concord", "invite registry build failed for ${entry.id}", e) + return false + } + publishConcordWrap(entry, wrap) + return true } /** @@ -414,7 +458,8 @@ class AccountConcordActions( // was never stored can never be refreshed, so the next Refounding orphans it and everyone // holding it is stranded — with nothing to have warned them. Failing the mint is the honest // outcome; a stored entry for a link nobody received is harmless by comparison. - if (!publishConcordInviteList( + val recorded = + publishConcordInviteList( ConcordInviteListDocument( entries = listOf( @@ -428,12 +473,15 @@ class AccountConcordActions( ), ), ) - ) { + if (recorded == null) { Log.w("Concord") { "Invite not minted for ${entry.id}: its link signer could not be recorded, so the link could never be refreshed" } return null } if (publishTo.isNotEmpty()) account.client.publish(minted.bundleEvent, publishTo) + // The member-facing shadow of the list we just wrote (CORD-05 §5): the link now makes the + // community Public. Best-effort — the link works without it. + publishConcordInviteRegistry(entry, recorded, minted = listOf(minted.linkSignerPubKey)) return minted.url } @@ -472,24 +520,29 @@ class AccountConcordActions( * leave the link live with its signer gone and no way left to retire it. A failed list write is * recoverable — the link is already dead on the wire, and the refresh path re-mints only a * coordinate that still resolves Live. + * + * Every retire also edits this account's Invite Registry (CORD-05 §5). When the link was the + * community's last live one, retiring it flips the community Private — "a Refounding (CORD-06)" + * (CORD-05 §2) — so this then Refounds with nobody removed, provided this account may (it takes + * BAN). The result says which of those happened. */ suspend fun revokeConcordInvite( communityId: String, token: String, - ): Boolean { - if (!account.isWriteable()) return false + ): ConcordRevokeResult { + if (!account.isWriteable()) return ConcordRevokeResult.FAILED val entry = account.concordChannelList.liveCommunities.value - .firstOrNull { it.id == communityId } ?: return false + .firstOrNull { it.id == communityId } ?: return ConcordRevokeResult.FAILED val link = readConcordInviteList()?.entries?.firstOrNull { it.token == token && it.communityId == communityId } ?: run { Log.w("Concord") { "Cannot revoke $token: it is not in this account's invite list, so its link signer is unknown" } - return false + return ConcordRevokeResult.FAILED } val relays = entry.relays.mapNotNullTo(mutableSetOf()) { RelayUrlNormalizer.normalizeOrNull(it) }.ifEmpty { account.outboxRelays.flow.value } - if (relays.isEmpty()) return false + if (relays.isEmpty()) return ConcordRevokeResult.FAILED // Confirmed, not fire-and-forget. A `publish` that returns Unit would report success for a // tombstone no relay stored — and the list write below would then drop this entry on merge, // destroying the only `signer_sk` that could ever retire the link while the link stays live. @@ -497,14 +550,31 @@ class AccountConcordActions( runCatching { account.client.publishAndConfirm(ConcordActions.revokeBundleAt(link.signerSk.hexToByteArray(), TimeUtils.now()), relays) }.onFailure { Log.w("Concord", "invite revocation failed for $communityId", it) }.getOrDefault(false) - if (!published) return false + if (!published) return ConcordRevokeResult.FAILED - if (!publishConcordInviteList(ConcordInviteListDocument(tombstones = listOf(ConcordInviteListTombstone(token = token, communityId = communityId))))) { + val signer = link.signerPubKeyHex().lowercase() + // Judged on the fold BEFORE our registry edit lands: afterwards the link is gone from it. + val privatizes = + account.concordSessions + .sessionFor(communityId) + ?.state + ?.value + ?.retiringWouldPrivatize(listOf(signer)) == true + + val recorded = publishConcordInviteList(ConcordInviteListDocument(tombstones = listOf(ConcordInviteListTombstone(token = token, communityId = communityId)))) + if (recorded == null) { // The link is already dead on the wire, so this is bookkeeping we can retry rather than a // failed revocation. Reported as success for exactly that reason. Log.w("Concord") { "Revoked $token on the wire but could not tombstone it in the invite list; a later revoke will record it" } } - return true + publishConcordInviteRegistry(entry, recorded, retired = listOf(signer)) + + if (!privatizes) return ConcordRevokeResult.REVOKED + // The last live link is gone: the community is Private now, and whoever already fetched a + // link holds the current root. CORD-05 §2/§5: this is a Refounding (CORD-06 §3, "converting a + // Public Community to Private"), which re-keys the members and leaves the lurkers behind. + Log.i("Concord") { "Retired the last live invite link of $communityId: the community is Private, Refounding" } + return if (privatizeConcordCommunity(communityId)) ConcordRevokeResult.PRIVATIZED else ConcordRevokeResult.PRIVATIZED_REFOUND_PENDING } /** Leave a joined Concord community: drop it from the Community List and tombstone it (CORD-02 §8). */ @@ -1194,7 +1264,17 @@ class AccountConcordActions( return if (canBan) communityId to author else null } - /** Add [member] to the community banlist. */ + /** + * Ban [member] (CORD-04 §5 composition): the Banlist edition first, then — only when the + * community is **Private** — the Refounding (CORD-06 §3). A Public ban is the Banlist alone + * (CORD-05 §5): anyone holding a live link can fetch a rotated root straight back out of its + * bundle, so rotating would cost every member a rekey and sever nobody. The mode is judged with + * the target's own links left out, since the ban stops honoring their registry + * ([com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityState.banRequiresRefounding]). + * + * Returns whether the ban landed; a Refounding that fails is logged and can be retried with + * [refoundConcordCommunity]. + */ suspend fun banConcordMember( communityId: String, member: HexKey, @@ -1204,6 +1284,13 @@ class AccountConcordActions( val cp = controlKeysForAction(session, ConcordPermissions.BAN, member) ?: return false 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))) { + if (!refoundConcordCommunity(communityId, setOf(member))) { + Log.w("Concord") { "Banned $member from the Private community $communityId, but its Refounding did not complete" } + } + } return true } @@ -1240,6 +1327,22 @@ class AccountConcordActions( suspend fun refoundConcordCommunity( communityId: String, removed: Set, + ): Boolean { + if (removed.isEmpty()) return false + return refound(communityId, removed) + } + + /** + * Converts the community to Private (CORD-06 §3): a Refounding with nobody removed, run when its + * last live invite link is retired (CORD-05 §2/§5). Every member is re-keyed; whoever only ever + * fetched a link — and so holds the current root without being a member — is left behind. + * Takes BAN (or ownership), like any Refounding. + */ + suspend fun privatizeConcordCommunity(communityId: String): Boolean = refound(communityId, emptySet()) + + private suspend fun refound( + communityId: String, + removed: Set, ): Boolean { if (!account.isWriteable()) return false val session = account.concordSessions.sessionFor(communityId) ?: return false @@ -1258,7 +1361,7 @@ class AccountConcordActions( val iCanBan = authority.isOwner(account.signer.pubKey) || authority.hasPermission(account.signer.pubKey, ConcordPermissions.BAN) if (!iCanBan) return false val removedLower = removed.mapTo(HashSet()) { it.lowercase() } - if (removedLower.isEmpty() || removedLower.any { authority.isOwner(it) }) return false + if (removedLower.any { authority.isOwner(it) }) return false // Removal is the hardest form of a ban, so it takes the same rank rule (CORD-04 §3): an admin // cannot Refound a peer admin out of the community any more than they could ban one. The owner // short-circuits, as everywhere else, because canActOn starts at hasPermission. @@ -1288,7 +1391,10 @@ class AccountConcordActions( // 1. Ban the removed members on the current Control Plane so the compacted snapshot — // and thus the new epoch — carries the ban. publishConcordWrap folds it in locally // first, so each subsequent edition chains onto the updated banlist head. + // A target the fold already bans (a ban that is composing its Refounding) needs no second edition. + val alreadyBanned = authority.bannedMembers().mapTo(HashSet()) { it.lowercase() } for (target in removedLower) { + if (target in alreadyBanned) continue val banWrap = ConcordModeration.ban(account.signer, cp, communityId.hexToByteArray(), target, session.controlEditions(), TimeUtils.now(), owner = session.entry.owner) publishConcordWrap(session.entry, banWrap) } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/ConcordRevokeResult.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/ConcordRevokeResult.kt new file mode 100644 index 0000000000..64311ef215 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/ConcordRevokeResult.kt @@ -0,0 +1,48 @@ +/* + * 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 + +/** + * The outcome of retiring an invite link (CORD-05 §2). Retiring the **last** live link flips the + * community Private, which is a Refounding (CORD-05 §5, CORD-06 §3); the two `PRIVATIZED` outcomes + * say whether that Refounding happened. + */ +enum class ConcordRevokeResult { + /** The link could not be retired (nothing changed on the wire). */ + FAILED, + + /** The link is retired; other live links keep the community Public (or it was already Private). */ + REVOKED, + + /** The last live link is retired and the community was Refounded, so it is Private now. */ + PRIVATIZED, + + /** + * The last live link is retired, so the community reads Private, but the Refounding did not run: + * this account cannot Refound (it takes BAN) or the rotation failed. Someone holding BAN must + * rotate the keys, or whoever already fetched a link keeps the current root. + */ + PRIVATIZED_REFOUND_PENDING, + + ; + + val revoked: Boolean get() = this != FAILED +} diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordInviteRegistryPublishTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordInviteRegistryPublishTest.kt new file mode 100644 index 0000000000..2ae9fbd741 --- /dev/null +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordInviteRegistryPublishTest.kt @@ -0,0 +1,121 @@ +/* + * 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.ConcordCommunityFactory +import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityState +import com.vitorpamplona.quartz.concord.cord04Roles.ConcordPermissions +import com.vitorpamplona.quartz.concord.cord04Roles.ControlEdition +import com.vitorpamplona.quartz.concord.cord04Roles.ControlEntityKind +import com.vitorpamplona.quartz.concord.cord04Roles.RoleEntity +import com.vitorpamplona.quartz.concord.cord05Invites.ConcordInviteRegistry +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.assertTrue + +/** + * CORD-05 §5 end to end through the writer: a creator's Invite Registry edition, published over the + * Control Plane, opened and folded like any other edition, drives the Public/Private mode, and that + * mode decides whether a ban Refounds (CORD-06 §3) and whether a retire privatizes (CORD-05 §2). + */ +class ConcordInviteRegistryPublishTest { + private val owner = NostrSignerInternal(KeyPair()) + private val inviter = NostrSignerInternal(KeyPair()) + private val troll = NostrSignerInternal(KeyPair()) + private val link1 = "c1".repeat(32) + private val link2 = "c2".repeat(32) + + @Test + fun registriesPublishFoldAndDriveThePublicPrivateMode() = + runTest { + val community = ConcordCommunityFactory.create(owner, "Nostrichs", createdAt = 1L, relays = listOf("wss://r.example")) + val cp = community.controlPlane + val cid = community.communityId + val editions = ConcordActions.controlEditions(community.genesisWraps, cp).toMutableList() + + fun add(wrap: Event) { + editions += ConcordActions.controlEditions(listOf(wrap), cp) + } + + fun fold(): ConcordCommunityState = ConcordCommunityState.fold(editions, cid, community.ownerPubKey) + + // A fresh community has no live link: Private, and a ban would Refound. + assertFalse(fold().isPublic) + assertTrue(fold().banRequiresRefounding(listOf(troll.pubKey))) + + // The owner mints: the registry lists the link signer and the community reads Public. + add(ConcordModeration.setInviteRegistry(owner, cp, cid, listOf(link1), editions, createdAt = 2L, owner = community.ownerPubKey)) + val ownerHead = editions.last() + assertEquals(ControlEntityKind.INVITE_REGISTRY, ownerHead.entityKind) + assertEquals(ConcordInviteRegistry.coordinateHex(cid, owner.pubKey), ownerHead.entityIdHex) + assertEquals("""["$link1"]""", ownerHead.content) + assertTrue(fold().isPublic) + assertFalse(fold().banRequiresRefounding(listOf(troll.pubKey)), "a Public ban is the Banlist alone") + + // A CREATE_INVITE holder's registry is honored beside the owner's; a troll's is not. + val roleId = ByteArray(32) { 5 } + add( + ConcordModeration.defineRole( + owner, + cp, + cid, + roleId, + RoleEntity(name = "Inviter", position = 2, permissions = ConcordPermissions.of(ConcordPermissions.CREATE_INVITE).toWire()), + editions, + createdAt = 3L, + owner = community.ownerPubKey, + ), + ) + add(ConcordModeration.grant(owner, cp, cid, inviter.pubKey, listOf(roleId.toHexKey()), editions, createdAt = 4L, owner = community.ownerPubKey)) + add(ConcordModeration.setInviteRegistry(inviter, cp, cid, listOf(link2), editions, createdAt = 5L, owner = community.ownerPubKey)) + add(ConcordModeration.setInviteRegistry(troll, cp, cid, listOf("dd".repeat(32)), editions, createdAt = 5L, owner = community.ownerPubKey)) + val both = fold() + assertEquals(setOf(link1, link2), both.liveInviteLinks) + assertEquals(listOf(link2), both.registryOf(inviter.pubKey)) + assertEquals(emptyList(), both.registryOf(troll.pubKey)) + + // The inviter's edition carried the `vac` citation that made it count. + val inviterEdition: ControlEdition = assertNotNull(editions.lastOrNull { it.author == inviter.pubKey && it.entityKind == ControlEntityKind.INVITE_REGISTRY }) + assertNotNull(inviterEdition.authorityCitation) + + // Retiring the owner's link leaves the inviter's: still Public, nothing privatizes. + assertFalse(both.retiringWouldPrivatize(listOf(link1))) + add(ConcordModeration.setInviteRegistry(owner, cp, cid, emptyList(), editions, createdAt = 6L, owner = community.ownerPubKey)) + val ownerRetired = fold() + assertEquals(2L, editions.last().version, "the retire chains onto the owner's own registry head") + assertEquals(setOf(link2), ownerRetired.liveInviteLinks) + + // Now the inviter's is the last live link: retiring it privatizes (a Refounding, CORD-05 §2). + assertTrue(ownerRetired.retiringWouldPrivatize(listOf(link2))) + // Banning the inviter would take their registry with them: that ban Refounds. + assertTrue(ownerRetired.banRequiresRefounding(listOf(inviter.pubKey))) + + add(ConcordModeration.setInviteRegistry(inviter, cp, cid, emptyList(), editions, createdAt = 7L, owner = community.ownerPubKey)) + assertFalse(fold().isPublic) + } +} diff --git a/commonsUI/src/commonMain/composeResources/values/strings.xml b/commonsUI/src/commonMain/composeResources/values/strings.xml index 1003a3ee26..2d9ba1ad10 100644 --- a/commonsUI/src/commonMain/composeResources/values/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values/strings.xml @@ -590,6 +590,14 @@ Revoke this link? Anyone still holding this link will no longer be able to join. People who already joined with it stay in the community. This can't be undone. Revoke + This is the community's last live invite link. Revoking it makes the community Private and rotates its keys, so only current members keep access. + Link revoked. The community is now Private and its keys were rotated. + Link revoked. The community is now Private, but its keys were not rotated: an admin who can ban members needs to rotate them. + Private + + Public · %1$d live invite link + Public · %1$d live invite links + Community name is only revealed after you join Joining connects to this invite's relays, publishes a join announcement signed by your account, and adds the community to your list. Nothing is sent until you tap Join. Relays this invite will contact: %1$s diff --git a/quartz/plans/2026-09-29-concord-spec-conformance.md b/quartz/plans/2026-09-29-concord-spec-conformance.md index b4a9ec4eb9..e2183616b3 100644 --- a/quartz/plans/2026-09-29-concord-spec-conformance.md +++ b/quartz/plans/2026-09-29-concord-spec-conformance.md @@ -86,7 +86,7 @@ Ranked security > interop > feature inside each group. | F2 | 08 | Disappearing Messages (sender tags, reader refusal/hiding/purge, 1740 notice, settings UI) | metadata field + parse **fixed**; the rest open → chat-plane batch | | 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) | -| F5 | 05 §5 | Invite Registry (vsk 8) not published or folded | open | +| 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 | open | | F7 | 06 §1-2 | Channel-scope rekeys; private-channel keys in invites | open (depends on S2) | | 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 | diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord02Community/ConcordCommunityState.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord02Community/ConcordCommunityState.kt index 34771f3f23..5222e39d52 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord02Community/ConcordCommunityState.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord02Community/ConcordCommunityState.kt @@ -31,6 +31,8 @@ import com.vitorpamplona.quartz.concord.cord04Roles.EntityFloor import com.vitorpamplona.quartz.concord.cord04Roles.MetadataEntity import com.vitorpamplona.quartz.concord.cord04Roles.RoleEntity import com.vitorpamplona.quartz.concord.cord04Roles.asFloor +import com.vitorpamplona.quartz.concord.cord05Invites.ConcordInviteRegistry +import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.toHexKey /** A channel id paired with its current folded definition. */ @@ -57,7 +59,68 @@ data class ConcordCommunityState( val roles: Map, val authority: AuthorityResolver, val dissolved: Boolean, + /** + * Each creator's honored Invite Registry (CORD-05 §5, `vsk 8`): creator pubkey → the link-signer + * pubkeys (lowercase) of their live public links. A creator is present only while their registry + * head is honored — well-formed at their own coordinate, authored while holding `CREATE_INVITE` + * (or by the owner), citing their Grant — so a creator who loses the bit drops out. + */ + val inviteRegistries: Map> = emptyMap(), ) { + /** The aggregate active-set of live public links: every honored registry's link signers (CORD-05 §5). */ + val liveInviteLinks: Set by lazy { inviteRegistries.values.flatMapTo(HashSet()) { it } } + + /** + * The community's Public/Private mode (CORD-05 §5): Public while any live link exists in the + * aggregate registry set, Private otherwise. A Public ban is the Banlist alone; only a Private + * ban Refounds (CORD-06 §3). + */ + val isPublic: Boolean get() = liveInviteLinks.isNotEmpty() + + /** + * [isPublic] with [excludingCreators]' registries left out — the mode a ban of those members + * lands in, since a banned creator's registry stops being honored (Armada `isCommunityPublic`). + */ + fun isPublic(excludingCreators: Collection): Boolean { + if (excludingCreators.isEmpty()) return isPublic + val excluded = excludingCreators.mapTo(HashSet()) { it.lowercase() } + return inviteRegistries.any { (creator, links) -> creator !in excluded && links.isNotEmpty() } + } + + /** + * Whether any live link belongs to someone other than [viewer] (and [excludingCreators]) — + * links a rotation by [viewer] would strand, since only a link's creator can refresh its bundle + * (Armada `hasForeignLiveLinks`). + */ + fun hasForeignLiveLinks( + viewer: HexKey, + excludingCreators: Collection = emptyList(), + ): Boolean { + val excluded = excludingCreators.mapTo(HashSet()) { it.lowercase() } + viewer.lowercase() + return inviteRegistries.any { (creator, links) -> creator !in excluded && links.isNotEmpty() } + } + + /** + * Whether banning [targets] must Refound (CORD-06 §3): only a ban from a **Private** community + * does; a Public ban is the Banlist alone, because anyone holding a live link can fetch the + * rotated root straight back out of its bundle. Judged with the targets' own registries left + * out, since the ban stops honoring them. + */ + fun banRequiresRefounding(targets: Collection): Boolean = !isPublic(targets) + + /** [creator]'s honored registry (their live link signers), empty when they publish none. */ + fun registryOf(creator: HexKey): List = inviteRegistries[creator.lowercase()] ?: emptyList() + + /** + * 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). + */ + fun retiringWouldPrivatize(linkSigners: Collection): Boolean { + if (!isPublic) return false + val retiring = linkSigners.mapTo(HashSet()) { it.lowercase() } + return liveInviteLinks.all { it in retiring } + } + /** * This state with [dissolved] set from the community's dissolution plane * ([ConcordDissolution.isDissolved]). One-way by the caller's contract: there is no un-dissolve. @@ -250,6 +313,16 @@ data class ConcordCommunityState( // the dissolved plane sets [dissolved] via [withDissolved]. val dissolved = false + // Invite Registries (CORD-05 §5): one entity per creator at invite_links_locator(community_id, + // creator), honored while its author holds CREATE_INVITE. The gate (AuthorityResolver.admits) + // pins the coordinate to the author and requires a JSON array, so a registry at someone + // else's coordinate or a malformed one never lands; entries are kept only when they are 64-hex. + val inviteRegistries = HashMap>() + for (head in foldGatedBy(ControlEntityKind.INVITE_REGISTRY, ConcordPermissions.CREATE_INVITE).values) { + val links = ConcordInviteRegistry.decodeOrNull(head.content) ?: continue + inviteRegistries[head.author.lowercase()] = links + } + return ConcordCommunityState( ownerPubKey = ownerPubKey.lowercase(), metadata = metadata, @@ -257,6 +330,7 @@ data class ConcordCommunityState( roles = roles, authority = authority, dissolved = dissolved, + inviteRegistries = inviteRegistries, ) } } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/AuthorityResolver.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/AuthorityResolver.kt index ab7a521f61..b2f43b77e6 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/AuthorityResolver.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/AuthorityResolver.kt @@ -20,6 +20,7 @@ */ package com.vitorpamplona.quartz.concord.cord04Roles +import com.vitorpamplona.quartz.concord.cord05Invites.ConcordInviteRegistry import com.vitorpamplona.quartz.concord.crypto.ConcordKeyDerivation import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray import com.vitorpamplona.quartz.nip01Core.core.hexToByteArrayOrNull @@ -290,8 +291,11 @@ data class AuthorityResolver private constructor( ControlEntityKind.ROLE -> ConcordJson.decodeOrNull(edition.content)?.isWellFormedAt(edition.entityIdHex) == true ControlEntityKind.GRANT -> grantAt(edition, communityId) != null ControlEntityKind.BANLIST -> edition.entityIdHex == banlistEidHex && ConcordJson.decodeBanlist(edition.content) != null + // 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 == ConcordKeyDerivation.inviteLinksCoordinate(communityId, edition.author.hexToByteArray()).toHexKey() && + ConcordInviteRegistry.isWellFormed(edition.content) else -> true } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordInviteRegistry.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordInviteRegistry.kt new file mode 100644 index 0000000000..9d4cd3612c --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordInviteRegistry.kt @@ -0,0 +1,174 @@ +/* + * 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.cord05Invites + +import com.vitorpamplona.quartz.concord.cord04Roles.AuthorityCitation +import com.vitorpamplona.quartz.concord.cord04Roles.ControlEditionBuilder +import com.vitorpamplona.quartz.concord.cord04Roles.ControlEntityKind +import com.vitorpamplona.quartz.concord.crypto.ConcordKeyDerivation +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 kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonPrimitive + +/** + * The Invite Registry (CORD-05 §5, `vsk 8`): a creator's member-facing list of their live link + * coordinates, published as a Control Plane edition at `invite_links_locator(community_id, creator)` + * (CORD-02 A.6), so each creator owns exactly their own list and nobody can forge entries into + * anyone else's. + * + * Its content is a bare JSON array of **link-signer pubkeys** (the authors of the kind-33301 + * bundles, whose `d` is empty, §2) — locators only, never tokens, URLs or signing secrets: + * ```jsonc + * ["", ""] + * ``` + * + * Members fold every creator's registry (honored only while its author holds `CREATE_INVITE`) + * into one aggregate active-set, and that set is the community's **Public/Private source of + * truth**: non-empty means Public, empty means Private (see + * [com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityState.isPublic]). + */ +object ConcordInviteRegistry { + private val LINK_SIGNER = Regex("^[0-9a-fA-F]{64}$") + + /** Strict JSON: the reference client reads the content with `JSON.parse`, which refuses what a lenient parser would accept. */ + private val strict = Json + + /** The registry coordinate (entity id) of [creator] in [communityId]. */ + fun coordinate( + communityId: ByteArray, + creator: HexKey, + ): ByteArray = ConcordKeyDerivation.inviteLinksCoordinate(communityId, creator.hexToByteArray()) + + /** [coordinate] as hex. */ + fun coordinateHex( + communityId: ByteArray, + creator: HexKey, + ): HexKey = coordinate(communityId, creator).toHexKey() + + /** Whether [value] is a link-signer entry a reader keeps: a 64-hex x-only pubkey. */ + fun isLinkSigner(value: String): Boolean = LINK_SIGNER.matches(value) + + /** + * The registry content for [linkSigners]: lowercase, de-duplicated and sorted, so two devices of + * one creator holding the same set write the same bytes. Anything that is not a link-signer + * pubkey is dropped rather than published, since every reader would drop it anyway. + */ + fun encode(linkSigners: Collection): String { + val clean = + linkSigners + .filter(::isLinkSigner) + .map { it.lowercase() } + .distinct() + .sorted() + return strict.encodeToString(JsonArray.serializer(), JsonArray(clean.map { JsonPrimitive(it) })) + } + + /** + * Whether [content] is a well-formed registry: a JSON array, whatever it holds (Armada's + * `Array.isArray(JSON.parse(content))`). A malformed edition is not honored, so the entity falls + * back to the creator's previous authorized edition. + */ + fun isWellFormed(content: String): Boolean = parseArrayOrNull(content) != null + + /** + * The link signers [content] lists, lowercase and de-duplicated, keeping only string entries + * that are 64-hex pubkeys; null when [content] is not a JSON array at all. + */ + fun decodeOrNull(content: String): List? { + val array = parseArrayOrNull(content) ?: return null + return array + .mapNotNull { element -> (element as? JsonPrimitive)?.takeIf { it.isString }?.content } + .filter(::isLinkSigner) + .map { it.lowercase() } + .distinct() + } + + private fun parseArrayOrNull(content: String): JsonArray? = + try { + strict.parseToJsonElement(content) as? JsonArray + } catch (_: Exception) { + null + } + + /** + * 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. + * + * 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. + */ + fun nextLinks( + published: Collection, + list: ConcordInviteListDocument?, + communityIdHex: HexKey, + nowSecs: Long, + minted: Collection = emptyList(), + retired: Collection = emptyList(), + ): List { + val dead = retired.mapTo(HashSet()) { it.lowercase() } + val live = LinkedHashSet() + published.forEach { live += it.lowercase() } + if (list != null) { + val tombstoned = list.tombstones.mapTo(HashSet()) { it.token } + for (entry in list.entries) { + if (!entry.communityId.equals(communityIdHex, ignoreCase = true)) continue + val signer = runCatching { entry.signerPubKeyHex().lowercase() }.getOrNull() ?: continue + if (entry.token in tombstoned || entry.isExpired(nowSecs)) dead += signer else live += signer + } + } + minted.forEach { live += it.lowercase() } + return live.filter { it !in dead && isLinkSigner(it) }.sorted() + } + + /** + * An unsigned registry edition rumor for [creator] listing [linkSigners] (CORD-05 §5). Chain it + * onto the creator's current authorized head ([version] = head + 1, [prevHash] = its hash; a + * first registry is version 1 with no prev) and cite the Grant the creator acts under + * ([authorityCitation], null for the owner) like any authority edition (CORD-04 §5, `vac`). + */ + fun rumor( + creator: HexKey, + communityId: ByteArray, + linkSigners: Collection, + version: Long, + prevHash: ByteArray?, + createdAt: Long, + authorityCitation: AuthorityCitation? = null, + ): Event = + ControlEditionBuilder.rumor( + authorPubKey = creator, + entityKind = ControlEntityKind.INVITE_REGISTRY, + entityId = coordinate(communityId, creator), + version = version, + prevHash = prevHash, + content = encode(linkSigners), + createdAt = createdAt, + authorityCitation = authorityCitation, + ) +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordInviteRegistryTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordInviteRegistryTest.kt new file mode 100644 index 0000000000..522c1ca0fc --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordInviteRegistryTest.kt @@ -0,0 +1,273 @@ +/* + * 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.cord05Invites + +import com.vitorpamplona.quartz.concord.cord04Roles.ConcordPermissions +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.concord.crypto.ConcordKeyDerivation +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 + +/** CORD-05 §5: the Invite Registry (`vsk 8`) and the Public/Private mode its aggregate defines. */ +class ConcordInviteRegistryTest { + private val owner = "0f".repeat(32) + private val alice = "a1".repeat(32) + private val bob = "b2".repeat(32) + private val troll = "77".repeat(32) + private val inviterRole = "11".repeat(32) + private val link1 = "c1".repeat(32) + private val link2 = "c2".repeat(32) + private val link3 = "c3".repeat(32) + + private val cid = ControlFixtures.communityId + + private fun registryEid(creator: String) = ConcordInviteRegistry.coordinateHex(cid, creator) + + private fun edition( + kind: ControlEntityKind, + eid: String, + content: String, + author: String = owner, + version: Long = 0, + prev: ControlEdition? = null, + ) = ControlEdition(kind, eid.hexToByteArray(), version, prev?.hash, null, content, author, "r-$eid-$version-$author", version) + + private fun registry( + creator: String, + content: String, + version: Long = 0, + prev: ControlEdition? = null, + at: String = registryEid(creator), + ) = edition(ControlEntityKind.INVITE_REGISTRY, at, content, creator, version, prev) + + private fun list(vararg signers: String) = ConcordInviteRegistry.encode(signers.toList()) + + private val inviterRoleEdition = + edition(ControlEntityKind.ROLE, inviterRole, """{"role_id":"$inviterRole","name":"Inviter","position":2,"permissions":"${ConcordPermissions.of(ConcordPermissions.CREATE_INVITE).toWire()}"}""") + + private fun grant( + member: String, + vararg roles: String, + version: Long = 0, + prev: ControlEdition? = null, + ) = edition( + ControlEntityKind.GRANT, + ControlFixtures.grantEid(member), + """{"member":"$member","role_ids":[${roles.joinToString(",") { "\"$it\"" }}]}""", + version = version, + prev = prev, + ) + + @Test + fun theCoordinateIsTheSpecDerivation() { + assertEquals( + ConcordKeyDerivation.inviteLinksCoordinate(cid, alice.hexToByteArray()).toHexKey(), + registryEid(alice), + ) + } + + @Test + fun theBuilderWritesAnEditionAtTheCreatorsCoordinateWithLocatorsOnly() { + val rumor = ConcordInviteRegistry.rumor(alice, cid, listOf(link2, link1.uppercase(), link1, "not-a-key"), version = 3, prevHash = ByteArray(32) { 9 }, createdAt = 1_700_000_000) + val parsed = assertNotNull(ControlEdition.fromRumor(rumor)) + assertEquals(ControlEntityKind.INVITE_REGISTRY, parsed.entityKind) + assertEquals("8", parsed.vsk) + assertEquals(registryEid(alice), parsed.entityIdHex) + assertEquals(3, parsed.version) + assertEquals(alice, parsed.author) + // Lowercase, de-duplicated, sorted, junk dropped: a bare array of link-signer pubkeys. + assertEquals("""["$link1","$link2"]""", parsed.content) + } + + @Test + fun theOwnersRegistryMakesTheCommunityPublic() { + val state = ControlFixtures.fold(listOf(registry(owner, list(link1, link2))), owner) + assertEquals(mapOf(owner to listOf(link1, link2)), state.inviteRegistries) + assertEquals(setOf(link1, link2), state.liveInviteLinks) + assertTrue(state.isPublic) + } + + @Test + fun noRegistryOrAnEmptyOneIsPrivate() { + assertFalse(ControlFixtures.fold(emptyList(), owner).isPublic) + val emptied = ControlFixtures.fold(listOf(registry(owner, "[]")), owner) + assertFalse(emptied.isPublic) + assertEquals(emptyList(), emptied.registryOf(owner)) + } + + @Test + fun aCreateInviteHolderIsHonoredAndAnUnauthorizedAuthorIsNot() { + val editions = + listOf( + inviterRoleEdition, + grant(alice, inviterRole), + registry(alice, list(link1)), + registry(troll, list(link2)), + ) + val state = ControlFixtures.fold(editions, owner) + assertEquals(listOf(link1), state.registryOf(alice)) + assertEquals(emptyList(), state.registryOf(troll), "no CREATE_INVITE, no registry") + assertEquals(setOf(link1), state.liveInviteLinks) + } + + @Test + fun aRegistryAtAnotherCreatorsCoordinateIsIgnored() { + // Alice holds CREATE_INVITE but writes into Bob's coordinate: the coordinate binds to the author. + val editions = + listOf( + inviterRoleEdition, + grant(alice, inviterRole), + registry(alice, list(link1), at = registryEid(bob)), + ) + val state = ControlFixtures.fold(editions, owner) + assertTrue(state.inviteRegistries.isEmpty()) + assertFalse(state.isPublic) + } + + @Test + fun malformedContentFallsBackToThePreviousEditionAndJunkEntriesAreDropped() { + val v0 = registry(owner, list(link1)) + val broken = registry(owner, """{"links":["$link2"]}""", version = 1, prev = v0) + assertEquals(listOf(link1), ControlFixtures.fold(listOf(v0, broken), owner).registryOf(owner), "not an array: the head stays at v0") + + val notJson = registry(owner, "[$link2", version = 1, prev = v0) + assertEquals(listOf(link1), ControlFixtures.fold(listOf(v0, notJson), owner).registryOf(owner)) + + // An array is well-formed whatever it holds; only 64-hex string entries survive, lowercased. + val mixed = registry(owner, """["$link2", 42, null, "abc", "${link3.uppercase()}", "$link2", ["$link1"]]""", version = 1, prev = v0) + assertEquals(listOf(link2, link3), ControlFixtures.fold(listOf(v0, mixed), owner).registryOf(owner)) + + assertNull(ConcordInviteRegistry.decodeOrNull("nope")) + assertNull(ConcordInviteRegistry.decodeOrNull("""{"a":1}""")) + } + + @Test + fun theAggregateSpansCreatorsAndDrivesThePublicHelpers() { + val editions = + listOf( + inviterRoleEdition, + grant(alice, inviterRole), + grant(bob, inviterRole), + registry(owner, list(link1)), + registry(alice, list(link2)), + registry(bob, "[]"), + ) + val state = ControlFixtures.fold(editions, owner) + assertEquals(setOf(link1, link2), state.liveInviteLinks) + assertTrue(state.isPublic) + + // Banning alice leaves the owner's link: still Public. Banning her with the owner gone would not. + assertTrue(state.isPublic(listOf(alice))) + assertFalse(state.isPublic(listOf(alice, owner))) + + // Foreign links are anyone's but the viewer's (and the excluded). + assertTrue(state.hasForeignLiveLinks(owner)) + assertFalse(state.hasForeignLiveLinks(owner, listOf(alice))) + assertFalse(state.hasForeignLiveLinks(bob, listOf(alice, owner))) + + // Retiring one of two live links keeps it Public; retiring both flips it Private. + assertFalse(state.retiringWouldPrivatize(listOf(link1))) + assertTrue(state.retiringWouldPrivatize(listOf(link1, link2))) + assertFalse(ControlFixtures.fold(emptyList(), owner).retiringWouldPrivatize(listOf(link1)), "already Private: nothing flips") + } + + @Test + fun aCreatorWhoLosesCreateInviteDropsOut() { + val g0 = grant(alice, inviterRole) + val before = listOf(inviterRoleEdition, g0, registry(alice, list(link1))) + assertTrue(ControlFixtures.fold(before, owner).isPublic) + + // The owner strips alice's roles: her registry is no longer honored, the link no longer counts. + val g1 = grant(alice, version = 1, prev = g0) + val after = ControlFixtures.fold(before + g1, owner) + assertEquals(emptyList(), after.registryOf(alice)) + assertFalse(after.isPublic) + } + + @Test + fun aBannedCreatorDropsOut() { + val editions = + listOf( + inviterRoleEdition, + grant(alice, inviterRole), + registry(alice, list(link1)), + edition(ControlEntityKind.BANLIST, ControlFixtures.banlistEid(), """["$alice"]"""), + ) + assertFalse(ControlFixtures.fold(editions, owner).isPublic) + } + + @Test + fun aBanRefoundsOnlyWhenTheCommunityIsPrivateWithoutTheTargetsLinks() { + val editions = listOf(inviterRoleEdition, grant(alice, inviterRole), registry(alice, list(link1))) + val state = ControlFixtures.fold(editions, owner) + assertFalse(state.banRequiresRefounding(listOf(bob)), "Public: the Banlist alone") + assertTrue(state.banRequiresRefounding(listOf(alice)), "banning the only link creator leaves it Private") + assertTrue(ControlFixtures.fold(emptyList(), owner).banRequiresRefounding(listOf(bob)), "Private: a ban Refounds") + } + + private fun entry( + token: String, + signer: KeyPair, + community: String = ControlFixtures.COMMUNITY_ID_HEX, + expiresAt: Long? = null, + ) = ConcordInviteListEntry(token = token, signerSk = signer.privKey!!.toHexKey(), communityId = community, url = "u", createdAt = 1, expiresAt = expiresAt) + + @Test + fun theNextRegistryAddsMintsDropsRetiredAndPrunesExpiredOrTombstonedLinks() { + val live = KeyPair() + val expired = KeyPair() + val tombstoned = KeyPair() + val otherCommunity = KeyPair() + val doc = + ConcordInviteListDocument( + entries = + listOf( + entry("01", live), + entry("02", expired, expiresAt = 100), + entry("03", tombstoned), + entry("04", otherCommunity, community = "ee".repeat(32)), + ), + tombstones = listOf(ConcordInviteListTombstone("03", ControlFixtures.COMMUNITY_ID_HEX)), + ) + 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. + val next = ConcordInviteRegistry.nextLinks(listOf(expiredPk, link1), doc, ControlFixtures.COMMUNITY_ID_HEX, nowSecs = 200, minted = listOf(link2)) + assertEquals(listOf(link1, 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))) + + // 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)) + } +} From 0cfcec6d5690fd0ce15e6c5af83d9a3b19d013ba Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 29 Sep 2026 18:30:25 +0000 Subject: [PATCH 7/7] =?UTF-8?q?feat(concord):=20Direct=20Invite=20UI=20and?= =?UTF-8?q?=20amy=20verbs=20(CORD-05=20=C2=A76)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - commons: ConcordActions.draftDirectInvite holds the send-side refusals (dissolved, banned sender, banned or invalid recipient) so the app and amy share them. - commonsUI: "Invite by npub" dialog (user typeahead) and a pending Direct Invites card (bundle name + robohash preview, no icon or profile fetch, Accept / Decline), with new strings in commonsUI resources. - amethyst: the dialog behind the community overflow menu; the invites card on the Concord hub, including its empty state. - amy: `concord invite COMMUNITY --to USER [--expires-in SECS]`, `concord invites`, `concord accept WRAP-ID`, `concord decline WRAP-ID`; the link join is factored into joinBundle and shared with accept. - Conformance review: F6 fixed. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01PmuzkD5qdqgv6dMeRMoC5N --- .../concord/ConcordChannelListScreen.kt | 17 + .../concord/ConcordHomeScreen.kt | 23 +- cli/README.md | 5 + .../com/vitorpamplona/amethyst/cli/Config.kt | 1 + .../com/vitorpamplona/amethyst/cli/Main.kt | 3 + .../cli/commands/ConcordChannelCommands.kt | 2 +- .../amethyst/cli/commands/ConcordCommands.kt | 327 +++++++++++++++--- .../cli/stores/ConcordInviteInboxStore.kt | 55 +++ .../commons/actions/ConcordActions.kt | 33 ++ .../commons/model/AccountConcordActions.kt | 19 +- .../model/ConcordDirectInviteSendResult.kt | 13 + .../actions/ConcordDirectInviteActionsTest.kt | 26 ++ .../composeResources/values/strings.xml | 17 + .../concord/ConcordDirectInvites.kt | 267 ++++++++++++++ .../2026-09-29-concord-spec-conformance.md | 2 +- 15 files changed, 736 insertions(+), 74 deletions(-) create mode 100644 cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/stores/ConcordInviteInboxStore.kt create mode 100644 commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/publicChannels/concord/ConcordDirectInvites.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordChannelListScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordChannelListScreen.kt index 9f67f7629f..9c2a42b63e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordChannelListScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordChannelListScreen.kt @@ -87,6 +87,7 @@ import com.vitorpamplona.amethyst.commons.resources.concord_channel_no_messages 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_channels_empty +import com.vitorpamplona.amethyst.commons.resources.concord_direct_invite_action import com.vitorpamplona.amethyst.commons.resources.concord_edit_title import com.vitorpamplona.amethyst.commons.resources.concord_invite_action import com.vitorpamplona.amethyst.commons.resources.concord_invite_links_action @@ -108,6 +109,7 @@ import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav import com.vitorpamplona.amethyst.commons.ui.navigation.topbars.ShorterTopAppBar import com.vitorpamplona.amethyst.commons.ui.note.timeAgo import com.vitorpamplona.amethyst.commons.ui.platform.AppBottomBar +import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.publicChannels.concord.ConcordDirectInviteDialog import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.publicChannels.concord.datasource.ConcordChannelPreviewLoader import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.publicChannels.concord.datasource.ConcordChannelSubscription import com.vitorpamplona.amethyst.commons.ui.stringRes @@ -179,6 +181,11 @@ fun ConcordChannelListScreen( // Read once here (it is @Composable) so the post-leave navigation can use it from a callback. val canPop = nav.canPop() var showLeave by remember { mutableStateOf(false) } + var showDirectInvite by remember { mutableStateOf(false) } + + if (showDirectInvite) { + ConcordDirectInviteDialog(communityId, accountViewModel, onDismiss = { showDirectInvite = false }) + } if (showLeave) { ConcordLeaveDialog( @@ -327,6 +334,16 @@ fun ConcordChannelListScreen( SymbolIcon(symbol = MaterialSymbols.MoreVert, contentDescription = stringRes(Res.string.more_options)) } DropdownMenu(expanded = menuOpen, onDismissRequest = { menuOpen = false }) { + // A Direct Invite (CORD-05 §6) hands keys to one known npub. No permission gates + // it — none could, any keyholder can whisper keys — so neither does this item; + // what it carries is bounded by the recipient's roles instead. + DropdownMenuItem( + text = { Text(stringRes(Res.string.concord_direct_invite_action)) }, + onClick = { + menuOpen = false + showDirectInvite = true + }, + ) // Deliberately not gated on CREATE_INVITE, unlike minting: the links listed // there are this account's own, authored by link-signer keys only we hold. // Gating on the bit would mean a demoted admin could no longer retire the diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordHomeScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordHomeScreen.kt index 62045385f4..d7ba2251db 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordHomeScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordHomeScreen.kt @@ -79,6 +79,7 @@ import com.vitorpamplona.amethyst.commons.ui.note.timeAgo import com.vitorpamplona.amethyst.commons.ui.platform.AppBottomBar 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.datasource.ConcordChannelSubscription import com.vitorpamplona.amethyst.commons.ui.stringRes import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel @@ -158,13 +159,18 @@ fun ConcordHomeScreen( }, ) { padding -> if (communities.isEmpty()) { - Box(Modifier.fillMaxSize().padding(padding), contentAlignment = Alignment.Center) { - Text( - stringRes(Res.string.concord_home_empty), - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(horizontal = 32.dp), - ) + // 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) { + Text( + stringRes(Res.string.concord_home_empty), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(horizontal = 32.dp), + ) + } } return@Scaffold } @@ -187,6 +193,9 @@ 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) } + sorted.forEach { entry -> val state = account.concordSessions diff --git a/cli/README.md b/cli/README.md index 519e277554..cf8a7a3dc8 100644 --- a/cli/README.md +++ b/cli/README.md @@ -680,6 +680,10 @@ also carried on-relay as the encrypted, fragmented kind:33302 Community List | `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). | +| `amy concord invite COMMUNITY --to USER [--expires-in SECS]` | Send a Direct Invite (CORD-05 §6): the bundle giftwrapped as standard NIP-59 (kind-3313 rumor, `k=3313` wrap tag, NIP-40 expiration when `--expires-in` is set) to USER (npub, hex, nprofile or NIP-05) on their kind-10050 relays, else NIP-65 read relays, else the stock set. Carries only the private-channel keys USER's roles grant; refused for a banned recipient. No registry entry, never flips the community Public, cannot be revoked. | +| `amy concord invites` | List Direct Invites waiting for this account (sender, community name/icon, expired, catch-up). Read-only: nothing joins or contacts the community's relays. Communities you already hold are hidden unless the invite carries new channel keys on the same base (a catch-up). | +| `amy concord accept WRAP-ID` | Accept a Direct Invite (full wrap id or a unique prefix): the same join path as a link (ban-gated, Guestbook Join attributed to the seal-verified sender); refused past `expires_at`. For a community you hold, only adopts newly granted private-channel keys on the same root/epoch/control_pk, never moving the base. | +| `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, then records it in your Invite List. | | `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). | @@ -987,6 +991,7 @@ matches that: │ ├── aliases.json # local name → npub map │ ├── cashu.json # NIP-60 NUT-13 counters │ ├── concord.json # Concord community secrets +│ ├── concord-invites.json # declined Concord Direct Invite wrap ids │ └── marmot/ # MLS state per group └── bob/ └── … diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Config.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Config.kt index efc4c20f40..95dd68e291 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Config.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Config.kt @@ -224,6 +224,7 @@ class DataDir( val aliasesFile = File(root, "aliases.json") val cashuFile = File(root, "cashu.json") val concordFile = File(root, "concord.json") + val concordInvitesFile = File(root, "concord-invites.json") val marmotDir = File(root, "marmot") val groupsDir = File(marmotDir, "groups") val keyPackageBundleFile = File(marmotDir, "keypackages.bundle") diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt index 89f9929c45..87caabad5f 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt @@ -884,6 +884,9 @@ private fun printUsage() { | concord send COMMUNITY CHANNEL TEXT post a message (CHANNEL = general|name|id) | concord read COMMUNITY CHANNEL [--limit N] read a channel's messages | concord invite COMMUNITY [--base URL] mint + publish a shareable invite link + | concord invite COMMUNITY --to USER send a Direct Invite (giftwrapped bundle) + | concord invites list Direct Invites waiting for you + | concord accept|decline WRAP-ID join from / discard a Direct Invite | concord revoke COMMUNITY TOKEN|URL retire a link you minted (vsk=9 tombstone) | concord join URL redeem an invite link and save the community | diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ConcordChannelCommands.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ConcordChannelCommands.kt index 312c3b88d3..554e549582 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ConcordChannelCommands.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ConcordChannelCommands.kt @@ -172,7 +172,7 @@ object ConcordChannelCommands { } /** Drain the control plane and fold it into the current community state. */ - private suspend fun foldState( + suspend fun foldState( ctx: Context, sc: StoredCommunity, ): ConcordCommunityState { diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ConcordCommands.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ConcordCommands.kt index dd7a23c1f4..683bcb54d2 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ConcordCommands.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ConcordCommands.kt @@ -24,12 +24,18 @@ 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.ConcordInviteInboxStore import com.vitorpamplona.amethyst.cli.stores.ConcordStore import com.vitorpamplona.amethyst.cli.stores.StoredCommunity import com.vitorpamplona.amethyst.cli.stores.StoredHeldRoot import com.vitorpamplona.amethyst.cli.stores.StoredPrivateChannel import com.vitorpamplona.amethyst.commons.actions.ConcordActions import com.vitorpamplona.amethyst.commons.actions.ConcordReceive +import com.vitorpamplona.amethyst.commons.model.ConcordDirectInviteDraft +import com.vitorpamplona.amethyst.commons.model.ConcordDirectInviteSendResult +import com.vitorpamplona.amethyst.commons.model.concord.ConcordDirectInviteInbox +import com.vitorpamplona.amethyst.commons.model.concord.ConcordDirectInviteView +import com.vitorpamplona.amethyst.commons.model.concord.DirectInviteAcceptPlan import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityList import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEntry import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEvent @@ -41,6 +47,7 @@ import com.vitorpamplona.quartz.concord.cord02Community.PrivateChannelKey import com.vitorpamplona.quartz.concord.cord04Roles.AuthorityResolver import com.vitorpamplona.quartz.concord.cord04Roles.ConcordLimits import com.vitorpamplona.quartz.concord.cord04Roles.ControlEdition +import com.vitorpamplona.quartz.concord.cord05Invites.CommunityInvite import com.vitorpamplona.quartz.concord.cord05Invites.ConcordInviteList import com.vitorpamplona.quartz.concord.cord05Invites.ConcordInviteListDocument import com.vitorpamplona.quartz.concord.cord05Invites.ConcordInviteListEntry @@ -49,6 +56,7 @@ import com.vitorpamplona.quartz.concord.cord05Invites.ConcordInviteListTombstone import com.vitorpamplona.quartz.concord.cord05Invites.InviteBundleStatus import com.vitorpamplona.quartz.concord.cord06Rekey.ReceivedRefounding import com.vitorpamplona.quartz.concord.crypto.ControlPlaneKeys +import com.vitorpamplona.quartz.marmot.RecipientRelayFetcher import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray import com.vitorpamplona.quartz.nip01Core.core.toHexKey import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter @@ -75,6 +83,14 @@ object ConcordCommands { | 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 | concord invite COMMUNITY [--base URL] mint + publish a shareable invite link + | concord invite COMMUNITY --to USER send a Direct Invite (CORD-05 §6): the bundle + | [--expires-in SECS] giftwrapped to USER (npub|hex|nprofile|nip05), + | to their 10050 / NIP-65 read / stock relays, + | with only the private channels their roles grant + | concord invites list Direct Invites waiting for you (never joins) + | concord accept WRAP-ID accept a Direct Invite: join (or, for a community + | you hold, adopt newly granted channel keys) + | concord decline WRAP-ID discard a Direct Invite; it never resurfaces | concord revoke COMMUNITY TOKEN|URL retire a link you minted: publishes a vsk=9 | tombstone at its coordinate, then tombstones | it in your invite list so it stays retired @@ -105,7 +121,7 @@ object ConcordCommands { route( "concord", tail, - "concord ", + "concord ", help = USAGE, routes = mapOf( @@ -116,6 +132,9 @@ object ConcordCommands { "send" to { rest -> ConcordChannelCommands.send(dataDir, rest) }, "read" to { rest -> ConcordChannelCommands.read(dataDir, rest) }, "invite" to { rest -> invite(dataDir, rest) }, + "invites" to { rest -> invites(dataDir, rest) }, + "accept" to { rest -> accept(dataDir, rest) }, + "decline" to { rest -> decline(dataDir, rest) }, "revoke" to { rest -> revoke(dataDir, rest) }, "join" to { rest -> join(dataDir, rest) }, "recover" to { rest -> recover(dataDir, rest) }, @@ -282,9 +301,13 @@ object ConcordCommands { val args = Args(rest) val handle = args.positional(0, "community") val base = args.flag("base", "https://vector.chat")!! + val to = args.flag("to") + val expiresInSecs = args.flag("expires-in")?.let { it.toLongOrNull()?.takeIf { secs -> secs > 0 } ?: throw IllegalArgumentException("--expires-in expects a positive number of seconds, got '$it'") } args.rejectUnknown() val sc = ConcordStore(dataDir.concordFile).find(handle) ?: return notFound(handle) + if (to != null) return directInvite(dataDir, sc, to, expiresInSecs) + if (expiresInSecs != null) return Output.error("bad_args", "--expires-in applies to a Direct Invite (--to)").let { 2 } Context.open(dataDir).use { ctx -> ctx.prepare() // The joiner cannot derive the Control Plane address, so the invite carries it @@ -447,62 +470,264 @@ object ConcordCommands { InviteBundleStatus.Absent -> return Output.error("not_found", "no bundle for this link on any of its relays") } - // Refuse a link that readmits us after we were removed. A Refounding re-mints every - // outstanding link onto the new root (CORD-05), and an ex-member keeps the URL and its - // unlock token forever — so without this check the rotation that was supposed to expel - // them hands them the new keys instead. `recover` has always been ban-gated; `join` is - // the other door into the same room. - // - // Fails CLOSED on an unreadable plane: no verdict, no join. The banlist is only knowable - // after the bundle yields the root, which is why the check lives here rather than before. - val joinKeys = - ConcordActions.controlPlaneKeys( - communityRoot = bundle.communityRoot.hexToByteArray(), - communityId = bundle.communityId.hexToByteArray(), - rootEpoch = bundle.rootEpoch, - controlPk = bundle.controlPk, - ) - val joinRelays = normalize(bundle.relays).ifEmpty { relays } - val joinEditions = - ConcordActions.controlEditions( - ctx.drain(joinRelays.associateWith { listOf(ConcordActions.planeFilter(joinKeys.address)) }, pendingOnAuthRequired = true).map { it.second }, - joinKeys, - ) - if (joinEditions.isEmpty()) { - return Output.error("control_plane_unreadable", "could not fold this community's Control Plane, so whether it has banned you is unknown — refusing to join") - } - if (AuthorityResolver.resolve(joinEditions, bundle.communityId.hexToByteArray(), bundle.owner).isBanned(ctx.signer.pubKey)) { - return Output.error("banned", "this community has banned this account; the link works but the roster does not admit you (CORD-04)") - } + return joinBundle( + ctx = ctx, + dataDir = dataDir, + bundle = bundle, + fallbackRelays = relays, + // The stranded-recovery anchor: if a later Refounding leaves us out, re-resolving + // this link is the only way back (CORD-05/06). Stored bare, domain-agnostic. + inviteRef = ConcordActions.bareInviteRef(url) ?: "", + inviteCreator = bundle.creatorNpub, + inviteLabel = bundle.label, + ) + } + } - val stored = - StoredCommunity( - name = bundle.name, - communityId = bundle.communityId, - owner = bundle.owner, - ownerSalt = bundle.ownerSalt, - root = bundle.communityRoot, - rootEpoch = bundle.rootEpoch, - // Read access to the Control Plane, never write (CORD-05 §1). Absent = the - // community is still pre-split and folds at the legacy address. - controlPk = bundle.controlPk ?: "", - relays = bundle.relays, - // The stranded-recovery anchor: if a later Refounding leaves us out, re-resolving - // this link is the only way back (CORD-05/06). Stored bare, domain-agnostic. - inviteRef = ConcordActions.bareInviteRef(url) ?: "", - privateChannels = ConcordActions.privateChannelKeysOf(bundle).map { StoredPrivateChannel(it.channelId, it.key, it.epoch, it.name) }, - ) - ConcordStore(dataDir.concordFile).upsert(stored) + /** + * The join half shared by `join` (a link) and `accept` (a Direct Invite): [bundle] is already + * opened, bounded, owner-proof validated and not expired. Ban-gates against the community's own + * Control Plane (read over the bundle's relays, else [fallbackRelays]), stores the membership and + * announces the Guestbook Join with [inviteCreator]/[inviteLabel] attribution. + */ + private suspend fun joinBundle( + ctx: Context, + dataDir: DataDir, + bundle: CommunityInvite, + fallbackRelays: Set, + inviteRef: String, + inviteCreator: String?, + inviteLabel: String?, + ): Int { + // Refuse a link that readmits us after we were removed. A Refounding re-mints every + // outstanding link onto the new root (CORD-05), and an ex-member keeps the URL and its + // unlock token forever — so without this check the rotation that was supposed to expel + // them hands them the new keys instead. `recover` has always been ban-gated; `join` is + // the other door into the same room. + // + // Fails CLOSED on an unreadable plane: no verdict, no join. The banlist is only knowable + // after the bundle yields the root, which is why the check lives here rather than before. + val joinKeys = + ConcordActions.controlPlaneKeys( + communityRoot = bundle.communityRoot.hexToByteArray(), + communityId = bundle.communityId.hexToByteArray(), + rootEpoch = bundle.rootEpoch, + controlPk = bundle.controlPk, + ) + val joinRelays = normalize(bundle.relays).ifEmpty { fallbackRelays } + val joinEditions = + ConcordActions.controlEditions( + ctx.drain(joinRelays.associateWith { listOf(ConcordActions.planeFilter(joinKeys.address)) }, pendingOnAuthRequired = true).map { it.second }, + joinKeys, + ) + if (joinEditions.isEmpty()) { + return Output.error("control_plane_unreadable", "could not fold this community's Control Plane, so whether it has banned you is unknown — refusing to join") + } + if (AuthorityResolver.resolve(joinEditions, bundle.communityId.hexToByteArray(), bundle.owner).isBanned(ctx.signer.pubKey)) { + return Output.error("banned", "this community has banned this account; the invite opens but the roster does not admit you (CORD-04)") + } - // Announce the membership (CORD-05 §6 / CORD-02 §5): a Guestbook Join is how a later - // Refounding finds this member to re-key, and it echoes the link's attribution so link - // holders can count per-link joins. Best-effort, like every Guestbook motion. - val announced = announceGuestbookJoin(ctx, stored, bundle.creatorNpub, bundle.label) - Output.emit(mapOf("community_id" to bundle.communityId, "name" to bundle.name, "relays" to bundle.relays, "guestbook_join" to announced)) + val stored = + StoredCommunity( + name = bundle.name, + communityId = bundle.communityId, + owner = bundle.owner, + ownerSalt = bundle.ownerSalt, + root = bundle.communityRoot, + rootEpoch = bundle.rootEpoch, + // Read access to the Control Plane, never write (CORD-05 §1). Absent = the + // community is still pre-split and folds at the legacy address. + controlPk = bundle.controlPk ?: "", + relays = bundle.relays, + // The stranded-recovery anchor; blank for a Direct Invite, which has no link. + inviteRef = inviteRef, + privateChannels = ConcordActions.privateChannelKeysOf(bundle).map { StoredPrivateChannel(it.channelId, it.key, it.epoch, it.name) }, + ) + ConcordStore(dataDir.concordFile).upsert(stored) + + // Announce the membership (CORD-05 §6 / CORD-02 §5): a Guestbook Join is how a later + // Refounding finds this member to re-key, and it echoes the link's attribution so link + // holders can count per-link joins. Best-effort, like every Guestbook motion. + val announced = announceGuestbookJoin(ctx, stored, inviteCreator, inviteLabel) + Output.emit(mapOf("community_id" to bundle.communityId, "name" to bundle.name, "relays" to bundle.relays, "guestbook_join" to announced)) + return 0 + } + + // ---- Direct Invites (CORD-05 §6) ------------------------------------------- + + /** + * `concord invite COMMUNITY --to USER` — hands the community's keys straight to USER as a + * Direct Invite: the §1 bundle giftwrapped (standard NIP-59, `k=3313`) to their inbox relays. + * Which Private Channel keys ride along, and who is refused, is [ConcordActions.draftDirectInvite]. + */ + private suspend fun directInvite( + dataDir: DataDir, + sc: StoredCommunity, + to: String, + expiresInSecs: Long?, + ): Int { + Context.open(dataDir).use { ctx -> + ctx.prepare() + val recipient = ctx.requireUserHex(to) + // The fold decides which Private Channel keys the recipient's Roles entitle them to and + // whether either side is banned; no fold, no verdict, no send. + val state = ConcordChannelCommands.foldState(ctx, sc) + if (state.metadata == null) { + return Output.error("control_plane_unreadable", "could not fold this community's Control Plane, so which keys the recipient may receive is unknown — not sending") + } + val expiresAtMs = expiresInSecs?.let { TimeUtils.nowMillis() + it * 1000 } + val invite = + when (val draft = ConcordActions.draftDirectInvite(entryFor(sc), state, ctx.signer.pubKey, recipient, expiresAtMs)) { + is ConcordDirectInviteDraft.Ready -> draft.invite + is ConcordDirectInviteDraft.Refused -> + return when (draft.reason) { + ConcordDirectInviteSendResult.RECIPIENT_BANNED -> Output.error("recipient_banned", "this community has banned $recipient; their join would be refused") + ConcordDirectInviteSendResult.INVALID_RECIPIENT -> Output.error("bad_args", "'$to' is not a 32-byte pubkey").let { 2 } + else -> Output.error("not_member", "this account is banned from, or no longer holds, this community") + } + } + val wrap = ConcordActions.buildDirectInvite(ctx.signer, recipient, invite) + // Their kind-10050 DM relays, else NIP-65 read relays, else the stock set (CORD-05 §6). + val lists = ctx.cachedRelayListsOf(recipient) ?: RecipientRelayFetcher.fetchRelayLists(ctx.client, recipient, ctx.bootstrapRelays()) + val relays = ConcordActions.directInviteDeliveryRelays(lists) + val ack = ctx.publish(wrap, relays) + RawEventSupport.publishGuard(ack, wrap.id)?.let { return it } + Output.emit( + mapOf( + "sent" to true, + "wrap_id" to wrap.id, + "recipient" to recipient, + "community_id" to sc.communityId, + "channels" to invite.channels.map { mapOf("id" to it.id, "name" to it.name, "epoch" to it.epoch) }, + "expires_at" to invite.expiresAt, + ) + RawEventSupport.ackFields(ack), + ) return 0 } } + /** + * Collects this account's Direct Invite wraps (`{"kinds":[1059],"#p":[me],"#k":["3313"]}`) from + * where senders deliver them — our 10050 / NIP-65 read / stock relays, plus the DM inbox — into + * the shared headless inbox, with the declines this account already made restored. + */ + private suspend fun sweepDirectInvites( + ctx: Context, + dataDir: DataDir, + ): ConcordDirectInviteInbox { + val inbox = ConcordDirectInviteInbox(ctx.signer) + inbox.restoreDeclined(ConcordInviteInboxStore(dataDir.concordInvitesFile).declined()) + val me = ctx.signer.pubKey + val relays = ConcordActions.directInviteDeliveryRelays(ctx.cachedRelayListsOf(me)) + ctx.inboxRelays() + val wraps = ctx.drain(relays.associateWith { listOf(ConcordActions.directInvitesFilter(me)) }).map { it.second } + wraps.distinctBy { it.id }.forEach { inbox.offer(it) } + return inbox + } + + private fun directInviteJson(view: ConcordDirectInviteView): Map = + mapOf( + "wrap_id" to view.wrapId, + "sender" to view.sender, + "community_id" to view.communityId, + "name" to view.name, + "icon" to view.icon?.url, + "relays" to view.invite.relays, + "channels" to + view.invite.channels + .filter { it.key.isNotBlank() } + .map { mapOf("id" to it.id, "name" to it.name, "epoch" to it.epoch) }, + "sent_at" to view.opened.sentAt, + "expires_at" to view.invite.expiresAt, + "expired" to view.expired, + "catch_up" to view.catchUp, + ) + + /** `concord invites` — the Direct Invites waiting for this account. Read-only: nothing joins. */ + private suspend fun invites( + dataDir: DataDir, + rest: Array, + ): Int { + Args(rest).rejectUnknown() + Context.open(dataDir).use { ctx -> + ctx.prepare() + val inbox = sweepDirectInvites(ctx, dataDir) + val joined = ConcordStore(dataDir.concordFile).load().map { entryFor(it) } + val views = ConcordDirectInviteInbox.visible(inbox.pending.value.values, joined) + Output.emit(mapOf("invites" to views.map { directInviteJson(it) })) { + if (views.isEmpty()) { + "no pending direct invites" + } else { + views.joinToString(System.lineSeparator()) { v -> + val flags = listOfNotNull("expired".takeIf { v.expired }, "catch-up".takeIf { v.catchUp }).joinToString(" ") { "[$it]" } + "${v.wrapId} ${v.name.ifBlank { v.communityId.take(12) }} from ${v.sender}" + if (flags.isNotEmpty()) " $flags" else "" + } + } + } + return 0 + } + } + + /** + * `concord accept WRAP-ID` — accepts a Direct Invite through the same join path as a link: + * refused past `expires_at` or when the roster bans us; for a community already held, only a + * catch-up adopting newly granted Private Channel keys on the same base (never a base move). + */ + private suspend fun accept( + dataDir: DataDir, + rest: Array, + ): Int { + val args = Args(rest) + val ref = args.positional(0, "wrap-id").lowercase() + args.rejectUnknown() + Context.open(dataDir).use { ctx -> + ctx.prepare() + val pending = sweepDirectInvites(ctx, dataDir).pending.value.values + val opened = + pending.firstOrNull { it.wrapId == ref } + ?: pending.singleOrNull { it.wrapId.startsWith(ref) } + ?: return Output.error("not_found", "no pending direct invite with wrap id '$ref' (see `amy concord invites`)") + + val store = ConcordStore(dataDir.concordFile) + val heldSc = store.load().firstOrNull { it.communityId.equals(opened.invite.communityId, ignoreCase = true) } + // An unreadable held plane is no verdict (metadata is written at genesis), so it waits. + val heldState = heldSc?.let { ConcordChannelCommands.foldState(ctx, it) }?.takeIf { it.metadata != null } + + fun done(extra: Map) = mapOf("wrap_id" to opened.wrapId, "community_id" to opened.invite.communityId, "name" to opened.invite.name) + extra + return when (val plan = ConcordDirectInviteInbox.acceptPlan(opened, heldSc?.let { entryFor(it) }, heldState, ctx.signer.pubKey)) { + DirectInviteAcceptPlan.Expired -> Output.error("expired", "this direct invite has expired and can no longer be joined") + DirectInviteAcceptPlan.Banned -> Output.error("banned", "this community has banned this account (CORD-04)") + DirectInviteAcceptPlan.RosterNotLoaded -> Output.error("control_plane_unreadable", "could not fold this community's Control Plane, so whether it has banned you is unknown — refusing to adopt") + DirectInviteAcceptPlan.NothingNew -> { + Output.emit(done(mapOf("joined" to true, "already_member" to true, "catch_up" to false))) + 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 } } + 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 + } + // The Join is attributed to the seal-verified sender, never the bundle's claim. + DirectInviteAcceptPlan.Join -> joinBundle(ctx, dataDir, opened.invite, emptySet(), inviteRef = "", inviteCreator = opened.sender, inviteLabel = opened.invite.label) + } + } + } + + /** `concord decline WRAP-ID` — discards a Direct Invite locally; it is never listed again. */ + private fun decline( + dataDir: DataDir, + rest: Array, + ): Int { + val args = Args(rest) + val wrapId = args.positional(0, "wrap-id").lowercase() + args.rejectUnknown() + if (!HEX64.matches(wrapId)) return Output.error("bad_args", "expected the invite's full 64-hex wrap id, got '$wrapId'").let { 2 } + ConcordInviteInboxStore(dataDir.concordInvitesFile).decline(wrapId) + Output.emit(mapOf("declined" to wrapId)) + return 0 + } + // ---- shared helpers (used by ConcordChannelCommands too) ------------------ private val HEX64 = Regex("^[0-9a-f]{64}$") diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/stores/ConcordInviteInboxStore.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/stores/ConcordInviteInboxStore.kt new file mode 100644 index 0000000000..b9ec3a7116 --- /dev/null +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/stores/ConcordInviteInboxStore.kt @@ -0,0 +1,55 @@ +/* + * 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.stores + +import com.fasterxml.jackson.module.kotlin.readValue +import com.vitorpamplona.amethyst.cli.Output +import com.vitorpamplona.amethyst.cli.SecureFileIO +import java.io.File + +/** amy's bookkeeping for Concord Direct Invites (CORD-05 §6): the wrap ids the user declined. */ +data class StoredInviteInbox( + val declined: List = emptyList(), +) + +/** + * `~/.amy//concord-invites.json` — the declined Direct Invite wrap ids, so a declined + * invite (whose wrap relays keep serving until its NIP-40 expiration) never resurfaces in + * `amy concord invites`. + */ +class ConcordInviteInboxStore( + private val file: File, +) { + fun load(): StoredInviteInbox = + if (file.exists()) { + runCatching { Output.mapper.readValue(file.readText()) }.getOrDefault(StoredInviteInbox()) + } else { + StoredInviteInbox() + } + + fun declined(): Set = load().declined.toSet() + + 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))) + } +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordActions.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordActions.kt index 86f7a9d918..ebc616bf87 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordActions.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordActions.kt @@ -20,6 +20,8 @@ */ package com.vitorpamplona.amethyst.commons.actions +import com.vitorpamplona.amethyst.commons.model.ConcordDirectInviteDraft +import com.vitorpamplona.amethyst.commons.model.ConcordDirectInviteSendResult import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityFactory import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEntry import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityState @@ -630,6 +632,37 @@ object ConcordActions { creatorNpub = creator, ) + /** + * The Direct Invite [sender] may hand [recipient] for the held [entry] whose Control Plane folds + * to [state] (CORD-05 §6), or why not. No community permission gates a Direct Invite — none + * could — but a dissolved community, a [sender] its roster bans (like minting a link), and a + * banned [recipient] (whose join would be refused anyway) are refused; the bundle's name/icon + * preview comes from the folded metadata. + */ + fun draftDirectInvite( + entry: ConcordCommunityListEntry, + state: ConcordCommunityState, + sender: HexKey, + recipient: HexKey, + expiresAtMs: Long? = null, + ): ConcordDirectInviteDraft { + val to = recipient.lowercase() + if (!HEX64.matches(to)) return ConcordDirectInviteDraft.Refused(ConcordDirectInviteSendResult.INVALID_RECIPIENT) + if (state.dissolved || state.authority.isBanned(sender)) return ConcordDirectInviteDraft.Refused(ConcordDirectInviteSendResult.NOT_MEMBER) + if (state.authority.isBanned(to)) return ConcordDirectInviteDraft.Refused(ConcordDirectInviteSendResult.RECIPIENT_BANNED) + return ConcordDirectInviteDraft.Ready( + directInviteFor( + entry = entry, + authority = state.authority, + recipient = to, + creator = sender.lowercase(), + expiresAtMs = expiresAtMs, + name = state.metadata?.name ?: entry.name, + icon = state.metadata?.icon, + ), + ) + } + /** Giftwraps [invite] to [recipient] as a Direct Invite (see [ConcordDirectInvite.build]). */ suspend fun buildDirectInvite( senderSigner: NostrSigner, diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/AccountConcordActions.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/AccountConcordActions.kt index e844c8b791..5a2d0a93dd 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/AccountConcordActions.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/AccountConcordActions.kt @@ -776,29 +776,20 @@ class AccountConcordActions( ): ConcordDirectInviteSendResult { if (!account.isWriteable()) return ConcordDirectInviteSendResult.NOT_WRITEABLE val recipient = recipientPubKey.lowercase() - if (!HEX64.matches(recipient)) return ConcordDirectInviteSendResult.INVALID_RECIPIENT val entry = account.concordChannelList.liveCommunities.value .firstOrNull { it.id == communityId } ?: return ConcordDirectInviteSendResult.NOT_MEMBER + // The fold decides which Private Channel keys the recipient may receive; no fold, no send. val state = account.concordSessions .sessionFor(communityId) ?.state ?.value ?: return ConcordDirectInviteSendResult.ROSTER_NOT_LOADED - if (state.dissolved) return ConcordDirectInviteSendResult.NOT_MEMBER - if (state.authority.isBanned(account.signer.pubKey)) return ConcordDirectInviteSendResult.NOT_MEMBER - if (state.authority.isBanned(recipient)) return ConcordDirectInviteSendResult.RECIPIENT_BANNED - val invite = - ConcordActions.directInviteFor( - entry = entry, - authority = state.authority, - recipient = recipient, - creator = account.signer.pubKey, - expiresAtMs = expiresAtMs, - name = state.metadata?.name ?: entry.name, - icon = state.metadata?.icon, - ) + when (val draft = ConcordActions.draftDirectInvite(entry, state, account.signer.pubKey, recipient, expiresAtMs)) { + is ConcordDirectInviteDraft.Refused -> return draft.reason + is ConcordDirectInviteDraft.Ready -> draft.invite + } val wrap = ConcordActions.buildDirectInvite(account.signer, recipient, invite) val relays = concordDirectInviteDeliveryRelays(recipient) if (relays.isEmpty()) return ConcordDirectInviteSendResult.NOT_DELIVERED diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/ConcordDirectInviteSendResult.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/ConcordDirectInviteSendResult.kt index d82eb09e5c..81f93237a7 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/ConcordDirectInviteSendResult.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/ConcordDirectInviteSendResult.kt @@ -20,6 +20,19 @@ */ package com.vitorpamplona.amethyst.commons.model +import com.vitorpamplona.quartz.concord.cord05Invites.CommunityInvite + +/** A Direct Invite bundle ready to wrap, or why this account may not send one (see `ConcordActions.draftDirectInvite`). */ +sealed interface ConcordDirectInviteDraft { + class Ready( + val invite: CommunityInvite, + ) : ConcordDirectInviteDraft + + class Refused( + val reason: ConcordDirectInviteSendResult, + ) : ConcordDirectInviteDraft +} + /** The outcome of sending a Concord Direct Invite (CORD-05 §6), so the UI can say why it failed. */ enum class ConcordDirectInviteSendResult { /** At least one of the recipient's inbox relays accepted the wrap. */ diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordDirectInviteActionsTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordDirectInviteActionsTest.kt index 05296629f5..63ff78adc8 100644 --- a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordDirectInviteActionsTest.kt +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordDirectInviteActionsTest.kt @@ -20,6 +20,8 @@ */ package com.vitorpamplona.amethyst.commons.actions +import com.vitorpamplona.amethyst.commons.model.ConcordDirectInviteDraft +import com.vitorpamplona.amethyst.commons.model.ConcordDirectInviteSendResult import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityFactory import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEntry import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityState @@ -39,6 +41,7 @@ import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal import kotlinx.coroutines.test.runTest import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertIs import kotlin.test.assertNotNull import kotlin.test.assertTrue @@ -125,6 +128,29 @@ class ConcordDirectInviteActionsTest { assertEquals(entry.controlPk, toMember.controlPk) } + @Test + fun draftRefusesBannedPartiesAndBadRecipients() = + runTest { + val community = ConcordCommunityFactory.create(owner, "Nostrichs", createdAt = 1L, relays = listOf("wss://relay.example")) + val cp = community.controlPlane + val editions = ConcordActions.controlEditions(community.genesisWraps, cp).toMutableList() + editions += ConcordActions.controlEditions(listOf(ConcordModeration.ban(owner, cp, community.communityId, member.pubKey, editions, createdAt = 2L, owner = community.ownerPubKey)), cp) + val state = ConcordCommunityState.fold(editions, community.communityId, community.ownerPubKey) + val entry = entryOf(community) + + fun refusal(draft: ConcordDirectInviteDraft) = (draft as? ConcordDirectInviteDraft.Refused)?.reason + + assertEquals(ConcordDirectInviteSendResult.RECIPIENT_BANNED, refusal(ConcordActions.draftDirectInvite(entry, state, owner.pubKey, member.pubKey))) + assertEquals(ConcordDirectInviteSendResult.NOT_MEMBER, refusal(ConcordActions.draftDirectInvite(entry, state, member.pubKey, mod.pubKey))) + assertEquals(ConcordDirectInviteSendResult.NOT_MEMBER, refusal(ConcordActions.draftDirectInvite(entry, state.withDissolved(true), owner.pubKey, mod.pubKey))) + assertEquals(ConcordDirectInviteSendResult.INVALID_RECIPIENT, refusal(ConcordActions.draftDirectInvite(entry, state, owner.pubKey, "npub1notahexkey"))) + + // The folded metadata names the preview. + val ready = assertIs(ConcordActions.draftDirectInvite(entry, state, owner.pubKey, mod.pubKey.uppercase())) + assertEquals("Nostrichs", ready.invite.name) + assertEquals(owner.pubKey, ready.invite.creatorNpub) + } + @Test fun theBuiltWrapOpensForTheRecipient() = runTest { diff --git a/commonsUI/src/commonMain/composeResources/values/strings.xml b/commonsUI/src/commonMain/composeResources/values/strings.xml index 1003a3ee26..76fbbd37c8 100644 --- a/commonsUI/src/commonMain/composeResources/values/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values/strings.xml @@ -3617,6 +3617,23 @@ Relays Relays that store this community's encrypted messages. Leave empty to use your own. New Concord Channel + Accept + Could not reach this community. Try again in a moment. + Invite by npub… + New channels for a community you are in: %1$s + Decline + The invite is encrypted to this person and delivered to their inbox relays. It carries only the private channels their roles give them, and it cannot be revoked once sent. + This invite has expired + The invite could not be delivered to this person's inbox relays. + This person is banned from this community. + This community is still loading. Try again in a moment. + You can't invite people to this community. + Invited by %1$s + Name, npub or NIP-05 + Send invite to %1$s + Invite sent. + Invite someone directly + Community invites Edit community Editing message Concord Channels diff --git a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/publicChannels/concord/ConcordDirectInvites.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/publicChannels/concord/ConcordDirectInvites.kt new file mode 100644 index 0000000000..e373ccb0f7 --- /dev/null +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/publicChannels/concord/ConcordDirectInvites.kt @@ -0,0 +1,267 @@ +/* + * 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.ui.screen.loggedIn.chats.publicChannels.concord + +import androidx.compose.foundation.layout.Arrangement +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.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.ElevatedCard +import androidx.compose.material3.ListItemDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.vitorpamplona.amethyst.commons.model.ConcordDirectInviteSendResult +import com.vitorpamplona.amethyst.commons.model.ConcordInviteResult +import com.vitorpamplona.amethyst.commons.model.User +import com.vitorpamplona.amethyst.commons.model.cache.LocalCache +import com.vitorpamplona.amethyst.commons.model.concord.ConcordDirectInviteView +import com.vitorpamplona.amethyst.commons.model.navigation.Route +import com.vitorpamplona.amethyst.commons.resources.Res +import com.vitorpamplona.amethyst.commons.resources.cancel +import com.vitorpamplona.amethyst.commons.resources.concord_direct_invite_accept +import com.vitorpamplona.amethyst.commons.resources.concord_direct_invite_accept_failed +import com.vitorpamplona.amethyst.commons.resources.concord_direct_invite_catch_up +import com.vitorpamplona.amethyst.commons.resources.concord_direct_invite_decline +import com.vitorpamplona.amethyst.commons.resources.concord_direct_invite_expired +import com.vitorpamplona.amethyst.commons.resources.concord_direct_invite_explainer +import com.vitorpamplona.amethyst.commons.resources.concord_direct_invite_failed +import com.vitorpamplona.amethyst.commons.resources.concord_direct_invite_failed_banned +import com.vitorpamplona.amethyst.commons.resources.concord_direct_invite_failed_loading +import com.vitorpamplona.amethyst.commons.resources.concord_direct_invite_failed_member +import com.vitorpamplona.amethyst.commons.resources.concord_direct_invite_from +import com.vitorpamplona.amethyst.commons.resources.concord_direct_invite_hint +import com.vitorpamplona.amethyst.commons.resources.concord_direct_invite_send +import com.vitorpamplona.amethyst.commons.resources.concord_direct_invite_sent +import com.vitorpamplona.amethyst.commons.resources.concord_direct_invite_title +import com.vitorpamplona.amethyst.commons.resources.concord_direct_invites_title +import com.vitorpamplona.amethyst.commons.resources.concord_home_title +import com.vitorpamplona.amethyst.commons.resources.concord_invite_failed_banned +import com.vitorpamplona.amethyst.commons.resources.concord_invite_failed_expired +import com.vitorpamplona.amethyst.commons.resources.concord_invite_failed_invalid +import com.vitorpamplona.amethyst.commons.ui.components.ConcordInvitePreviewRow +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.note.creators.userSuggestions.UserSuggestionState +import com.vitorpamplona.amethyst.commons.ui.stringRes +import com.vitorpamplona.amethyst.commons.ui.theme.SuggestionListDefaultHeightChat +import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel +import kotlinx.coroutines.launch + +/** + * "Invite by npub" (CORD-05 §6): pick a person with the app's ordinary user typeahead (cache, relay + * search, NIP-05, a pasted npub/nprofile), then hand them the community's keys as a Direct Invite — + * a giftwrap to their inbox relays carrying only the private channels their roles grant. + */ +@Composable +fun ConcordDirectInviteDialog( + communityId: String, + accountViewModel: AccountViewModel, + onDismiss: () -> Unit, +) { + val scope = rememberCoroutineScope() + var query by remember { mutableStateOf("") } + var picked by remember { mutableStateOf(null) } + var sending by remember { mutableStateOf(false) } + val userSuggestions = + remember(accountViewModel) { + UserSuggestionState(accountViewModel.account, accountViewModel.nip05ClientBuilder()) + } + + LaunchedEffect(query) { userSuggestions.processCurrentWord(query) } + + AlertDialog( + onDismissRequest = { if (!sending) onDismiss() }, + title = { Text(stringRes(Res.string.concord_direct_invite_title)) }, + text = { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text(stringRes(Res.string.concord_direct_invite_explainer), style = MaterialTheme.typography.bodySmall) + OutlinedTextField( + value = query, + onValueChange = { + query = it + picked = null + }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + enabled = !sending, + label = { Text(stringRes(Res.string.concord_direct_invite_hint)) }, + ) + if (picked == null && query.length > 2) { + ShowUserSuggestionList( + userSuggestions = userSuggestions, + onSelect = { user -> + picked = user + query = user.toBestDisplayName() + }, + accountViewModel = accountViewModel, + modifier = SuggestionListDefaultHeightChat, + itemColors = ListItemDefaults.colors(containerColor = Color.Transparent), + showDividers = false, + contentPadding = PaddingValues(0.dp), + ) + } + } + }, + confirmButton = { + val target = picked + TextButton( + enabled = target != null && !sending, + onClick = { + if (target == null) return@TextButton + sending = true + scope.launch { + try { + val result = accountViewModel.account.concord.sendConcordDirectInvite(communityId, target.pubkeyHex) + accountViewModel.toastManager.toast(Res.string.concord_direct_invite_title, sendResultMessage(result)) + if (result == ConcordDirectInviteSendResult.SENT) onDismiss() + } finally { + sending = false + } + } + }, + ) { + Text(stringRes(Res.string.concord_direct_invite_send, picked?.toBestDisplayName() ?: "…")) + } + }, + dismissButton = { + TextButton(enabled = !sending, onClick = onDismiss) { Text(stringRes(Res.string.cancel)) } + }, + ) +} + +private fun sendResultMessage(result: ConcordDirectInviteSendResult) = + when (result) { + ConcordDirectInviteSendResult.SENT -> Res.string.concord_direct_invite_sent + ConcordDirectInviteSendResult.ROSTER_NOT_LOADED -> Res.string.concord_direct_invite_failed_loading + ConcordDirectInviteSendResult.RECIPIENT_BANNED -> Res.string.concord_direct_invite_failed_banned + ConcordDirectInviteSendResult.NOT_MEMBER, ConcordDirectInviteSendResult.NOT_WRITEABLE -> Res.string.concord_direct_invite_failed_member + ConcordDirectInviteSendResult.INVALID_RECIPIENT, ConcordDirectInviteSendResult.NOT_DELIVERED -> Res.string.concord_direct_invite_failed + } + +/** + * 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. + * + * 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( + 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 -> + ConcordDirectInviteCard(invite, accountViewModel, nav) + } + } +} + +@Composable +private fun ConcordDirectInviteCard( + invite: ConcordDirectInviteView, + accountViewModel: AccountViewModel, + nav: INav, +) { + val scope = rememberCoroutineScope() + var working by remember(invite.wrapId) { mutableStateOf(false) } + val autoPlayGif by accountViewModel.settings.autoPlayVideosFlow.collectAsStateWithLifecycle() + val senderName = remember(invite.sender) { LocalCache.checkGetOrCreateUser(invite.sender)?.toBestDisplayName() ?: invite.sender.take(12) } + + 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" }) + else -> stringRes(Res.string.concord_direct_invite_from, senderName) + } + + ElevatedCard(Modifier.fillMaxWidth()) { + ConcordInvitePreviewRow( + robotSeed = invite.communityId, + title = invite.name.ifBlank { stringRes(Res.string.concord_home_title) }, + subtitle = subtitle, + accountViewModel = accountViewModel, + autoPlayGif = autoPlayGif, + ) + Row( + Modifier.fillMaxWidth().padding(start = 12.dp, end = 12.dp, bottom = 12.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp, alignment = Alignment.End), + ) { + OutlinedButton( + enabled = !working, + onClick = { accountViewModel.account.concord.declineConcordDirectInvite(invite.wrapId) }, + ) { + Text(stringRes(Res.string.concord_direct_invite_decline)) + } + Button( + enabled = !working && !invite.expired, + onClick = { + working = true + scope.launch { + try { + when (val result = accountViewModel.account.concord.acceptConcordDirectInvite(invite.wrapId)) { + is ConcordInviteResult.Joined -> nav.nav(Route.ConcordServer(result.communityId)) + is ConcordInviteResult.Expired -> accountViewModel.toastManager.toast(Res.string.concord_direct_invites_title, Res.string.concord_invite_failed_expired) + is ConcordInviteResult.Banned -> accountViewModel.toastManager.toast(Res.string.concord_direct_invites_title, Res.string.concord_invite_failed_banned) + is ConcordInviteResult.InvalidLink -> accountViewModel.toastManager.toast(Res.string.concord_direct_invites_title, Res.string.concord_invite_failed_invalid) + else -> accountViewModel.toastManager.toast(Res.string.concord_direct_invites_title, Res.string.concord_direct_invite_accept_failed) + } + } finally { + working = false + } + } + }, + ) { + Text(stringRes(Res.string.concord_direct_invite_accept)) + } + } + } +} diff --git a/quartz/plans/2026-09-29-concord-spec-conformance.md b/quartz/plans/2026-09-29-concord-spec-conformance.md index b4a9ec4eb9..a4dd16ec67 100644 --- a/quartz/plans/2026-09-29-concord-spec-conformance.md +++ b/quartz/plans/2026-09-29-concord-spec-conformance.md @@ -87,7 +87,7 @@ Ranked security > interop > feature inside each group. | 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) | | F5 | 05 §5 | Invite Registry (vsk 8) not published or folded | open | -| F6 | 05 §6 | Direct invites: wire format only, no send/receive | open | +| 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) | | 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 |