mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-10-05 19:28:25 +00:00
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:
+4
-2
@@ -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 --------------------------------------------------------------
|
||||
|
||||
|
||||
+65
-5
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
+96
@@ -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
|
||||
|
||||
|
||||
+12
-2
@@ -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
|
||||
|
||||
+163
@@ -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))
|
||||
}
|
||||
+91
@@ -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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user