diff --git a/contextvm/build.gradle.kts b/contextvm/build.gradle.kts new file mode 100644 index 0000000000..a775d07763 --- /dev/null +++ b/contextvm/build.gradle.kts @@ -0,0 +1,105 @@ +/* + * 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. + */ +import org.jetbrains.kotlin.gradle.dsl.JvmTarget + +plugins { + alias(libs.plugins.kotlinMultiplatform) + alias(libs.plugins.androidKotlinMultiplatformLibrary) + alias(libs.plugins.serialization) +} + +kotlin { + jvm { + compilerOptions { + jvmTarget.set(JvmTarget.JVM_21) + } + } + + android { + namespace = "com.vitorpamplona.contextvm" + compileSdk = + libs.versions.android.compileSdk + .get() + .toInt() + minSdk = + libs.versions.android.minSdk + .get() + .toInt() + + compilerOptions { + jvmTarget.set(JvmTarget.JVM_21) + } + + withHostTest {} + } + + sourceSets { + commonMain { + dependencies { + implementation(libs.kotlin.stdlib) + implementation(libs.kotlinx.coroutines.core) + + // The JSON-RPC layer works on a JSON tree: MCP `params`/`result` are + // arbitrary and pass through us untouched. quartz keeps its own + // serialization dep `implementation`, so it is not transitive here. + implementation(libs.kotlinx.serialization.json) + + api(project(":quartz")) + } + } + + commonTest { + dependencies { + implementation(libs.kotlin.test) + implementation(libs.kotlinx.coroutines.test) + } + } + + val jvmAndroid = + create("jvmAndroid") { + dependsOn(commonMain.get()) + } + + jvmMain { + dependsOn(jvmAndroid) + } + + androidMain { + dependsOn(jvmAndroid) + } + + jvmTest { + dependencies { + implementation(libs.kotlin.test) + implementation(libs.kotlinx.coroutines.test) + implementation(libs.secp256k1.kmp.jni.jvm) + } + } + + getByName("androidHostTest") { + dependencies { + implementation(libs.kotlin.test) + implementation(libs.kotlinx.coroutines.test) + implementation(libs.secp256k1.kmp.jni.jvm) + } + } + } +} diff --git a/contextvm/src/commonMain/kotlin/com/vitorpamplona/contextvm/core/CvmKinds.kt b/contextvm/src/commonMain/kotlin/com/vitorpamplona/contextvm/core/CvmKinds.kt new file mode 100644 index 0000000000..d6ddbb6bc9 --- /dev/null +++ b/contextvm/src/commonMain/kotlin/com/vitorpamplona/contextvm/core/CvmKinds.kt @@ -0,0 +1,109 @@ +/* + * 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.contextvm.core + +import com.vitorpamplona.quartz.nip01Core.core.Kind +import com.vitorpamplona.quartz.nip01Core.core.isEphemeral + +/** + * Nostr event kinds used by ContextVM. + * + * Implemented from the ContextVM specification and its CEPs, not from any + * reference SDK source. See `quartz/plans/2026-09-17-cordn-interop.md` §6-§7 + * for the sourcing rule and the revision this targets. + */ +object CvmKinds { + /** + * The single kind carrying every ContextVM message. `content` is the + * stringified MCP JSON-RPC message; addressing and correlation live in tags. + * + * This kind is **ephemeral**, so relays are not expected to retain it. A + * client must already be subscribed when the peer publishes, because there + * is no fetch-after-the-fact recovery (rule `CVM-CORE-06`). + */ + const val MESSAGE: Kind = 25910 + + /** + * NIP-59 gift wrap carrying an encrypted [MESSAGE] (CEP-4). + * + * Not ephemeral: relays may retain the encrypted envelope. [EPHEMERAL_GIFT_WRAP] + * exists to avoid that. + */ + const val GIFT_WRAP: Kind = 1059 + + /** + * Ephemeral gift wrap (CEP-19) — identical structure and semantics to + * [GIFT_WRAP], but in NIP-01's ephemeral range so relays do not store the + * envelope either. + */ + const val EPHEMERAL_GIFT_WRAP: Kind = 21059 + + /** Addressable server announcement (CEP-6). `content` is the initialize result. */ + const val SERVER_ANNOUNCEMENT: Kind = 11316 + + /** Addressable `tools/list` announcement (CEP-6). */ + const val TOOLS_LIST: Kind = 11317 + + /** Addressable `resources/list` announcement (CEP-6). */ + const val RESOURCES_LIST: Kind = 11318 + + /** Addressable `resources/templates/list` announcement (CEP-6). */ + const val RESOURCE_TEMPLATES_LIST: Kind = 11319 + + /** Addressable `prompts/list` announcement (CEP-6). */ + const val PROMPTS_LIST: Kind = 11320 + + /** NIP-65 relay list metadata, reused by CEP-17 for server reachability. */ + const val RELAY_LIST: Kind = 10002 + + /** NIP-01 profile metadata, optionally published by servers (CEP-23). */ + const val PROFILE_METADATA: Kind = 0 + + /** NIP-22 comment, reused by CEP-24 for server reviews. */ + const val REVIEW: Kind = 1111 + + /** The announcement kinds a client subscribes to when discovering a server (CEP-6). */ + val ANNOUNCEMENTS = + intArrayOf( + SERVER_ANNOUNCEMENT, + TOOLS_LIST, + RESOURCES_LIST, + RESOURCE_TEMPLATES_LIST, + PROMPTS_LIST, + ) + + /** + * Both gift wrap kinds. A client subscribes to both regardless of which it + * sends: CEP-19 requires falling back to [GIFT_WRAP] for peers that do not + * advertise ephemeral support, so either may arrive. + */ + val GIFT_WRAPS = intArrayOf(GIFT_WRAP, EPHEMERAL_GIFT_WRAP) + + fun isGiftWrap(kind: Kind) = kind == GIFT_WRAP || kind == EPHEMERAL_GIFT_WRAP + + /** + * True when relays are not expected to retain this kind. + * + * [MESSAGE] and [EPHEMERAL_GIFT_WRAP] are ephemeral; [GIFT_WRAP] is not, + * which is the whole reason CEP-19 exists. + */ + fun isTransient(kind: Kind) = kind.isEphemeral() +} diff --git a/contextvm/src/commonMain/kotlin/com/vitorpamplona/contextvm/core/CvmMessageEvent.kt b/contextvm/src/commonMain/kotlin/com/vitorpamplona/contextvm/core/CvmMessageEvent.kt new file mode 100644 index 0000000000..41748946f3 --- /dev/null +++ b/contextvm/src/commonMain/kotlin/com/vitorpamplona/contextvm/core/CvmMessageEvent.kt @@ -0,0 +1,127 @@ +/* + * 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.contextvm.core + +import com.vitorpamplona.contextvm.jsonrpc.JsonRpcCodec +import com.vitorpamplona.contextvm.jsonrpc.JsonRpcMessage +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.Tag +import com.vitorpamplona.quartz.nip01Core.core.TagArray +import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate +import com.vitorpamplona.quartz.nip01Core.tags.events.ETag +import com.vitorpamplona.quartz.nip01Core.tags.people.PTag +import com.vitorpamplona.quartz.utils.TimeUtils + +/** + * A ContextVM message: kind 25910, carrying a stringified MCP JSON-RPC message + * in `content`. + * + * The ContextVM layering is deliberately thin — the MCP message is preserved + * byte-for-byte and only addressing and correlation move into tags: + * - `p` names the peer this message is for + * - `e` references the request event a response answers + * + * `content` is a **JSON string**, not an embedded JSON object. The spec's + * examples show it unstringified for readability, which is an easy trap; rule + * `CVM-CORE-02` asserts the stringified form. + * + * This kind is ephemeral, so a response can only be received by a subscription + * that was already live when the peer published it (`CVM-CORE-06`). + */ +class CvmMessageEvent( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: TagArray, + content: String, + sig: HexKey, +) : Event(id, pubKey, createdAt, KIND, tags, content, sig) { + /** The peer this message is addressed to, or null when untagged. */ + fun recipient(): HexKey? = tags.firstNotNullOfOrNull(PTag::parseKey) + + /** The request event id this message answers, or null when it is not a response. */ + fun inReplyTo(): HexKey? = tags.firstNotNullOfOrNull(ETag::parseId) + + /** + * The JSON-RPC message in `content`. + * + * @throws com.vitorpamplona.contextvm.jsonrpc.JsonRpcFormatException when + * `content` is not a well-formed JSON-RPC 2.0 message. + */ + fun message(): JsonRpcMessage = JsonRpcCodec.decode(content) + + /** + * The discovery tags this message carries, per CEP-35. + * + * Routing tags are excluded; everything else is preserved, including tags we + * do not understand, because CEP-35 makes forward compatibility the default. + */ + fun discoveryTags(): List = tags.filter { it.isNotEmpty() && !CvmTags.isRouting(it[0]) } + + companion object { + const val KIND = CvmKinds.MESSAGE + + /** + * Template for a message addressed to [recipient], optionally answering + * [inReplyTo]. + * + * [extraTags] carries this side's CEP-35 discovery tags on the first + * direct message of a session; omit them afterwards. + */ + fun build( + message: JsonRpcMessage, + recipient: HexKey, + inReplyTo: HexKey? = null, + extraTags: List = emptyList(), + createdAt: Long = TimeUtils.now(), + ) = eventTemplate(KIND, JsonRpcCodec.encode(message), createdAt) { + addAll(assembleTags(recipient, inReplyTo, extraTags)) + } + + suspend fun create( + message: JsonRpcMessage, + recipient: HexKey, + signer: NostrSigner, + inReplyTo: HexKey? = null, + extraTags: List = emptyList(), + createdAt: Long = TimeUtils.now(), + ): CvmMessageEvent = + signer.sign( + createdAt, + KIND, + assembleTags(recipient, inReplyTo, extraTags), + JsonRpcCodec.encode(message), + ) + + private fun assembleTags( + recipient: HexKey, + inReplyTo: HexKey?, + extraTags: List, + ): TagArray = + buildList { + add(PTag.assemble(recipient, relayHint = null)) + inReplyTo?.let { add(ETag.assemble(it, relay = null, author = null)) } + addAll(extraTags) + }.toTypedArray() + } +} diff --git a/contextvm/src/commonMain/kotlin/com/vitorpamplona/contextvm/core/CvmTags.kt b/contextvm/src/commonMain/kotlin/com/vitorpamplona/contextvm/core/CvmTags.kt new file mode 100644 index 0000000000..1256253a26 --- /dev/null +++ b/contextvm/src/commonMain/kotlin/com/vitorpamplona/contextvm/core/CvmTags.kt @@ -0,0 +1,119 @@ +/* + * 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.contextvm.core + +import com.vitorpamplona.quartz.nip01Core.core.Tag +import com.vitorpamplona.quartz.nip01Core.tags.events.ETag +import com.vitorpamplona.quartz.nip01Core.tags.people.PTag + +/** + * ContextVM tag vocabulary. + * + * Addressing (`p`) and correlation (`e`) reuse the NIP-01 tags quartz already + * models — [PTag] and [ETag] — because ContextVM assigns them their ordinary + * NIP-01 meanings. Only the ContextVM-specific names are defined here. + */ +object CvmTags { + /** Recipient public key. Same meaning as NIP-01; parse with [PTag]. */ + const val PUBKEY = PTag.TAG_NAME + + /** Correlates a response to its request event. Same meaning as NIP-01; parse with [ETag]. */ + const val EVENT_ID = ETag.TAG_NAME + + /** Relay URL hint (CEP-17 reuses the NIP-65 `r` tag). */ + const val RELAY = "r" + + // --- CEP-6 server identity metadata --- + + const val NAME = "name" + const val ABOUT = "about" + const val PICTURE = "picture" + const val WEBSITE = "website" + + // --- Transport capability advertisement --- + + /** CEP-4: presence alone indicates the peer supports encrypted messages. */ + const val SUPPORT_ENCRYPTION = "support_encryption" + + /** CEP-19: presence indicates the peer accepts kind 21059 gift wraps. */ + const val SUPPORT_ENCRYPTION_EPHEMERAL = "support_encryption_ephemeral" + + /** CEP-22: presence indicates support for bounded oversized payload transfer. */ + const val SUPPORT_OVERSIZED_TRANSFER = "support_oversized_transfer" + + /** CEP-41: presence indicates support for open-ended streams. */ + const val SUPPORT_OPEN_STREAM = "support_open_stream" + + // --- CEP-8 pricing and payment --- + + /** `["cap", "", "", ""]`. */ + const val CAPABILITY_PRICE = "cap" + + /** `["pmi", ""]`. */ + const val PAYMENT_METHOD = "pmi" + + /** `["payment_interaction", "transparent"|"explicit_gating"]`. */ + const val PAYMENT_INTERACTION = "payment_interaction" + + /** `["direct_payment", "", ""]` — bearer-asset optimization. */ + const val DIRECT_PAYMENT = "direct_payment" + + /** `["change", "", ""]` — overpayment remainder. */ + const val CHANGE = "change" + + // --- CEP-15 common tool schemas (NIP-73 external identity tags) --- + + /** `["i", "", ""]`. */ + const val EXTERNAL_ID = "i" + + /** `["k", "io.contextvm/common-schema"]`. */ + const val EXTERNAL_KIND = "k" + + /** The NIP-73 kind value CEP-15 uses in its [EXTERNAL_KIND] tag. */ + const val COMMON_SCHEMA_NAMESPACE = "io.contextvm/common-schema" + + /** + * Tags that carry routing rather than discovery information. + * + * CEP-35 requires unknown discovery tags to be preserved, but says routing + * tags SHOULD be excluded from the learned discovery surface. Keeping the + * exclusion set explicit (rather than an allow-list of known discovery tags) + * is what makes forward compatibility work: a tag we have never heard of is + * preserved by default instead of dropped. + */ + val ROUTING = setOf(PUBKEY, EVENT_ID) + + fun isRouting(tagName: String) = tagName in ROUTING + + /** A valueless capability tag, e.g. `["support_encryption"]`. */ + fun flag(name: String): Tag = arrayOf(name) + + /** + * True when [tags] contains the capability flag [name]. + * + * A flag is signalled by presence, so a tag carrying extra elements still + * counts — the spec defines meaning by the tag name, not by arity. + */ + fun hasFlag( + tags: Array, + name: String, + ) = tags.any { it.isNotEmpty() && it[0] == name } +} diff --git a/contextvm/src/commonMain/kotlin/com/vitorpamplona/contextvm/jsonrpc/JsonRpcCodec.kt b/contextvm/src/commonMain/kotlin/com/vitorpamplona/contextvm/jsonrpc/JsonRpcCodec.kt new file mode 100644 index 0000000000..63d60547b0 --- /dev/null +++ b/contextvm/src/commonMain/kotlin/com/vitorpamplona/contextvm/jsonrpc/JsonRpcCodec.kt @@ -0,0 +1,196 @@ +/* + * 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.contextvm.jsonrpc + +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.jsonPrimitive +import kotlinx.serialization.json.longOrNull + +/** + * Encodes and decodes the JSON-RPC 2.0 messages carried in a ContextVM event's + * `content`. + * + * Decoding is deliberately strict. ContextVM's only framing is "the content is a + * JSON-RPC message", so a malformed payload has to be rejected here or it becomes + * a confusing failure several layers up. Every rejection below is a rule in + * `quartz/plans/2026-09-17-cordn-interop.md` §6.5 (`CVM-CORE-*`) and has a test. + */ +object JsonRpcCodec { + private const val JSONRPC = "jsonrpc" + private const val ID = "id" + private const val METHOD = "method" + private const val PARAMS = "params" + private const val RESULT = "result" + private const val ERROR = "error" + private const val CODE = "code" + private const val MESSAGE = "message" + private const val DATA = "data" + + /** + * Lenient only about *unknown* members: MCP grows fields, and CEP-35 tells us + * to preserve what we do not understand rather than fail. The structural + * checks in [decode] are not relaxed. + */ + private val json = Json { ignoreUnknownKeys = true } + + fun encode(message: JsonRpcMessage): String = json.encodeToString(JsonObject.serializer(), toJsonObject(message)) + + fun toJsonObject(message: JsonRpcMessage): JsonObject = + buildJsonObject { + put(JSONRPC, JsonPrimitive(JsonRpcMessage.VERSION)) + when (message) { + is JsonRpcRequest -> { + put(ID, message.id.toPrimitive()) + put(METHOD, JsonPrimitive(message.method)) + message.params?.let { put(PARAMS, it) } + } + + is JsonRpcNotification -> { + put(METHOD, JsonPrimitive(message.method)) + message.params?.let { put(PARAMS, it) } + } + + is JsonRpcSuccess -> { + put(ID, message.id.toPrimitive()) + put(RESULT, message.result) + } + + is JsonRpcFailure -> { + put(ID, message.id?.toPrimitive() ?: JsonPrimitive(null as String?)) + put( + ERROR, + buildJsonObject { + put(CODE, JsonPrimitive(message.error.code)) + put(MESSAGE, JsonPrimitive(message.error.message)) + message.error.data?.let { put(DATA, it) } + }, + ) + } + } + } + + fun decode(text: String): JsonRpcMessage { + val root = + try { + json.parseToJsonElement(text) + } catch (e: IllegalArgumentException) { + throw JsonRpcFormatException("content is not valid JSON: ${e.message}") + } + + if (root !is JsonObject) throw JsonRpcFormatException("JSON-RPC message must be an object") + return decode(root) + } + + fun decode(root: JsonObject): JsonRpcMessage { + val version = root[JSONRPC]?.asStringOrNull() + if (version != JsonRpcMessage.VERSION) { + throw JsonRpcFormatException("unsupported jsonrpc version: $version") + } + + val hasMethod = root.containsKey(METHOD) + val hasResult = root.containsKey(RESULT) + val hasError = root.containsKey(ERROR) + + // A response is exactly one of result or error. Carrying both is + // ambiguous about whether the call succeeded, so it cannot be repaired + // by preferring one -- reject it. + if (hasResult && hasError) { + throw JsonRpcFormatException("response carries both result and error") + } + if (hasMethod && (hasResult || hasError)) { + throw JsonRpcFormatException("message carries both a method and a response body") + } + + // `id` may legitimately be JSON null on a failure, so distinguish + // "absent" (a notification) from "present but null". + val idElement = root[ID] + val id = if (idElement == null || idElement.isJsonNull()) null else parseId(idElement) + + return when { + hasMethod -> { + val method = + root[METHOD]?.asStringOrNull() + ?: throw JsonRpcFormatException("method must be a string") + val params = root[PARAMS]?.let { requireObject(it, PARAMS) } + if (id == null) { + JsonRpcNotification(method, params) + } else { + JsonRpcRequest(id, method, params) + } + } + + hasResult -> { + if (id == null) throw JsonRpcFormatException("success response requires an id") + JsonRpcSuccess(id, root.getValue(RESULT)) + } + + hasError -> JsonRpcFailure(id, parseError(root.getValue(ERROR))) + + else -> throw JsonRpcFormatException("message has no method, result or error") + } + } + + private fun parseError(element: JsonElement): JsonRpcError { + val obj = requireObject(element, ERROR) + val code = + obj[CODE]?.jsonPrimitive?.longOrNull + ?: throw JsonRpcFormatException("error.code must be a number") + val message = + obj[MESSAGE]?.asStringOrNull() + ?: throw JsonRpcFormatException("error.message must be a string") + return JsonRpcError(code.toInt(), message, obj[DATA]) + } + + private fun parseId(element: JsonElement): JsonRpcId { + val primitive = + (element as? JsonPrimitive) + ?: throw JsonRpcFormatException("id must be a string or a number") + + if (primitive.isString) return JsonRpcId.Text(primitive.content) + + return primitive.longOrNull?.let { JsonRpcId.Num(it) } + ?: throw JsonRpcFormatException("id must be a string or an integral number") + } + + private fun requireObject( + element: JsonElement, + field: String, + ): JsonObject = + element as? JsonObject + ?: throw JsonRpcFormatException("$field must be an object") + + private fun JsonRpcId.toPrimitive() = + when (this) { + is JsonRpcId.Num -> JsonPrimitive(value) + is JsonRpcId.Text -> JsonPrimitive(value) + } + + private fun JsonElement.isJsonNull() = this is JsonPrimitive && !isString && content == "null" + + private fun JsonElement.asStringOrNull(): String? { + val primitive = this as? JsonPrimitive ?: return null + return if (primitive.isString) primitive.content else null + } +} diff --git a/contextvm/src/commonMain/kotlin/com/vitorpamplona/contextvm/jsonrpc/JsonRpcMessage.kt b/contextvm/src/commonMain/kotlin/com/vitorpamplona/contextvm/jsonrpc/JsonRpcMessage.kt new file mode 100644 index 0000000000..fb1b659580 --- /dev/null +++ b/contextvm/src/commonMain/kotlin/com/vitorpamplona/contextvm/jsonrpc/JsonRpcMessage.kt @@ -0,0 +1,121 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.contextvm.jsonrpc + +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonObject + +/** + * A JSON-RPC 2.0 message, as carried in a ContextVM event's `content`. + * + * ContextVM transports MCP messages unmodified, so this models JSON-RPC itself + * rather than any MCP-specific shape: `params` and `result` stay as a JSON tree + * and pass through untouched. MCP semantics live a layer up. + */ +sealed interface JsonRpcMessage { + companion object { + /** The only version ContextVM carries. Decoding rejects anything else. */ + const val VERSION = "2.0" + } +} + +/** + * A JSON-RPC message id. + * + * JSON-RPC allows a string or a number, and MCP implementations use both — the + * TypeScript SDK numbers its requests while others use strings. Modelling the + * distinction (rather than normalising to String) keeps decode/encode a faithful + * round-trip, which rule `CVM-CORE-01` asserts. + * + * It also matters for CEP-8: the canonical invocation identity deliberately + * excludes the id, so a retry may legitimately change both its type and value + * and still match a paid authorization. + */ +sealed interface JsonRpcId { + data class Num( + val value: Long, + ) : JsonRpcId + + data class Text( + val value: String, + ) : JsonRpcId +} + +/** A call expecting exactly one matching [JsonRpcSuccess] or [JsonRpcFailure]. */ +data class JsonRpcRequest( + val id: JsonRpcId, + val method: String, + val params: JsonObject? = null, +) : JsonRpcMessage + +/** + * A one-way message with no id and no response. + * + * CEP-22 and CEP-41 both ride `notifications/progress` notifications, so this is + * the carrier for every transfer frame as well as for ordinary MCP notifications. + */ +data class JsonRpcNotification( + val method: String, + val params: JsonObject? = null, +) : JsonRpcMessage + +/** A successful response. `result` is opaque to this layer. */ +data class JsonRpcSuccess( + val id: JsonRpcId, + val result: JsonElement, +) : JsonRpcMessage + +/** + * An error response. + * + * [id] is nullable because JSON-RPC permits a null id when the request could not + * be parsed well enough to recover one. + */ +data class JsonRpcFailure( + val id: JsonRpcId?, + val error: JsonRpcError, +) : JsonRpcMessage + +data class JsonRpcError( + val code: Int, + val message: String, + val data: JsonElement? = null, +) { + companion object { + // JSON-RPC 2.0 reserved codes. + const val PARSE_ERROR = -32700 + const val INVALID_REQUEST = -32600 + const val METHOD_NOT_FOUND = -32601 + const val INVALID_PARAMS = -32602 + const val INTERNAL_ERROR = -32603 + + /** CEP-8 `explicit_gating`: payment is required before the call will run. */ + const val PAYMENT_REQUIRED = -32042 + + /** CEP-8 `explicit_gating`: payment is in flight but not yet verified. */ + const val PAYMENT_PENDING = -32043 + } +} + +/** Thrown when a payload is not a well-formed JSON-RPC 2.0 message. */ +class JsonRpcFormatException( + message: String, +) : IllegalArgumentException(message) diff --git a/contextvm/src/commonTest/kotlin/com/vitorpamplona/contextvm/core/CvmMessageEventTest.kt b/contextvm/src/commonTest/kotlin/com/vitorpamplona/contextvm/core/CvmMessageEventTest.kt new file mode 100644 index 0000000000..42ed85a9ea --- /dev/null +++ b/contextvm/src/commonTest/kotlin/com/vitorpamplona/contextvm/core/CvmMessageEventTest.kt @@ -0,0 +1,140 @@ +/* + * 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.contextvm.core + +import com.vitorpamplona.contextvm.jsonrpc.JsonRpcCodec +import com.vitorpamplona.contextvm.jsonrpc.JsonRpcId +import com.vitorpamplona.contextvm.jsonrpc.JsonRpcRequest +import com.vitorpamplona.quartz.nip01Core.core.isEphemeral +import kotlin.test.Test +import kotlin.test.assertContentEquals +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * `CVM-CORE-02..06`: the kind 25910 envelope — content shape, addressing, + * correlation and the ephemeral-delivery consequence. + */ +class CvmMessageEventTest { + private val serverPubKey = "a".repeat(64) + private val requestEventId = "b".repeat(64) + + private val ping = JsonRpcRequest(JsonRpcId.Num(1), "ping") + + @Test + fun `CVM-CORE-02 content is a stringified JSON-RPC message, not an embedded object`() { + val template = CvmMessageEvent.build(ping, serverPubKey) + + // The spec's examples print `content` unstringified for readability, + // which is the trap this asserts against: content is a String field + // whose own text is the JSON-RPC message. + assertEquals("""{"jsonrpc":"2.0","id":1,"method":"ping"}""", template.content) + + // And it must survive the trip back through the codec. + assertEquals(ping, JsonRpcCodec.decode(template.content)) + } + + @Test + fun `CVM-CORE-03 addresses the peer with a p tag`() { + val template = CvmMessageEvent.build(ping, serverPubKey) + assertContentEquals(arrayOf("p", serverPubKey), template.tags.first()) + } + + @Test + fun `CVM-CORE-04 correlates a response with an e tag naming the request event`() { + val template = CvmMessageEvent.build(ping, serverPubKey, inReplyTo = requestEventId) + val eTag = template.tags.first { it[0] == "e" } + assertContentEquals(arrayOf("e", requestEventId), eTag) + } + + @Test + fun `CVM-CORE-04 omits the e tag when the message is not a response`() { + val template = CvmMessageEvent.build(ping, serverPubKey) + assertFalse(template.tags.any { it.isNotEmpty() && it[0] == "e" }) + } + + @Test + fun `CVM-CORE-03 reads addressing and correlation back off a parsed event`() { + val event = event(CvmMessageEvent.build(ping, serverPubKey, inReplyTo = requestEventId).tags) + + assertEquals(serverPubKey, event.recipient()) + assertEquals(requestEventId, event.inReplyTo()) + assertEquals(ping, event.message()) + } + + @Test + fun `CVM-CORE-03 tolerates an unaddressed event rather than throwing`() { + val event = event(emptyArray()) + assertNull(event.recipient()) + assertNull(event.inReplyTo()) + } + + @Test + fun `CVM-CORE-06 kind 25910 is ephemeral, so delivery has no replay`() { + // Consequence, not decoration: relays do not retain this kind, so a + // subscription must be live before the peer publishes. The transport's + // request API is built around this and the property is worth pinning. + assertTrue(CvmKinds.MESSAGE.isEphemeral()) + assertTrue(CvmKinds.isTransient(CvmKinds.MESSAGE)) + assertTrue(CvmKinds.isTransient(CvmKinds.EPHEMERAL_GIFT_WRAP)) + + // The CEP-19 motivation: the persistent wrap is *not* ephemeral, which + // is exactly why 21059 exists. + assertFalse(CvmKinds.isTransient(CvmKinds.GIFT_WRAP)) + } + + @Test + fun `CVM-35 discovery tags exclude routing tags but keep unknown ones`() { + val template = + CvmMessageEvent.build( + ping, + serverPubKey, + inReplyTo = requestEventId, + extraTags = + listOf( + CvmTags.flag(CvmTags.SUPPORT_ENCRYPTION), + arrayOf("some_future_tag", "value"), + ), + ) + + val discovery = event(template.tags).discoveryTags().map { it[0] } + + assertFalse(discovery.contains("p"), "p is routing, not discovery") + assertFalse(discovery.contains("e"), "e is routing, not discovery") + assertTrue(discovery.contains(CvmTags.SUPPORT_ENCRYPTION)) + assertTrue( + discovery.contains("some_future_tag"), + "CEP-35 requires unknown discovery tags to be preserved", + ) + } + + private fun event(tags: Array>) = + CvmMessageEvent( + id = "c".repeat(64), + pubKey = "d".repeat(64), + createdAt = 1_700_000_000L, + tags = tags, + content = JsonRpcCodec.encode(ping), + sig = "e".repeat(128), + ) +} diff --git a/contextvm/src/commonTest/kotlin/com/vitorpamplona/contextvm/jsonrpc/JsonRpcCodecTest.kt b/contextvm/src/commonTest/kotlin/com/vitorpamplona/contextvm/jsonrpc/JsonRpcCodecTest.kt new file mode 100644 index 0000000000..064809da79 --- /dev/null +++ b/contextvm/src/commonTest/kotlin/com/vitorpamplona/contextvm/jsonrpc/JsonRpcCodecTest.kt @@ -0,0 +1,236 @@ +/* + * 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.contextvm.jsonrpc + +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * `CVM-CORE-01`: the JSON-RPC layer round-trips every message class faithfully, + * and rejects payloads that are not well-formed JSON-RPC 2.0. + * + * Rule ids come from `quartz/plans/2026-09-17-cordn-interop.md` §6.5. When a CEP + * revises, the failing test names say what changed. + */ +class JsonRpcCodecTest { + private val toolsCall = + JsonRpcRequest( + id = JsonRpcId.Num(2), + method = "tools/call", + params = + buildJsonObject { + put("name", JsonPrimitive("kp_publish")) + put( + "arguments", + buildJsonObject { + put("kp_ref", JsonPrimitive("abc")) + put("kp_64", JsonPrimitive("BASE64")) + }, + ) + }, + ) + + @Test + fun `CVM-CORE-01 round-trips a request`() { + assertEquals(toolsCall, JsonRpcCodec.decode(JsonRpcCodec.encode(toolsCall))) + } + + @Test + fun `CVM-CORE-01 round-trips a notification`() { + val notification = + JsonRpcNotification( + method = "notifications/progress", + params = + buildJsonObject { + put("progressToken", JsonPrimitive("req-123")) + put("progress", JsonPrimitive(1)) + }, + ) + assertEquals(notification, JsonRpcCodec.decode(JsonRpcCodec.encode(notification))) + } + + @Test + fun `CVM-CORE-01 round-trips a success response`() { + val success = + JsonRpcSuccess( + id = JsonRpcId.Num(2), + result = buildJsonObject { put("cursor", JsonPrimitive(7)) }, + ) + assertEquals(success, JsonRpcCodec.decode(JsonRpcCodec.encode(success))) + } + + @Test + fun `CVM-CORE-01 round-trips an error response with data`() { + val failure = + JsonRpcFailure( + id = JsonRpcId.Num(2), + error = + JsonRpcError( + code = JsonRpcError.PAYMENT_REQUIRED, + message = "Payment Required", + data = buildJsonObject { put("instructions", JsonPrimitive("pay then retry")) }, + ), + ) + assertEquals(failure, JsonRpcCodec.decode(JsonRpcCodec.encode(failure))) + } + + @Test + fun `CVM-CORE-01 preserves a string id distinctly from a numeric one`() { + // MCP implementations use both. Normalising to String would make a + // string "2" and a numeric 2 indistinguishable on the wire, so the two + // must stay separate types through a round trip. + val text = toolsCall.copy(id = JsonRpcId.Text("2")) + val encodedText = JsonRpcCodec.encode(text) + val encodedNum = JsonRpcCodec.encode(toolsCall) + + assertTrue(encodedText.contains("\"id\":\"2\""), "string id must encode quoted: $encodedText") + assertTrue(encodedNum.contains("\"id\":2"), "numeric id must encode bare: $encodedNum") + assertEquals(text, JsonRpcCodec.decode(encodedText)) + assertEquals(toolsCall, JsonRpcCodec.decode(encodedNum)) + } + + @Test + fun `CVM-CORE-01 omits absent params rather than emitting null`() { + val encoded = JsonRpcCodec.encode(JsonRpcNotification("notifications/initialized")) + assertEquals("""{"jsonrpc":"2.0","method":"notifications/initialized"}""", encoded) + } + + @Test + fun `CVM-CORE-01 keeps an unknown member out of the way of decoding`() { + // MCP grows fields and CEP-35 tells us to tolerate what we do not know. + val decoded = + JsonRpcCodec.decode( + """{"jsonrpc":"2.0","id":1,"method":"ping","futureField":{"x":1}}""", + ) + assertEquals(JsonRpcRequest(JsonRpcId.Num(1), "ping"), decoded) + } + + @Test + fun `CVM-CORE-01 accepts a null id on an error response`() { + // JSON-RPC allows a null id when the request could not be parsed. + val decoded = + JsonRpcCodec.decode( + """{"jsonrpc":"2.0","id":null,"error":{"code":-32700,"message":"Parse error"}}""", + ) + assertTrue(decoded is JsonRpcFailure) + assertNull(decoded.id) + assertEquals(JsonRpcError.PARSE_ERROR, decoded.error.code) + } + + @Test + fun `CVM-CORE-01 treats a method without an id as a notification`() { + val decoded = JsonRpcCodec.decode("""{"jsonrpc":"2.0","method":"notifications/initialized"}""") + assertEquals(JsonRpcNotification("notifications/initialized"), decoded) + } + + @Test + fun `CVM-CORE-01 preserves the params tree untouched`() { + // ContextVM transports MCP unmodified, so anything inside params has to + // survive verbatim -- including nesting we assign no meaning to. + val decoded = JsonRpcCodec.decode(JsonRpcCodec.encode(toolsCall)) as JsonRpcRequest + val arguments = decoded.params!!["arguments"]!!.jsonObject + assertEquals("BASE64", arguments["kp_64"]!!.jsonPrimitive.content) + } + + // --- rejections: CVM-CORE-05 --- + + @Test + fun `CVM-CORE-05 rejects a wrong jsonrpc version`() { + assertFailsWith { + JsonRpcCodec.decode("""{"jsonrpc":"1.0","id":1,"method":"ping"}""") + } + } + + @Test + fun `CVM-CORE-05 rejects a missing jsonrpc member`() { + assertFailsWith { + JsonRpcCodec.decode("""{"id":1,"method":"ping"}""") + } + } + + @Test + fun `CVM-CORE-05 rejects a response carrying both result and error`() { + assertFailsWith { + JsonRpcCodec.decode( + """{"jsonrpc":"2.0","id":1,"result":{},"error":{"code":-1,"message":"x"}}""", + ) + } + } + + @Test + fun `CVM-CORE-05 rejects a message with neither method nor result nor error`() { + assertFailsWith { + JsonRpcCodec.decode("""{"jsonrpc":"2.0","id":1}""") + } + } + + @Test + fun `CVM-CORE-05 rejects a method combined with a response body`() { + assertFailsWith { + JsonRpcCodec.decode("""{"jsonrpc":"2.0","id":1,"method":"ping","result":{}}""") + } + } + + @Test + fun `CVM-CORE-05 rejects a success response without an id`() { + assertFailsWith { + JsonRpcCodec.decode("""{"jsonrpc":"2.0","result":{}}""") + } + } + + @Test + fun `CVM-CORE-05 rejects a non-object payload`() { + assertFailsWith { JsonRpcCodec.decode("""["jsonrpc","2.0"]""") } + } + + @Test + fun `CVM-CORE-05 rejects malformed JSON`() { + assertFailsWith { JsonRpcCodec.decode("""{"jsonrpc":"2.0",""") } + } + + @Test + fun `CVM-CORE-05 rejects a fractional id`() { + assertFailsWith { + JsonRpcCodec.decode("""{"jsonrpc":"2.0","id":1.5,"method":"ping"}""") + } + } + + @Test + fun `CVM-CORE-05 rejects non-object params`() { + assertFailsWith { + JsonRpcCodec.decode("""{"jsonrpc":"2.0","id":1,"method":"ping","params":[1,2]}""") + } + } + + @Test + fun `CVM-CORE-05 rejects an error object missing its code`() { + assertFailsWith { + JsonRpcCodec.decode("""{"jsonrpc":"2.0","id":1,"error":{"message":"x"}}""") + } + } +} diff --git a/settings.gradle.kts b/settings.gradle.kts index ebc38ddb98..793e57f94d 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -38,6 +38,7 @@ include(":geode") include(":commons") include(":commonsUI") include(":quic") +include(":contextvm") include(":nestsClient") include(":marmotQuic") include(":desktopApp")