fix(concord): require consent for invite links, enforce expiry, fold the head

Three fixes to the Concord invite and moderation paths.

**Invite deep links redeemed with zero consent.** `ConcordInviteScreen`
called `joinConcordViaInvite` from a `LaunchedEffect` on open, and the
manifest registers `https://amethyst.social/invite/` as BROWSABLE. So a
link on any web page — or a QR code, or a push — silently caused a
connection to up to three ATTACKER-CHOSEN relay URLs decoded from the URL
fragment (disclosing the user's IP to a third party), a Guestbook JOIN
signed by the user's identity published to those relays, and a write to
their private community list. No tap, no preview.

The screen now opens in an awaiting-consent state and only joins from an
explicit Join button. The preview is built entirely from the link itself
— base64url and NIP-19 decoding, both pure in-memory — and touches the
network for nothing: no relay connection, no signing, no publishing. It
shows the relays it would contact so the user can see whom they'd be
talking to. The community name lives inside a bundle only those relays
can serve, so it is honestly reported as unknown until joining rather
than fetched.

**Invite expiry was decorative.** `ConcordInviteBundle.isExpired` had no
production callers at all — the only ones were in a test — so an expired
invite redeemed forever. Expiry is now enforced at `classify`, the choke
point every redeem path funnels through, with its own result and message
so the user knows to ask for a fresh link.

**Moderation read the wrong edition.** `ConcordModeration` used
`firstOrNull` over `controlEditions()`, which is in wrap-ARRIVAL order,
not the folded head. Once an entity had two or more editions the next one
chained off a stale predecessor, forking the chain at an already-used
version, and `EditionFold` then resolved the fork by `minByOrNull` on the
rumor id — a coin flip. Bans were masked by a down-only healing union;
UNBANS and role revocations were not, so they could silently fail to
apply. Both call sites now fold to the true head.

Regression tests assert the fold-head behaviour under two arrival orders
— a single order accidentally puts the head first and passes against the
buggy code.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Vitor Pamplona
2026-07-19 18:59:50 -04:00
co-authored by Claude Opus 4.8
parent 153191e722
commit c8be65a02e
10 changed files with 368 additions and 53 deletions
@@ -52,6 +52,7 @@ import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import com.vitorpamplona.quartz.nip92IMeta.IMetaTag
import com.vitorpamplona.quartz.nipC7Chats.ChatEvent
import com.vitorpamplona.quartz.utils.TimeUtils
/** One decrypted, verified Concord channel message projected for display. */
data class ConcordChatMessage(
@@ -358,14 +359,16 @@ object ConcordActions {
/**
* Resolves every event fetched at an invite's addressable coordinate into one
* [InviteBundleStatus] (live / revoked / unreadable / absent) per CORD-05 §2, so a
* redeeming client honours a `vsk=9` revocation tombstone and reports why a link
* can't be opened instead of retrying blindly.
* [InviteBundleStatus] (live / expired / revoked / unreadable / absent) per CORD-05
* §2, so a redeeming client honours a `vsk=9` revocation tombstone and an
* `expires_at` in the past, and reports why a link can't be opened instead of
* retrying blindly. [nowMs] is unix milliseconds.
*/
fun classifyInvite(
wraps: List<Event>,
token: ByteArray,
): InviteBundleStatus = ConcordInviteBundle.classify(wraps, token)
nowMs: Long = TimeUtils.nowMillis(),
): InviteBundleStatus = ConcordInviteBundle.classify(wraps, token, nowMs)
/** Derives the control plane described by a redeemed [invite] so the joiner can read it. */
fun controlPlaneFor(invite: CommunityInvite): GroupKey = controlPlane(invite.communityRoot.hexToByteArray(), invite.communityId.hexToByteArray(), invite.rootEpoch)
@@ -26,6 +26,7 @@ import com.vitorpamplona.quartz.concord.cord04Roles.ConcordJson
import com.vitorpamplona.quartz.concord.cord04Roles.ControlEdition
import com.vitorpamplona.quartz.concord.cord04Roles.ControlEditionBuilder
import com.vitorpamplona.quartz.concord.cord04Roles.ControlEntityKind
import com.vitorpamplona.quartz.concord.cord04Roles.EditionFold
import com.vitorpamplona.quartz.concord.cord04Roles.GrantEntity
import com.vitorpamplona.quartz.concord.cord04Roles.MetadataEntity
import com.vitorpamplona.quartz.concord.cord04Roles.RoleEntity
@@ -55,13 +56,31 @@ import kotlinx.serialization.builtins.serializer
* act under so the fold can verify the chain terminates at the owner.
*/
object ConcordModeration {
/**
* The current head of ([kind], [entityId]) within [current], or null if the entity
* has no editions yet.
*
* [current] arrives in **wrap-arrival order**, which is not chain order — so the
* first matching edition is whichever one a relay happened to deliver first, not
* the newest. Chaining off that stale edition would fork the chain at an
* already-used version, and [EditionFold] would then break the tie by
* `minByOrNull { rumorId }` — a coin flip that can silently drop the new edition
* (an unban or a role revocation quietly failing to apply). Fold the entity's
* chain instead, exactly as every reader does.
*/
private fun headOf(
current: List<ControlEdition>,
kind: ControlEntityKind,
entityId: ByteArray,
): ControlEdition? = EditionFold.foldEntity(current.filter { it.entityKind == kind && it.entityId.contentEquals(entityId) })
/** version/prevHash to chain onto the current head of ([kind], [entityId]), or genesis. */
private fun versioning(
current: List<ControlEdition>,
kind: ControlEntityKind,
entityId: ByteArray,
): Pair<Long, ByteArray?> {
val head = current.firstOrNull { it.entityKind == kind && it.entityId.contentEquals(entityId) }
val head = headOf(current, kind, entityId)
return if (head != null) (head.version + 1) to head.hash else 0L to null
}
@@ -184,7 +203,7 @@ object ConcordModeration {
communityId: ByteArray,
): Set<HexKey> {
val entityId = ConcordKeyDerivation.banlistCoordinate(communityId)
val head = current.firstOrNull { it.entityKind == ControlEntityKind.BANLIST && it.entityId.contentEquals(entityId) }
val head = headOf(current, ControlEntityKind.BANLIST, entityId)
return head?.let { ConcordJson.decodeBanlist(it.content) }?.mapTo(HashSet()) { it.lowercase() } ?: emptySet()
}