feat(concord): WebXDC kind 3310 plumbing — accepted on the Chat Plane, held apart from chat rows (F10)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PmuzkD5qdqgv6dMeRMoC5N
This commit is contained in:
Claude
2026-09-29 20:52:21 +00:00
parent 947ca3ebbb
commit 72d8b90d52
7 changed files with 432 additions and 10 deletions
@@ -623,14 +623,16 @@ object ConcordActions {
/**
* [openChannelRumor] without the CORD-08 expiry refusal, for a caller that must tell an expired
* rumor apart from garbage — the session, which purges an expired rumor's wrap instead of merely
* skipping it. Such a caller owns the refusal.
* skipping it. Such a caller owns the refusal. [kinds] widens the gate to
* [ChannelChat.PLANE_KINDS] for a caller that routes the WebXDC signal apart from chat rows.
*/
fun openChannelRumorAnyExpiry(
wrap: Event,
channel: GroupKey,
channelId: HexKey,
epoch: Long,
): Event? = ConcordStreamEnvelope.openOrNull(wrap, channel)?.let { ChannelChat.acceptOpened(it, channelId, epoch) }
kinds: Set<Int> = ChannelChat.CHAT_KINDS,
): Event? = ConcordStreamEnvelope.openOrNull(wrap, channel)?.let { ChannelChat.acceptOpened(it, channelId, epoch, kinds) }
// ---- invites --------------------------------------------------------------
@@ -38,6 +38,7 @@ import com.vitorpamplona.quartz.concord.cord02Community.GuestbookAction
import com.vitorpamplona.quartz.concord.cord02Community.GuestbookEntry
import com.vitorpamplona.quartz.concord.cord03Channels.ChannelChat
import com.vitorpamplona.quartz.concord.cord03Channels.ConcordDisappearing
import com.vitorpamplona.quartz.concord.cord03Channels.ConcordWebxdc
import com.vitorpamplona.quartz.concord.cord04Roles.ControlEdition
import com.vitorpamplona.quartz.concord.cord04Roles.EditionFold
import com.vitorpamplona.quartz.concord.cord04Roles.EntityFloor
@@ -888,7 +889,17 @@ class ConcordCommunitySession(
val authors = HashSet<HexKey>()
val now = TimeUtils.now()
for (wrap in wraps) {
val rumor = ConcordActions.openChannelRumorAnyExpiry(wrap, key, channelIdHex, epoch) ?: continue
val rumor = ConcordActions.openChannelRumorAnyExpiry(wrap, key, channelIdHex, epoch, ChannelChat.PLANE_KINDS) ?: continue
// A WebXDC signal (kind 3310) rides the plane but is never a chat row: held apart for a
// WebXDC host, never handed to the store, so it can't reach a feed, a preview or an
// unread count. Its author is still observably present (CORD-02 §5).
if (ConcordWebxdc.isWebxdc(rumor)) {
if (!ConcordDisappearing.isExpired(rumor, now) && holdWebxdc(channelIdHex, rumor)) {
observe(rumor)
authors.add(rumor.pubKey.lowercase())
}
continue
}
// Pins reopen the carrying wrap to disclose this one message's keys (CORD-04 §7).
lock.withLock { wrapIdByRumorId[rumor.id] = wrap.id }
// CORD-08 §3: only the rumor's own tag counts. A rumor carrying one is remembered so the
@@ -899,10 +910,8 @@ class ConcordCommunitySession(
trackExpiring(wrap.id, channelIdHex, rumor.id, expiresAt)
if (expiresAt <= now) continue
}
val author = rumor.pubKey.lowercase()
authors.add(author)
val atMs = ChannelChat.orderingMs(rumor) ?: (rumor.createdAt * 1000)
lock.withLock { if (atMs > (observedAtMs[author] ?: Long.MIN_VALUE)) observedAtMs[author] = atMs }
authors.add(rumor.pubKey.lowercase())
observe(rumor)
onRumor(entry.id, channelIdHex, rumor, seenOnRelays)
}
// Every author we just decrypted is observably present (CORD-02 §5), so fold them into the
@@ -912,6 +921,54 @@ class ConcordCommunitySession(
}
}
/** Records [rumor]'s author as seen at its CORD-02 §4 time (observation counts forward only). */
private fun observe(rumor: Event) {
val author = rumor.pubKey.lowercase()
val atMs = ChannelChat.orderingMs(rumor) ?: (rumor.createdAt * 1000)
lock.withLock { if (atMs > (observedAtMs[author] ?: Long.MIN_VALUE)) observedAtMs[author] = atMs }
}
// ── WebXDC signals (kind 3310) ───────────────────────────────────────────
// Channel id -> its WebXDC signals by rumor id, in arrival order, bounded per channel.
private val webxdcByChannel = HashMap<HexKey, LinkedHashMap<HexKey, Event>>()
private val _webxdcRevision = MutableStateFlow(0L)
/** Bumps whenever a new WebXDC signal is held — what a WebXDC host re-reads [webxdcSignals] on. */
val webxdcRevision: StateFlow<Long> = _webxdcRevision
/**
* [channelIdHex]'s held WebXDC signals (kind 3310: app state updates and realtime peer signals,
* [ConcordWebxdc]) not yet expired (CORD-08: app state disappears with the chat plane), oldest
* first on the CORD-02 §4 basis. Amethyst has no WebXDC host; this is the plumbing one would read.
*/
fun webxdcSignals(
channelIdHex: HexKey,
now: Long = TimeUtils.now(),
): List<Event> =
lock
.withLock { webxdcByChannel[channelIdHex]?.values?.toList() }
.orEmpty()
.filterNot { ConcordDisappearing.isExpired(it, now) }
.sortedBy { ChannelChat.orderingMs(it) ?: (it.createdAt * 1000) }
/** Holds [rumor] for [channelIdHex]; false when already held. Keeps the newest [MAX_WEBXDC_PER_CHANNEL] arrivals. */
private fun holdWebxdc(
channelIdHex: HexKey,
rumor: Event,
): Boolean {
val added =
lock.withLock {
val held = webxdcByChannel.getOrPut(channelIdHex) { LinkedHashMap() }
if (held.put(rumor.id, rumor) != null) return@withLock false
while (held.size > MAX_WEBXDC_PER_CHANNEL) held.remove(held.keys.first())
true
}
if (added) _webxdcRevision.update { it + 1 }
return added
}
// ── Disappearing messages (CORD-08) ──────────────────────────────────────
/** Wrap id -> the expiring rumor it carries, for every rumor with an `expiration` we emitted or refused. */
@@ -1033,5 +1090,8 @@ class ConcordCommunitySession(
/** A typing heartbeat is considered current for this many seconds after it's seen. */
const val TYPING_STALE_SECS = 8L
/** WebXDC signals held per channel (the reference client scans its newest 2000 for peer signals). */
const val MAX_WEBXDC_PER_CHANNEL = 2000
}
}
@@ -0,0 +1,96 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.commons.model.concord
import com.vitorpamplona.amethyst.commons.actions.ConcordActions
import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityFactory
import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEntry
import com.vitorpamplona.quartz.concord.cord03Channels.ConcordWebxdc
import com.vitorpamplona.quartz.concord.envelope.ConcordStreamEnvelope
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal
import kotlinx.coroutines.test.runTest
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNotNull
import kotlin.test.assertTrue
/**
* WebXDC signals (kind 3310) on a Chat Plane (F10): the session accepts them under the plane's strict
* binding and holds them for a WebXDC host, but never hands them to the chat store — so they never
* become feed rows, previews or unread messages — while ordinary messages on the same plane still do.
*/
class ConcordWebxdcSessionTest {
private val owner = NostrSignerInternal(KeyPair())
private val alice = NostrSignerInternal(KeyPair())
private val topic = "A".repeat(26) + "234567".repeat(4) + "BC"
@Test
fun webxdcSignalsAreHeldApartFromChatRows() =
runTest {
val community = ConcordCommunityFactory.create(owner, "Nostrichs", createdAt = 1L, relays = listOf("wss://r.example"))
val entry =
ConcordCommunityListEntry(
id = community.communityIdHex,
owner = community.ownerPubKey,
ownerSalt = community.ownerSalt.toHexKey(),
root = community.communityRoot.toHexKey(),
rootEpoch = community.rootEpoch,
controlPk = community.controlPkHex,
controlRoot = community.controlRoot.toHexKey(),
relays = listOf("wss://r.example"),
name = "Nostrichs",
)
val stored = mutableListOf<Event>()
val session = ConcordCommunitySession(entry, owner.pubKey) { _, _, rumor, _ -> stored += rumor }
community.genesisWraps.forEach { session.ingest(it) }
val general = community.generalChannelIdHex
val plane = assertNotNull(session.currentChannelPlane(general))
val update = ConcordWebxdc.stateUpdate(alice.pubKey, general, plane.epoch, "uuid-1", "{\"move\":1}", createdAt = 10L)
val ad = ConcordWebxdc.peerSignal(alice.pubKey, general, plane.epoch, topic, "node-addr", createdAt = 11L)
// Bound to another epoch: the plane's strict binding still refuses it.
val misbound = ConcordWebxdc.stateUpdate(alice.pubKey, general, plane.epoch + 1, "uuid-1", "{}", createdAt = 12L)
for (rumor in listOf(update, ad, misbound)) {
session.ingest(ConcordStreamEnvelope.wrap(rumor, plane.key, alice, encrypted = true, createdAt = rumor.createdAt))
}
val message = ConcordActions.buildChannelMessage(alice, plane.key, general, plane.epoch, "hello", 13L)
session.ingest(message)
// Only the chat message reached the store (feeds, previews, unread counts read from there).
assertEquals(listOf("hello"), stored.map { it.content })
assertTrue(stored.none { it.kind == ConcordWebxdc.KIND })
// The signals are held for a WebXDC host, oldest first; the misbound one is not.
val held = session.webxdcSignals(general)
assertEquals(listOf(update.id, ad.id), held.map { it.id })
assertEquals("uuid-1", ConcordWebxdc.sessionOf(held.first()))
assertEquals("node-addr", ConcordWebxdc.parsePeerSignal(held.last().content)?.addr)
assertEquals(2L, session.webxdcRevision.value)
// A duplicate delivery holds nothing new; the author counts as observed either way.
session.ingest(ConcordStreamEnvelope.wrap(update, plane.key, alice, encrypted = true, createdAt = 10L))
assertEquals(2, session.webxdcSignals(general).size)
assertTrue(alice.pubKey.lowercase() in session.observedAuthors.value)
}
}
@@ -91,7 +91,7 @@ Ranked security > interop > feature inside each group.
| F7 | 06 §1-2 | Channel-scope rekeys; private-channel keys in invites | **fixed** — quartz `ConcordChannelRekey`: root-keyed `concord/rekey-pseudonym` address, 72-byte scope-bound blobs, chunked 3303 with `prevcommit` over the held channel key and `vac` on every chunk; the receive walk adopts only complete, honored (owner / MANAGE_CHANNELS / BAN + synced `vac`) rotations off the held key (multi-epoch, racing rotators → lowest key) and treats "no blob" as a cut only from a rotator who outranks us, published after our join. `ConcordChannelKeyring` rotates keys in place, reads older keys from `seed`/peer `priors` (never writes intermediate keys, CORD-02 §8) and models Armada's `channel_cuts` floor (extension, round-tripped). `ConcordInviteVend.entitledMembers`/`accessChanges`/`judgeCatchUp`. Commons `ConcordPrivateChannels` + app verbs: create Private channel (key at channel epoch 0 + bit-less access Role, as Armada), privatise (next channel epoch, floored by probing the rekey addresses) / publicise, `rekeyConcordChannel`, vend on grant (Direct Invite limited to the gained channels) and rotate on revoke/ban, the Refounding rotates every held private channel under the prior root, sessions subscribe/AUTH/buffer the channel-rekey window and the revision tick drains it; staff catch-ups auto-adopt. Links carry no channel keys (F6's `vendableChannels`, audience link). UI: Private toggle + access-role name on create, Make private/public + Rotate key per channel. `amy concord channel create/privatize/publicize/rekey`, `rekey`/`grant`/`refound` follow channel keys. Not done: republishing a rotated channel's sealed Pin List under the new key (a SHOULD); history across our own rotations after restart (intermediate keys stay out of the List by spec, and the key walk from `seed` is not implemented); a role *scope edit* in the UI does not trigger reconcile (only grants/revokes/bans do) |
| F8 | 06 §2, 02 §8 | Walk forward from `seed`; we still keep intermediate roots in a `held_roots` List extension the spec says doesn't belong there | open |
| F9 | 04 §6 | Kick (kind 3309) | **fixed** — quartz `Guestbook.kick` writes the examples' shape (`ms`, `p`, `vac`), `Guestbook.parse` reads Kicks too (target, author, `ms` basis, rumor id, citation; malformed `ms`/target dropped; a 3306 "kick" verb is not a Kick), `Guestbook.canKick` honors one only from a KICK holder who strictly outranks the target with a synced `vac` (block-until-synced), and `Guestbook.coalesce` is the CORD-02 §5 fold (latest per npub by ms, lower rumor id on a tie, >1 h future dropped, banned authors dropped) — the old projection was createdAt-only and ignored Kicks. Commons: the session coalesces against its roster and re-coalesces on every control fold (a parked Kick lands when its Grant folds), exposes `guestbook`, `departedMembers()` (Leave/Kick not followed by newer observed activity — observation counts forward only) and `kickedMe()` (honored Kick postdating the entry's `added_at`); `allMembers()` drops the departed; guestbook seals must be encrypted. `kickConcordMember` strips roles first (MANAGE_ROLES + outrank, best-effort, as Armada) then publishes the directive; the revision tick's `drainConcordKicks` leaves the community locally (List tombstone, like Leave; network-silent) and emits a `ConcordKickNotice` the app toasts. Re-join works: `follow` bumps `added_at` past the tombstone, which also puts the old Kick behind the new membership. UI: Kick (KICK + outrank, confirm dialog) next to Ban in the members roster; departed members show a Kicked/Left badge. `amy concord kick COMMUNITY USER`. Not done: Armada's double-read debounce before self-removal, the dissolution ordering rule for Kicks (we refuse to *write* one on a dissolved community but do not date-check received ones), and Guestbook snapshots (kind 3312) |
| F10 | 03 | WebXDC (kind 3310) | open |
| F10 | 03 | WebXDC (kind 3310) | **fixed (plumbing only)** — 3310 was refused by the Chat gate and silently dropped. quartz `ConcordWebxdc` builds/parses what Armada puts on the kind (the spec leaves the payload opaque, examples §2.6): app **state updates** (`["i", <session>]`, `["alt","Webxdc update"]`, optional `info`/`document`/`summary`, JSON content) and realtime **peer signals** (`{"op":"ad","topic":<52-char base32>,"addr":…}` / `{"op":"left","topic":…}`, addr ≤ 2048, as Vector bounds it), both under the usual `channel`/`epoch`/`ms` binding; `ChannelChat.PLANE_KINDS` = `CHAT_KINDS` + 3310 and `acceptOpened(…, kinds)`, so 3310 stays out of `CHAT_KINDS` (never a row, never pinnable, still refused by `EventCache.consumeConcordRumor`). The session opens its planes with `PLANE_KINDS` and routes 3310 into a bounded per-channel buffer (`webxdcSignals(channel)`, `webxdcRevision`; 2000 per channel, CORD-08-expired ones filtered) instead of the chat store, so feeds, previews and unread counts never see it; its author still counts as observed. Amethyst has no WebXDC host. **Full support** (Armada `useConcordAppSync` + `XdcAttachment`, interoperating with Vector) would take: rendering an `.xdc` attachment (`application/x-webxdc` imeta carrying a `webxdc` session/topic id, or a topic derived from URL + message id) as an app card; a sandboxed WebView runtime exposing the webxdc JS API (`sendUpdate`/`setUpdateListener` → durable 3310 state updates for the session, read back by `#i`, with the CORD-08 `expiration`; `joinRealtimeChannel` → realtime); and for realtime an iroh gossip transport (Vector's frame format with the 36-byte `seq‖sender` trailer, topic = base32(sha256(…))) advertised/withdrawn by 3310 peer signals and folded latest-per-author (ties to `left`, >1 h future refused) — there is no relay fallback by design. The `:napplet` WebView sandbox is the natural host; iroh has no Kotlin implementation (our `:quic` is plain QUIC/HTTP3) |
## Spec issues to raise upstream
@@ -435,11 +435,20 @@ object ChannelChat {
/** True when [kind] may ride a Chat Plane ([CHAT_KINDS]). */
fun isChatKind(kind: Int): Boolean = kind in CHAT_KINDS
/**
* Every rumor kind a Chat Plane carries (CORD-02 Appendix B): the chat kinds plus the WebXDC
* signal ([ConcordWebxdc], kind 3310), which rides the plane under the same binding but is never
* a chat row — so it is kept out of [CHAT_KINDS], and only a caller that routes it apart (the
* session's WebXDC buffer) opens a plane with this set.
*/
val PLANE_KINDS: Set<Int> = CHAT_KINDS + ConcordWebxdc.KIND
/**
* The Chat Plane ingest gate for a wrap already opened under [channelId]'s key at [epoch]:
* returns its rumor only when every Chat rule holds, else null (drop it).
* - the seal is the encrypted kind 20013 (CORD-02 §5: a plaintext 20014 seal is Control-only);
* - the rumor kind is a Chat kind ([CHAT_KINDS]), never another plane's;
* - the rumor kind is one of [kinds] — by default the Chat kinds ([CHAT_KINDS]), never another
* plane's; [PLANE_KINDS] also admits the WebXDC signal for a caller that routes it apart;
* - the binding is strict: exactly one `channel` and one `epoch`, equal to the plane's
* ([isBoundTo], CORD-03 §3);
* - its `ms` tag, if any, is well formed (CORD-02 §4/§5 — a malformed one is dropped, never
@@ -449,10 +458,11 @@ object ChannelChat {
opened: OpenedStreamEvent,
channelId: HexKey,
epoch: Long,
kinds: Set<Int> = CHAT_KINDS,
): Event? {
if (opened.sealKind != ConcordStreamEnvelope.KIND_SEAL_ENCRYPTED) return null
val rumor = opened.rumor
if (!isChatKind(rumor.kind)) return null
if (rumor.kind !in kinds) return null
if (!isBoundTo(rumor, channelId, epoch)) return null
if (orderingMs(rumor) == null) return null
return rumor
@@ -0,0 +1,163 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.concord.cord03Channels
import com.vitorpamplona.quartz.concord.cord03Channels.tags.ChannelTag
import com.vitorpamplona.quartz.concord.cord03Channels.tags.EpochTag
import com.vitorpamplona.quartz.concord.cord03Channels.tags.MsTag
import com.vitorpamplona.quartz.concord.cord04Roles.ConcordJson
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.firstTagValue
import com.vitorpamplona.quartz.nip59Giftwrap.rumors.RumorAssembler
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.put
/**
* A WebXDC realtime peer signal: [topic] is the app's gossip topic (52 base32 characters), and
* [addr] the advertised, opaque node address — null for a departure (`"left"`).
*/
class WebxdcPeerSignal(
val topic: String,
val addr: String?,
) {
val isAdvert: Boolean get() = addr != null
}
/**
* WebXDC on the Chat Plane (kind 3310, CORD-02 Appendix B; examples §2.6). The CORDs register the
* kind — a Chat rumor with the usual `channel`/`epoch`/`ms` binding — but do not pin its payload: the
* content is app-level and opaque to the protocol. A 3310 is **never a chat message**: it is not a
* feed row, not a preview, not unread; a client with no WebXDC host just holds it for one.
*
* What rides it in practice is the reference client's (Armada, interoperating with Vector), which
* this object builds and parses so a future host has the plumbing:
* - an app **state update** for one app session: `["i", <session>]` and `["alt", "Webxdc update"]`
* (plus optional `info`, `document`, `summary`), the content being the JSON payload;
* - a realtime **peer signal**, untagged beyond the binding: content
* `{"op":"ad","topic":<52-char base32>,"addr":<node address>}` to advertise a gossip endpoint, or
* `{"op":"left","topic":…}` to withdraw it.
* Amethyst has no WebXDC runtime; see the conformance review (F10) for what full support would take.
*/
object ConcordWebxdc {
const val KIND = 3310
const val TAG_SESSION = "i"
const val TAG_ALT = "alt"
const val ALT_UPDATE = "Webxdc update"
const val TAG_INFO = "info"
const val TAG_DOCUMENT = "document"
const val TAG_SUMMARY = "summary"
/** A topic id is 32 bytes, so its RFC 4648 base32 form (no padding) is always this long. */
const val TOPIC_ID_CHARS = 52
/** The longest advertised node address honored (Vector's cap); anything longer is not one. */
const val MAX_NODE_ADDR_CHARS = 2048
private const val OP_AD = "ad"
private const val OP_LEFT = "left"
/** True when [rumor] is a WebXDC signal. */
fun isWebxdc(rumor: Event): Boolean = rumor.kind == KIND
/** An app state update for app session [session] on [channelId]/[epoch]; [payload] is the app's JSON. */
fun stateUpdate(
authorPubKey: HexKey,
channelId: HexKey,
epoch: Long,
session: String,
payload: String,
createdAt: Long,
info: String? = null,
document: String? = null,
summary: String? = null,
ms: Int = MsTag.remainderFor(createdAt),
): Event {
val tags = binding(channelId, epoch, ms)
tags.add(arrayOf(TAG_SESSION, session))
tags.add(arrayOf(TAG_ALT, ALT_UPDATE))
if (info != null) tags.add(arrayOf(TAG_INFO, info))
if (document != null) tags.add(arrayOf(TAG_DOCUMENT, document))
if (summary != null) tags.add(arrayOf(TAG_SUMMARY, summary))
return RumorAssembler.assembleRumor(authorPubKey, createdAt, KIND, tags.toTypedArray(), payload)
}
/**
* A realtime peer signal on [channelId]/[epoch]: an advert of [nodeAddr] for [topic], or, when
* [nodeAddr] is null, the departure from it.
*/
fun peerSignal(
authorPubKey: HexKey,
channelId: HexKey,
epoch: Long,
topic: String,
nodeAddr: String?,
createdAt: Long,
ms: Int = MsTag.remainderFor(createdAt),
): Event = RumorAssembler.assembleRumor(authorPubKey, createdAt, KIND, binding(channelId, epoch, ms).toTypedArray(), peerSignalContent(topic, nodeAddr))
/** The peer-signal body, key order `op`, `topic`, `addr` as the reference client writes it. */
fun peerSignalContent(
topic: String,
nodeAddr: String?,
): String =
buildJsonObject {
put("op", if (nodeAddr == null) OP_LEFT else OP_AD)
put("topic", topic)
if (nodeAddr != null) put("addr", nodeAddr)
}.toString()
/** The app session a state update belongs to, or null (a peer signal carries none). */
fun sessionOf(rumor: Event): String? = if (rumor.kind == KIND) rumor.tags.firstTagValue(TAG_SESSION) else null
/** Exactly [TOPIC_ID_CHARS] uppercase RFC 4648 base32 characters (Vector's receive-side check). */
fun isTopicId(value: String?): Boolean = value != null && value.length == TOPIC_ID_CHARS && value.all { it in 'A'..'Z' || it in '2'..'7' }
/**
* Parses an untrusted peer-signal body: null unless it is `{"op":"ad","topic","addr"}` with a
* valid topic and a non-empty address of at most [MAX_NODE_ADDR_CHARS], or `{"op":"left","topic"}`.
*/
fun parsePeerSignal(content: String): WebxdcPeerSignal? {
val obj = runCatching { ConcordJson.instance.parseToJsonElement(content) }.getOrNull() as? JsonObject ?: return null
val topic = (obj["topic"] as? JsonPrimitive)?.takeIf { it.isString }?.content ?: return null
if (!isTopicId(topic)) return null
val op = (obj["op"] as? JsonPrimitive)?.takeIf { it.isString }?.content
return when (op) {
OP_LEFT -> WebxdcPeerSignal(topic, null)
OP_AD -> {
val addr = (obj["addr"] as? JsonPrimitive)?.takeIf { it.isString }?.content ?: return null
if (addr.isEmpty() || addr.length > MAX_NODE_ADDR_CHARS) return null
WebxdcPeerSignal(topic, addr)
}
else -> null
}
}
/** The binding every Chat rumor commits, in the examples' order: channel, epoch, ms. */
private fun binding(
channelId: HexKey,
epoch: Long,
ms: Int,
): ArrayList<Array<String>> = arrayListOf(ChannelTag.assemble(channelId), EpochTag.assemble(epoch), MsTag.assemble(ms))
}
@@ -0,0 +1,91 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.concord.cord03Channels
import com.vitorpamplona.quartz.concord.crypto.ConcordKeyDerivation
import com.vitorpamplona.quartz.concord.crypto.ConcordLabels
import com.vitorpamplona.quartz.concord.envelope.ConcordStreamEnvelope
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal
import kotlinx.coroutines.test.runTest
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertNotNull
import kotlin.test.assertNull
import kotlin.test.assertTrue
/** WebXDC signals (kind 3310): Chat-plane plumbing only — built, bound, gated apart from chat rows, parsed. */
class ConcordWebxdcTest {
private val author = NostrSignerInternal(KeyPair())
private val channelId = "42".repeat(32)
private val topic = "A".repeat(26) + "234567".repeat(4) + "BC" // 52 base32 chars
@Test
fun theExamplesBindingAndTheStateUpdateTags() {
val rumor = ConcordWebxdc.stateUpdate(author.pubKey, channelId, 0L, session = "uuid-1", payload = "{\"x\":1}", createdAt = 1_686_840_700L, info = "moved", ms = 266)
assertEquals(3310, rumor.kind)
// examples.md §2.6: channel, epoch, ms — then the app's own tags.
assertEquals(listOf("channel", "epoch", "ms", "i", "alt", "info"), rumor.tags.map { it[0] })
assertTrue(ChannelChat.isBoundTo(rumor, channelId, 0L))
assertEquals("uuid-1", ConcordWebxdc.sessionOf(rumor))
assertEquals("{\"x\":1}", rumor.content)
}
@Test
fun peerSignalsRoundTripInTheReferenceShape() {
val ad = ConcordWebxdc.peerSignal(author.pubKey, channelId, 0L, topic, nodeAddr = "node-addr", createdAt = 5L)
assertEquals("{\"op\":\"ad\",\"topic\":\"$topic\",\"addr\":\"node-addr\"}", ad.content)
assertNull(ConcordWebxdc.sessionOf(ad))
val parsed = assertNotNull(ConcordWebxdc.parsePeerSignal(ad.content))
assertTrue(parsed.isAdvert)
assertEquals("node-addr", parsed.addr)
val left = ConcordWebxdc.peerSignal(author.pubKey, channelId, 0L, topic, nodeAddr = null, createdAt = 6L)
assertEquals("{\"op\":\"left\",\"topic\":\"$topic\"}", left.content)
assertFalse(assertNotNull(ConcordWebxdc.parsePeerSignal(left.content)).isAdvert)
}
@Test
fun untrustedPeerSignalsAreBounded() {
assertNull(ConcordWebxdc.parsePeerSignal("not json"))
assertNull(ConcordWebxdc.parsePeerSignal("{\"op\":\"ad\",\"topic\":\"short\",\"addr\":\"a\"}"))
assertNull(ConcordWebxdc.parsePeerSignal("{\"op\":\"ad\",\"topic\":\"${topic.lowercase()}\",\"addr\":\"a\"}"))
assertNull(ConcordWebxdc.parsePeerSignal("{\"op\":\"ad\",\"topic\":\"$topic\",\"addr\":\"\"}"))
assertNull(ConcordWebxdc.parsePeerSignal("{\"op\":\"ad\",\"topic\":\"$topic\",\"addr\":\"${"a".repeat(ConcordWebxdc.MAX_NODE_ADDR_CHARS + 1)}\"}"))
assertNull(ConcordWebxdc.parsePeerSignal("{\"op\":\"join\",\"topic\":\"$topic\"}"))
}
@Test
fun theChatGateKeepsWebxdcOutOfChatRowsButThePlaneAdmitsIt() =
runTest {
val plane = ConcordKeyDerivation.groupKey(ConcordLabels.CHANNEL, ByteArray(32) { 9 }, channelId.hexToByteArray(), 0)
val rumor = ConcordWebxdc.stateUpdate(author.pubKey, channelId, 0L, "uuid-1", "{}", createdAt = 5L)
val wrap = ConcordStreamEnvelope.wrap(rumor, plane, author, encrypted = true, createdAt = 5L)
val opened = assertNotNull(ConcordStreamEnvelope.openOrNull(wrap, plane))
assertFalse(ChannelChat.isChatKind(ConcordWebxdc.KIND), "never a chat row")
assertNull(ChannelChat.acceptOpened(opened, channelId, 0L), "the chat gate refuses it")
assertEquals(rumor.id, ChannelChat.acceptOpened(opened, channelId, 0L, ChannelChat.PLANE_KINDS)?.id, "the plane carries it")
assertNull(ChannelChat.acceptOpened(opened, channelId, 1L, ChannelChat.PLANE_KINDS), "under the same strict binding")
}
}