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 eaa665a5f8..6ed53eacf2 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 @@ -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 = ChannelChat.CHAT_KINDS, + ): Event? = ConcordStreamEnvelope.openOrNull(wrap, channel)?.let { ChannelChat.acceptOpened(it, channelId, epoch, kinds) } // ---- invites -------------------------------------------------------------- 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 5a4d14a407..1e7d8bf56e 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 @@ -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() 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>() + + private val _webxdcRevision = MutableStateFlow(0L) + + /** Bumps whenever a new WebXDC signal is held — what a WebXDC host re-reads [webxdcSignals] on. */ + val webxdcRevision: StateFlow = _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 = + 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 } } diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordWebxdcSessionTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordWebxdcSessionTest.kt new file mode 100644 index 0000000000..d00e5160ce --- /dev/null +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordWebxdcSessionTest.kt @@ -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() + 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) + } +} diff --git a/quartz/plans/2026-09-29-concord-spec-conformance.md b/quartz/plans/2026-09-29-concord-spec-conformance.md index 0ca24bf133..c4e5cf1680 100644 --- a/quartz/plans/2026-09-29-concord-spec-conformance.md +++ b/quartz/plans/2026-09-29-concord-spec-conformance.md @@ -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", ]`, `["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 diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord03Channels/ChannelChat.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord03Channels/ChannelChat.kt index 318dfff466..988c99ed7b 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord03Channels/ChannelChat.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord03Channels/ChannelChat.kt @@ -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 = 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 = 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 diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord03Channels/ConcordWebxdc.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord03Channels/ConcordWebxdc.kt new file mode 100644 index 0000000000..1bfdd76cdb --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord03Channels/ConcordWebxdc.kt @@ -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", ]` 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":}` 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> = arrayListOf(ChannelTag.assemble(channelId), EpochTag.assemble(epoch), MsTag.assemble(ms)) +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord03Channels/ConcordWebxdcTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord03Channels/ConcordWebxdcTest.kt new file mode 100644 index 0000000000..d25ca3bfb6 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord03Channels/ConcordWebxdcTest.kt @@ -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") + } +}