mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-10-06 11:48:24 +00:00
feat(marmot): send the canonical unsigned app-payload shape
Marmot app payloads are a Nostr event MINUS the signature, and we were sending them WITH one. A conformant decoder rejects a payload carrying a `sig` member at all, so every message we sent was refusable by any peer following the adopted spec — and a signed inner event is a valid standalone relay event, so one leaked plaintext could be republished publicly as a signed statement by its author. `MarmotAppEvent` is that shape, with the strict decoder the spec requires. Each rejection closes a different hole: a `sig` member for the reason above; an unknown top-level member, because two implementations that disagree about what to ignore disagree about the id preimage; a duplicate key, because "last one wins" and "first one wins" are both defensible and yield different events from identical bytes; and a mismatched id, because the id is what edits, history and dedup all reference. Duplicate-key detection needed its own scan. Every JSON library here resolves duplicates before the caller sees them, so `MarmotJson` walks the raw text tracking nesting depth and string boundaries — it has to be right about exactly one thing, where a top-level key sits. The id is unchanged by the switch. NIP-01 hashes [0, pubkey, created_at, kind, tags, content], which never covered the signature, so message identity survives and existing history still lines up. The Android pipeline already treated inner events as unsigned rumors with an empty sig and skipped verification, so the app layer needs no change: the empty `sig` is re-added at the inbound boundary instead of travelling on the wire. Also adds the two Stage 7 kinds. Kind 1009 edits carry the deterministic tie-break the spec implies but does not spell out — two devices of one account can stamp the same second, and without it two readers would render different text for the same message forever. Kind 1210 system rows are synthesized from canonical state rather than received, which is what makes them unforgeable by a single member. Verified against the spec's published fixture: our canonical serialization hashes to the exact event id the spec prints for its kind 1210 example. That is the only check that distinguishes correct from merely self-consistent. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016kCuA6tc4JQzHPCDd39GHq
This commit is contained in:
+45
-14
@@ -20,6 +20,7 @@
|
||||
*/
|
||||
package com.vitorpamplona.quartz.marmot
|
||||
|
||||
import com.vitorpamplona.quartz.marmot.foundation.appEvents.MarmotAppEvent
|
||||
import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageRotationManager
|
||||
import com.vitorpamplona.quartz.marmot.mip02Welcome.WelcomeEvent
|
||||
import com.vitorpamplona.quartz.marmot.mip03GroupMessages.GroupEvent
|
||||
@@ -539,20 +540,20 @@ class MarmotInboundProcessor(
|
||||
// canonical epoch by construction.
|
||||
processCandidateBranchMessage(groupId, bytes)
|
||||
} else {
|
||||
val innerJson = decrypted.content.decodeToString()
|
||||
val payload = decrypted.content.decodeToString()
|
||||
val author = payloadAuthor(payload)
|
||||
|
||||
// MIP-03: if the inner application payload is a Nostr event,
|
||||
// its `pubkey` field MUST equal the MLS sender's credential
|
||||
// identity. Reject any mismatch — otherwise a group member
|
||||
// could mint events claiming a different author. Non-event
|
||||
// payloads (raw bytes via buildGroupEventFromBytes) bypass
|
||||
// this check since there is no author field to verify.
|
||||
// `foundation/application-messages.md`, "Receiver
|
||||
// authentication": the inner author MUST equal the account
|
||||
// the MLS sender leaf authenticates. Without it any member
|
||||
// could mint messages attributed to anyone else in the
|
||||
// group. Payloads with no author field at all (raw bytes
|
||||
// via buildGroupEventFromBytes) have nothing to compare.
|
||||
val senderIdentity = groupManager.memberIdentityHex(groupId, decrypted.senderLeafIndex)
|
||||
val innerEvent = Event.fromJsonOrNull(innerJson)
|
||||
if (innerEvent != null && (senderIdentity == null || innerEvent.pubKey != senderIdentity)) {
|
||||
if (author != null && (senderIdentity == null || author != senderIdentity)) {
|
||||
return GroupEventResult.Error(
|
||||
groupId,
|
||||
"MIP-03: inner event pubkey (${innerEvent.pubKey}) does not match MLS sender identity ($senderIdentity)",
|
||||
"inner event pubkey ($author) does not match MLS sender identity ($senderIdentity)",
|
||||
)
|
||||
}
|
||||
|
||||
@@ -561,13 +562,13 @@ class MarmotInboundProcessor(
|
||||
// incumbent is rebuilt and rescored at every resolution, so
|
||||
// counting only divergent branches would let any fork win
|
||||
// the witness steps unopposed.
|
||||
if (innerEvent != null && senderIdentity != null) {
|
||||
if (author != null && senderIdentity != null) {
|
||||
convergence.recordCanonicalWitness(groupId, decrypted.epoch, senderIdentity)
|
||||
}
|
||||
|
||||
GroupEventResult.ApplicationMessage(
|
||||
groupId = groupId,
|
||||
innerEventJson = innerJson,
|
||||
innerEventJson = asEventShapedJson(payload),
|
||||
senderLeafIndex = decrypted.senderLeafIndex,
|
||||
epoch = decrypted.epoch,
|
||||
)
|
||||
@@ -624,6 +625,36 @@ class MarmotInboundProcessor(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The account a payload claims as its author, or null when it has none.
|
||||
*
|
||||
* The canonical shape is tried first, because that is what a conformant
|
||||
* peer sends and its checks are the strict ones. The legacy fall-back
|
||||
* exists only for payloads this client itself wrote before the switch to
|
||||
* the unsigned shape: those carry a `sig` member, which the strict decoder
|
||||
* refuses by design. It is deliberately not a general "accept anything"
|
||||
* path — a payload that is neither shape still has no author, and still
|
||||
* fails the comparison rather than passing it.
|
||||
*/
|
||||
private fun payloadAuthor(payload: String): HexKey? =
|
||||
MarmotAppEvent.decodeOrNull(payload)?.pubKey
|
||||
?: Event.fromJsonOrNull(payload)?.pubKey
|
||||
|
||||
/**
|
||||
* Re-shape a canonical payload into the Event-shaped JSON the app layer
|
||||
* consumes.
|
||||
*
|
||||
* The application pipeline is built around `Event`, which requires a `sig`
|
||||
* member; the wire form must not carry one. Rather than force every
|
||||
* consumer to learn a second shape, the empty signature is re-added here at
|
||||
* the boundary. The id is unaffected either way — NIP-01 never hashed the
|
||||
* signature — so a message keeps one identity across the conversion.
|
||||
*/
|
||||
private fun asEventShapedJson(payload: String): String {
|
||||
val appEvent = MarmotAppEvent.decodeOrNull(payload) ?: return payload
|
||||
return appEvent.toJson().dropLast(1) + ",\"sig\":\"\"}"
|
||||
}
|
||||
|
||||
/**
|
||||
* Try an app message against the retained candidate branches.
|
||||
*
|
||||
@@ -646,9 +677,9 @@ class MarmotInboundProcessor(
|
||||
"Application message decrypts on no canonical epoch or retained candidate branch",
|
||||
)
|
||||
|
||||
val innerEvent = Event.fromJsonOrNull(candidate.content.decodeToString())
|
||||
val author = payloadAuthor(candidate.content.decodeToString())
|
||||
val sender = candidate.senderAccount
|
||||
val valid = innerEvent != null && sender != null && innerEvent.pubKey == sender
|
||||
val valid = author != null && sender != null && author == sender
|
||||
if (valid && sender != null) {
|
||||
convergence.recordWitness(groupId, candidate.stateId, sender)
|
||||
}
|
||||
|
||||
+23
-1
@@ -20,6 +20,7 @@
|
||||
*/
|
||||
package com.vitorpamplona.quartz.marmot
|
||||
|
||||
import com.vitorpamplona.quartz.marmot.foundation.appEvents.MarmotAppEvent
|
||||
import com.vitorpamplona.quartz.marmot.mip01Groups.MarmotGroupData
|
||||
import com.vitorpamplona.quartz.marmot.mip03GroupMessages.GroupEvent
|
||||
import com.vitorpamplona.quartz.marmot.mip03GroupMessages.GroupEventEncryption
|
||||
@@ -84,7 +85,28 @@ class MarmotOutboundProcessor(
|
||||
suspend fun buildGroupEvent(
|
||||
nostrGroupId: HexKey,
|
||||
innerEvent: Event,
|
||||
): OutboundGroupEvent = buildGroupEventFromBytes(nostrGroupId, innerEvent.toJson().encodeToByteArray())
|
||||
): OutboundGroupEvent = buildAppEvent(nostrGroupId, MarmotAppEvent.fromEvent(innerEvent))
|
||||
|
||||
/**
|
||||
* Send a Marmot app event — the canonical, UNSIGNED payload shape
|
||||
* (`foundation/application-messages.md`).
|
||||
*
|
||||
* The signature is dropped rather than merely left empty, and both halves
|
||||
* of that matter. A conformant decoder REJECTS a payload carrying a `sig`
|
||||
* member at all, so an event serialized with `"sig":""` is refused by every
|
||||
* peer; and a payload with a real signature would be a valid standalone
|
||||
* relay event, so one leaked plaintext could be republished publicly as a
|
||||
* signed statement by its author.
|
||||
*
|
||||
* The event id is unchanged by the conversion: NIP-01 hashes
|
||||
* `[0, pubkey, created_at, kind, tags, content]`, which never included the
|
||||
* signature. Message identity therefore survives the switch, and history
|
||||
* written under the old shape still lines up.
|
||||
*/
|
||||
suspend fun buildAppEvent(
|
||||
nostrGroupId: HexKey,
|
||||
appEvent: MarmotAppEvent,
|
||||
): OutboundGroupEvent = buildGroupEventFromBytes(nostrGroupId, appEvent.encodeToPayload())
|
||||
|
||||
/**
|
||||
* Encrypt raw bytes and build a GroupEvent for publishing.
|
||||
|
||||
+233
@@ -0,0 +1,233 @@
|
||||
/*
|
||||
* 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.marmot.foundation.appEvents
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.core.TagArray
|
||||
import com.vitorpamplona.quartz.nip01Core.crypto.EventHasher
|
||||
|
||||
/**
|
||||
* A Marmot app event — the plaintext inside an MLS application message
|
||||
* (`foundation/application-messages.md`).
|
||||
*
|
||||
* It has the Nostr event shape **minus `sig`**, and the missing signature is
|
||||
* the point rather than an omission. MLS already authenticates the sender as a
|
||||
* group member and `pubkey` names the Marmot account that wrote it, so a
|
||||
* signature would add nothing — while making every plaintext a valid standalone
|
||||
* relay event, so one leaked message could be republished publicly as a signed
|
||||
* statement by its author. A client MUST NOT sign one.
|
||||
*
|
||||
* The `id` is still the ordinary NIP-01 event id over
|
||||
* `[0, pubkey, created_at, kind, tags, content]`, and decoders MUST reject a
|
||||
* payload whose id does not match — which means every implementation has to
|
||||
* produce byte-identical canonical JSON. That is why the id goes through
|
||||
* [EventHasher] rather than being hashed over hand-built text here.
|
||||
*/
|
||||
class MarmotAppEvent(
|
||||
val id: HexKey,
|
||||
val pubKey: HexKey,
|
||||
val createdAt: Long,
|
||||
val kind: Int,
|
||||
val tags: TagArray,
|
||||
val content: String,
|
||||
) {
|
||||
/** Whether [id] matches the canonical id of the other members. */
|
||||
fun hasValidId(): Boolean = EventHasher.hashIdCheck(id, pubKey, createdAt, kind, tags, content)
|
||||
|
||||
/**
|
||||
* The canonical wire form: one UTF-8 JSON object with exactly the six
|
||||
* members, in this order, and no others.
|
||||
*/
|
||||
fun toJson(): String {
|
||||
val sb = StringBuilder(128 + content.length)
|
||||
sb.append("{\"id\":\"").append(id)
|
||||
sb.append("\",\"pubkey\":\"").append(pubKey)
|
||||
sb.append("\",\"created_at\":").append(createdAt)
|
||||
sb.append(",\"kind\":").append(kind)
|
||||
sb.append(",\"tags\":")
|
||||
appendTags(sb, tags)
|
||||
sb.append(",\"content\":")
|
||||
appendJsonString(sb, content)
|
||||
sb.append('}')
|
||||
return sb.toString()
|
||||
}
|
||||
|
||||
fun encodeToPayload(): ByteArray = toJson().encodeToByteArray()
|
||||
|
||||
companion object {
|
||||
/** Marmot's default ordinary chat kind. */
|
||||
const val KIND_CHAT = 9
|
||||
|
||||
/** In-place replacement of a prior message's text. */
|
||||
const val KIND_EDIT = 1009
|
||||
|
||||
/** A durable group system row, synthesized from canonical state. */
|
||||
const val KIND_SYSTEM = 1210
|
||||
|
||||
private val ALLOWED_MEMBERS = setOf("id", "pubkey", "created_at", "kind", "tags", "content")
|
||||
|
||||
val EMPTY_TAGS: TagArray = emptyArray()
|
||||
|
||||
/**
|
||||
* Drop a signed Nostr event down to its Marmot app-event shape.
|
||||
*
|
||||
* The id survives unchanged: NIP-01 hashes
|
||||
* `[0, pubkey, created_at, kind, tags, content]`, which never covered
|
||||
* the signature. That is what lets a client switch to the canonical
|
||||
* shape without renumbering its own history.
|
||||
*/
|
||||
fun fromEvent(event: Event) =
|
||||
MarmotAppEvent(
|
||||
id = event.id,
|
||||
pubKey = event.pubKey,
|
||||
createdAt = event.createdAt,
|
||||
kind = event.kind,
|
||||
tags = event.tags,
|
||||
content = event.content,
|
||||
)
|
||||
|
||||
/** Build one, computing the canonical id from the other members. */
|
||||
fun build(
|
||||
pubKey: HexKey,
|
||||
kind: Int,
|
||||
content: String,
|
||||
createdAt: Long,
|
||||
tags: TagArray = EMPTY_TAGS,
|
||||
) = MarmotAppEvent(
|
||||
id = EventHasher.hashId(pubKey, createdAt, kind, tags, content),
|
||||
pubKey = pubKey,
|
||||
createdAt = createdAt,
|
||||
kind = kind,
|
||||
tags = tags,
|
||||
content = content,
|
||||
)
|
||||
|
||||
/**
|
||||
* Decode a Marmot app payload, strictly.
|
||||
*
|
||||
* Every rejection here is required by the spec, and each closes a
|
||||
* different hole:
|
||||
*
|
||||
* - a `sig` member, because a signed inner event is republishable as a
|
||||
* public statement by its author;
|
||||
* - an unknown top-level member, because two implementations that
|
||||
* disagree about what to ignore disagree about the id preimage;
|
||||
* - a duplicate key, because "last one wins" and "first one wins" are
|
||||
* both defensible and yield different events from identical bytes;
|
||||
* - an id that does not match, because the id is what edits, history
|
||||
* and deduplication all reference.
|
||||
*
|
||||
* @throws IllegalArgumentException naming the reason.
|
||||
*/
|
||||
fun decode(json: String): MarmotAppEvent {
|
||||
val obj = MarmotJson.parseObject(json)
|
||||
|
||||
require(!obj.containsKey("sig")) {
|
||||
"Marmot app payload MUST NOT carry a Nostr signature"
|
||||
}
|
||||
val unknown = obj.keys - ALLOWED_MEMBERS
|
||||
require(unknown.isEmpty()) {
|
||||
"Marmot app payload has unknown member(s): ${unknown.sorted()}"
|
||||
}
|
||||
require(obj.duplicateKeys.isEmpty()) {
|
||||
"Marmot app payload has duplicate key(s): ${obj.duplicateKeys.sorted()}"
|
||||
}
|
||||
val missing = ALLOWED_MEMBERS - obj.keys
|
||||
require(missing.isEmpty()) {
|
||||
"Marmot app payload is missing member(s): ${missing.sorted()}"
|
||||
}
|
||||
|
||||
val event =
|
||||
MarmotAppEvent(
|
||||
id = obj.string("id"),
|
||||
pubKey = obj.string("pubkey"),
|
||||
createdAt = obj.long("created_at"),
|
||||
kind = obj.int("kind"),
|
||||
tags = obj.tags("tags"),
|
||||
content = obj.string("content"),
|
||||
)
|
||||
require(event.hasValidId()) {
|
||||
"Marmot app payload id does not match its canonical serialization"
|
||||
}
|
||||
return event
|
||||
}
|
||||
|
||||
/** [decode], returning null instead of throwing. */
|
||||
fun decodeOrNull(json: String): MarmotAppEvent? =
|
||||
try {
|
||||
decode(json)
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
|
||||
private fun appendTags(
|
||||
sb: StringBuilder,
|
||||
tags: TagArray,
|
||||
) {
|
||||
sb.append('[')
|
||||
for (i in tags.indices) {
|
||||
if (i > 0) sb.append(',')
|
||||
sb.append('[')
|
||||
val tag = tags[i]
|
||||
for (j in tag.indices) {
|
||||
if (j > 0) sb.append(',')
|
||||
appendJsonString(sb, tag[j])
|
||||
}
|
||||
sb.append(']')
|
||||
}
|
||||
sb.append(']')
|
||||
}
|
||||
|
||||
/**
|
||||
* NIP-01 string escaping.
|
||||
*
|
||||
* The escape set is exactly the one NIP-01 pins, because this text also
|
||||
* feeds the id preimage: escaping one more character than a peer does
|
||||
* changes the hash, and the payload is then rejected as having a bad id.
|
||||
*/
|
||||
private fun appendJsonString(
|
||||
sb: StringBuilder,
|
||||
value: String,
|
||||
) {
|
||||
sb.append('"')
|
||||
for (ch in value) {
|
||||
when (ch) {
|
||||
'"' -> sb.append("\\\"")
|
||||
'\\' -> sb.append("\\\\")
|
||||
'\n' -> sb.append("\\n")
|
||||
'\r' -> sb.append("\\r")
|
||||
'\t' -> sb.append("\\t")
|
||||
'\u0008' -> sb.append("\\b")
|
||||
'\u000C' -> sb.append("\\f")
|
||||
else ->
|
||||
if (ch < ' ') {
|
||||
sb.append("\\u")
|
||||
sb.append(ch.code.toString(16).padStart(4, '0'))
|
||||
} else {
|
||||
sb.append(ch)
|
||||
}
|
||||
}
|
||||
}
|
||||
sb.append('"')
|
||||
}
|
||||
}
|
||||
}
|
||||
+176
@@ -0,0 +1,176 @@
|
||||
/*
|
||||
* 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.marmot.foundation.appEvents
|
||||
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonArray
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
|
||||
/**
|
||||
* A parsed top-level JSON object, keeping the one thing every JSON library
|
||||
* throws away: which keys appeared more than once.
|
||||
*
|
||||
* `foundation/application-messages.md` requires a decoder to REJECT a Marmot app
|
||||
* payload with duplicate keys, and a `Map`-shaped parse cannot tell you that —
|
||||
* it has already picked a winner. "Last one wins" and "first one wins" are both
|
||||
* defensible, which is exactly the problem: two implementations would derive
|
||||
* different events, and therefore different ids, from identical bytes.
|
||||
*/
|
||||
class MarmotJsonObject(
|
||||
private val values: JsonObject,
|
||||
/** Keys that appeared more than once at the top level. */
|
||||
val duplicateKeys: Set<String>,
|
||||
) {
|
||||
val keys: Set<String> get() = values.keys
|
||||
|
||||
fun containsKey(name: String) = values.containsKey(name)
|
||||
|
||||
fun string(name: String): String = requireNotNull(values[name]).jsonPrimitive.content
|
||||
|
||||
fun long(name: String): Long =
|
||||
requireNotNull(requireNotNull(values[name]).jsonPrimitive.content.toLongOrNull()) {
|
||||
"$name is not an integer"
|
||||
}
|
||||
|
||||
fun int(name: String): Int =
|
||||
requireNotNull(requireNotNull(values[name]).jsonPrimitive.content.toIntOrNull()) {
|
||||
"$name is not an integer"
|
||||
}
|
||||
|
||||
fun tags(name: String): Array<Array<String>> {
|
||||
val array = values[name] as? JsonArray ?: throw IllegalArgumentException("$name is not an array")
|
||||
return Array(array.size) { i ->
|
||||
val tag = array[i] as? JsonArray ?: throw IllegalArgumentException("$name[$i] is not an array")
|
||||
Array(tag.size) { j ->
|
||||
(tag[j] as? JsonPrimitive)?.content
|
||||
?: throw IllegalArgumentException("$name[$i][$j] is not a string")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** The raw element, for callers that need a nested object. */
|
||||
fun element(name: String) = values[name]
|
||||
}
|
||||
|
||||
/** Strict parsing helpers for Marmot app payloads. */
|
||||
object MarmotJson {
|
||||
private val parser = Json { ignoreUnknownKeys = false }
|
||||
|
||||
fun parseObject(json: String): MarmotJsonObject {
|
||||
val element = parser.parseToJsonElement(json)
|
||||
val obj = element as? JsonObject ?: throw IllegalArgumentException("payload is not a JSON object")
|
||||
return MarmotJsonObject(obj, findDuplicateTopLevelKeys(json))
|
||||
}
|
||||
|
||||
/**
|
||||
* Scan the raw text for repeated top-level member names.
|
||||
*
|
||||
* A hand-rolled scan rather than a library call, because every JSON library
|
||||
* this codebase has resolves duplicates before the caller sees them. It only
|
||||
* has to be right about ONE thing — where a top-level key sits — so it
|
||||
* tracks nesting depth and string boundaries and reads nothing else.
|
||||
*/
|
||||
private fun findDuplicateTopLevelKeys(json: String): Set<String> {
|
||||
val seen = mutableSetOf<String>()
|
||||
val duplicates = mutableSetOf<String>()
|
||||
var depth = 0
|
||||
var i = 0
|
||||
var expectingKey = false
|
||||
|
||||
while (i < json.length) {
|
||||
when (val ch = json[i]) {
|
||||
'{' -> {
|
||||
depth++
|
||||
if (depth == 1) expectingKey = true
|
||||
}
|
||||
|
||||
'}' -> depth--
|
||||
'[' -> depth++
|
||||
']' -> depth--
|
||||
',' -> if (depth == 1) expectingKey = true
|
||||
'"' -> {
|
||||
val end = endOfString(json, i)
|
||||
if (depth == 1 && expectingKey) {
|
||||
val key = unescape(json.substring(i + 1, end))
|
||||
if (!seen.add(key)) duplicates.add(key)
|
||||
expectingKey = false
|
||||
}
|
||||
i = end
|
||||
}
|
||||
|
||||
else ->
|
||||
if (ch != ' ' && ch != '\n' && ch != '\r' && ch != '\t' && ch != ':') {
|
||||
expectingKey = false
|
||||
}
|
||||
}
|
||||
i++
|
||||
}
|
||||
return duplicates
|
||||
}
|
||||
|
||||
/** Index of the closing quote of the string starting at [start]. */
|
||||
private fun endOfString(
|
||||
json: String,
|
||||
start: Int,
|
||||
): Int {
|
||||
var i = start + 1
|
||||
while (i < json.length) {
|
||||
when (json[i]) {
|
||||
'\\' -> i++
|
||||
'"' -> return i
|
||||
}
|
||||
i++
|
||||
}
|
||||
throw IllegalArgumentException("unterminated string in payload")
|
||||
}
|
||||
|
||||
private fun unescape(raw: String): String {
|
||||
if ('\\' !in raw) return raw
|
||||
val sb = StringBuilder(raw.length)
|
||||
var i = 0
|
||||
while (i < raw.length) {
|
||||
val ch = raw[i]
|
||||
if (ch != '\\' || i + 1 >= raw.length) {
|
||||
sb.append(ch)
|
||||
i++
|
||||
continue
|
||||
}
|
||||
when (val esc = raw[i + 1]) {
|
||||
'n' -> sb.append('\n')
|
||||
'r' -> sb.append('\r')
|
||||
't' -> sb.append('\t')
|
||||
'b' -> sb.append('\u0008')
|
||||
'f' -> sb.append('\u000C')
|
||||
'u' -> {
|
||||
val hex = raw.substring(i + 2, minOf(i + 6, raw.length))
|
||||
sb.append(hex.toInt(16).toChar())
|
||||
i += 4
|
||||
}
|
||||
|
||||
else -> sb.append(esc)
|
||||
}
|
||||
i += 2
|
||||
}
|
||||
return sb.toString()
|
||||
}
|
||||
}
|
||||
+98
@@ -0,0 +1,98 @@
|
||||
/*
|
||||
* 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.marmot.foundation.appEvents
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
|
||||
/**
|
||||
* A kind `1009` message edit: an in-place replacement of a prior message's text
|
||||
* (`foundation/application-messages.md`, "Message edits").
|
||||
*
|
||||
* An edit is NOT chat. It must never render as its own row — the replacement is
|
||||
* overlaid on the original body — and it must not advance an unread count: a
|
||||
* reader who was caught up with the original is caught up with the edit.
|
||||
*/
|
||||
class MarmotMessageEdit(
|
||||
/** Event id of the message being replaced. */
|
||||
val targetId: HexKey,
|
||||
/** The replacement plaintext. Not JSON — a 1009's content is the body itself. */
|
||||
val replacement: String,
|
||||
/** Orders competing edits. NOT a new activity timestamp for the target. */
|
||||
val createdAt: Long,
|
||||
/** Account that authored the edit. */
|
||||
val author: HexKey,
|
||||
) {
|
||||
fun toAppEvent() =
|
||||
MarmotAppEvent.build(
|
||||
pubKey = author,
|
||||
kind = MarmotAppEvent.KIND_EDIT,
|
||||
content = replacement,
|
||||
createdAt = createdAt,
|
||||
tags = arrayOf(arrayOf("e", targetId)),
|
||||
)
|
||||
|
||||
companion object {
|
||||
/**
|
||||
* Read an edit out of a decoded app event, or null when it is not one.
|
||||
*
|
||||
* Requires exactly one `e` tag: an edit that named several targets would
|
||||
* leave every client to pick one, and they would not all pick the same.
|
||||
*/
|
||||
fun fromAppEvent(event: MarmotAppEvent): MarmotMessageEdit? {
|
||||
if (event.kind != MarmotAppEvent.KIND_EDIT) return null
|
||||
val targets = event.tags.filter { it.size >= 2 && it[0] == "e" }
|
||||
if (targets.size != 1) return null
|
||||
return MarmotMessageEdit(
|
||||
targetId = targets[0][1],
|
||||
replacement = event.content,
|
||||
createdAt = event.createdAt,
|
||||
author = event.pubKey,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether [edit] may replace a message authored by [originalAuthor].
|
||||
*
|
||||
* Authorship is by Marmot ACCOUNT identity, not by leaf: a second device
|
||||
* of the same account holds a different leaf and may still edit its own
|
||||
* account's message. An edit from any other account is ignored outright
|
||||
* — otherwise any member could rewrite anyone's words.
|
||||
*/
|
||||
fun isAuthorized(
|
||||
edit: MarmotMessageEdit,
|
||||
originalAuthor: HexKey,
|
||||
): Boolean = edit.author == originalAuthor
|
||||
|
||||
/**
|
||||
* The edit that wins for one target: the latest by `created_at`, with
|
||||
* the event id breaking a tie.
|
||||
*
|
||||
* The tie-break matters more than it looks. Two devices of one account
|
||||
* can stamp the same second, and without a deterministic rule two
|
||||
* readers would render different text for the same message forever.
|
||||
*/
|
||||
fun selectOverlay(edits: List<MarmotMessageEdit>): MarmotMessageEdit? =
|
||||
edits.maxWithOrNull(
|
||||
compareBy<MarmotMessageEdit> { it.createdAt }
|
||||
.thenBy { it.toAppEvent().id },
|
||||
)
|
||||
}
|
||||
}
|
||||
+177
@@ -0,0 +1,177 @@
|
||||
/*
|
||||
* 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.marmot.foundation.appEvents
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
|
||||
/** The group-state changes a kind `1210` row can record. */
|
||||
enum class MarmotSystemType(
|
||||
val wireName: String,
|
||||
val defaultText: String,
|
||||
) {
|
||||
MEMBER_ADDED("member_added", "Member added"),
|
||||
MEMBER_REMOVED("member_removed", "Member removed"),
|
||||
MEMBER_LEFT("member_left", "Member left"),
|
||||
ADMIN_ADDED("admin_added", "Admin added"),
|
||||
ADMIN_REMOVED("admin_removed", "Admin removed"),
|
||||
GROUP_RENAMED("group_renamed", "Group renamed"),
|
||||
GROUP_AVATAR_CHANGED("group_avatar_changed", "Group avatar changed"),
|
||||
GROUP_DISBANDED("group_disbanded", "Group disbanded"),
|
||||
;
|
||||
|
||||
companion object {
|
||||
fun fromWire(name: String): MarmotSystemType? = entries.firstOrNull { it.wireName == name }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A kind `1210` group system row
|
||||
* (`foundation/application-messages.md`, "Group system events").
|
||||
*
|
||||
* These rows are **synthesized locally from canonical group state**, not
|
||||
* received as messages. That is what makes them trustworthy: a row derived from
|
||||
* an MLS-authenticated commit cannot be forged by one member, and every client
|
||||
* that applies the same commit derives the same row. A client MUST NOT wait for
|
||||
* a 1210 *message* to learn that group state changed — the state notification
|
||||
* is authoritative, and a 1210 that does arrive over the wire is an assertion by
|
||||
* its sender, not a derived fact.
|
||||
*
|
||||
* They are also not chat: render them separately, and never treat [text] as a
|
||||
* chat body.
|
||||
*/
|
||||
class MarmotSystemEvent(
|
||||
val systemType: MarmotSystemType,
|
||||
/** Committing member, when the change is attributable. */
|
||||
val actor: HexKey?,
|
||||
/** The member the change concerns, for the member/admin types. */
|
||||
val subject: HexKey? = null,
|
||||
/** New group name, for [MarmotSystemType.GROUP_RENAMED]. */
|
||||
val name: String? = null,
|
||||
/** Human-readable fallback. Clients SHOULD render from the structured fields instead. */
|
||||
val text: String = systemType.defaultText,
|
||||
) {
|
||||
/**
|
||||
* The `content` JSON.
|
||||
*
|
||||
* Built by hand rather than via a serializer because this string is inside
|
||||
* the app event's id preimage: a library that reorders members or spaces
|
||||
* them differently would produce a different id for the same row, and a
|
||||
* peer would reject it.
|
||||
*/
|
||||
fun toContentJson(): String {
|
||||
val sb = StringBuilder(128)
|
||||
sb.append("{\"v\":").append(SCHEMA_VERSION)
|
||||
sb.append(",\"system_type\":\"").append(systemType.wireName).append("\"")
|
||||
sb.append(",\"text\":")
|
||||
appendJsonString(sb, text)
|
||||
sb.append(",\"data\":{")
|
||||
var first = true
|
||||
actor?.let {
|
||||
sb.append("\"actor\":\"").append(it).append("\"")
|
||||
first = false
|
||||
}
|
||||
subject?.let {
|
||||
if (!first) sb.append(',')
|
||||
sb.append("\"subject\":\"").append(it).append("\"")
|
||||
first = false
|
||||
}
|
||||
name?.let {
|
||||
if (!first) sb.append(',')
|
||||
sb.append("\"name\":")
|
||||
appendJsonString(sb, it)
|
||||
}
|
||||
sb.append("}}")
|
||||
return sb.toString()
|
||||
}
|
||||
|
||||
/**
|
||||
* The complete app event for this row.
|
||||
*
|
||||
* [author] is the account the row is attributed to — the committer for an
|
||||
* attributable change. The row is anchored to the epoch the change reached,
|
||||
* so [createdAt] should be that moment, not the moment it was rendered.
|
||||
*/
|
||||
fun toAppEvent(
|
||||
author: HexKey,
|
||||
createdAt: Long,
|
||||
) = MarmotAppEvent.build(
|
||||
pubKey = author,
|
||||
kind = MarmotAppEvent.KIND_SYSTEM,
|
||||
content = toContentJson(),
|
||||
createdAt = createdAt,
|
||||
tags = arrayOf(arrayOf("system", systemType.wireName)),
|
||||
)
|
||||
|
||||
companion object {
|
||||
const val SCHEMA_VERSION = 1
|
||||
|
||||
/**
|
||||
* Parse a 1210 row's content, or null when it is not one we understand.
|
||||
*
|
||||
* An unknown `system_type` returns null rather than throwing: the
|
||||
* registry grows, and protocol processing MUST NOT reject an otherwise
|
||||
* valid app payload just because its semantics are unfamiliar. The
|
||||
* caller delivers it and declines to render it.
|
||||
*/
|
||||
fun fromAppEvent(event: MarmotAppEvent): MarmotSystemEvent? {
|
||||
if (event.kind != MarmotAppEvent.KIND_SYSTEM) return null
|
||||
return try {
|
||||
val obj = MarmotJson.parseObject(event.content)
|
||||
if (obj.int("v") != SCHEMA_VERSION) return null
|
||||
val type = MarmotSystemType.fromWire(obj.string("system_type")) ?: return null
|
||||
val data = obj.element("data")?.let { MarmotJson.parseObject(it.toString()) }
|
||||
MarmotSystemEvent(
|
||||
systemType = type,
|
||||
actor = data?.takeIf { it.containsKey("actor") }?.string("actor"),
|
||||
subject = data?.takeIf { it.containsKey("subject") }?.string("subject"),
|
||||
name = data?.takeIf { it.containsKey("name") }?.string("name"),
|
||||
text = if (obj.containsKey("text")) obj.string("text") else type.defaultText,
|
||||
)
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
private fun appendJsonString(
|
||||
sb: StringBuilder,
|
||||
value: String,
|
||||
) {
|
||||
sb.append('"')
|
||||
for (ch in value) {
|
||||
when (ch) {
|
||||
'"' -> sb.append("ESC\"")
|
||||
'\\' -> sb.append("\\\\")
|
||||
'\n' -> sb.append("\\n")
|
||||
'\r' -> sb.append("\\r")
|
||||
'\t' -> sb.append("\\t")
|
||||
else ->
|
||||
if (ch < ' ') {
|
||||
sb.append("\\u")
|
||||
sb.append(ch.code.toString(16).padStart(4, '0'))
|
||||
} else {
|
||||
sb.append(ch)
|
||||
}
|
||||
}
|
||||
}
|
||||
sb.append('"')
|
||||
}
|
||||
}
|
||||
}
|
||||
+229
@@ -0,0 +1,229 @@
|
||||
/*
|
||||
* 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.marmot.foundation.appEvents
|
||||
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFailsWith
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertNull
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
/**
|
||||
* `foundation/application-messages.md` conformance.
|
||||
*
|
||||
* The load-bearing test is [matchesTheSpecPublishedSystemEventFixture]: the
|
||||
* spec publishes one complete kind `1210` event together with the exact id it
|
||||
* hashes to. Because decoders MUST reject a payload whose id does not match,
|
||||
* canonical encoding is not a style question — one extra space or a reordered
|
||||
* member and every peer rejects everything we send. Matching the published id
|
||||
* is the only way to know we are right rather than merely self-consistent.
|
||||
*/
|
||||
class MarmotAppEventTest {
|
||||
private val alice = "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798"
|
||||
|
||||
@Test
|
||||
fun matchesTheSpecPublishedSystemEventFixture() {
|
||||
val row = MarmotSystemEvent(systemType = MarmotSystemType.GROUP_DISBANDED, actor = alice)
|
||||
val event = row.toAppEvent(author = alice, createdAt = 1700000000L)
|
||||
|
||||
assertEquals(
|
||||
"{\"v\":1,\"system_type\":\"group_disbanded\"," +
|
||||
"\"text\":\"Group disbanded\"," +
|
||||
"\"data\":{\"actor\":\"" + alice + "\"}}",
|
||||
row.toContentJson(),
|
||||
)
|
||||
assertEquals("126e47076e4d0a75ed260b279c33ed433acd764fc80e2de2e0315a64116d1f52", event.id)
|
||||
assertTrue(event.hasValidId())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun roundTripsThroughItsCanonicalJson() {
|
||||
val event =
|
||||
MarmotAppEvent.build(
|
||||
pubKey = alice,
|
||||
kind = MarmotAppEvent.KIND_CHAT,
|
||||
content = "hello",
|
||||
createdAt = 1700000000L,
|
||||
)
|
||||
val decoded = MarmotAppEvent.decode(event.toJson())
|
||||
assertEquals(event.id, decoded.id)
|
||||
assertEquals(event.content, decoded.content)
|
||||
assertEquals(event.toJson(), decoded.toJson())
|
||||
}
|
||||
|
||||
/**
|
||||
* A signed inner event would be republishable as a public statement by its
|
||||
* author, which is exactly why the signature is left out — so a payload
|
||||
* carrying one is refused, not merely ignored.
|
||||
*/
|
||||
@Test
|
||||
fun rejectsAPayloadCarryingASignature() {
|
||||
val event = MarmotAppEvent.build(alice, 9, "hi", 1700000000L)
|
||||
val withSig = event.toJson().dropLast(1) + ",\"sig\":\"" + "0".repeat(128) + "\"}"
|
||||
val failure = assertFailsWith<IllegalArgumentException> { MarmotAppEvent.decode(withSig) }
|
||||
assertTrue(failure.message!!.contains("signature"))
|
||||
}
|
||||
|
||||
/** Two implementations that disagree about what to ignore disagree about the id. */
|
||||
@Test
|
||||
fun rejectsAnUnknownTopLevelMember() {
|
||||
val event = MarmotAppEvent.build(alice, 9, "hi", 1700000000L)
|
||||
val extra = event.toJson().dropLast(1) + ",\"nonce\":\"x\"}"
|
||||
assertFailsWith<IllegalArgumentException> { MarmotAppEvent.decode(extra) }
|
||||
}
|
||||
|
||||
/**
|
||||
* "Last one wins" and "first one wins" are both defensible, which is the
|
||||
* problem: identical bytes would yield different events, and so different
|
||||
* ids, on two clients.
|
||||
*/
|
||||
@Test
|
||||
fun rejectsDuplicateKeys() {
|
||||
val event = MarmotAppEvent.build(alice, 9, "hi", 1700000000L)
|
||||
val dup = event.toJson().dropLast(1) + ",\"content\":\"something else\"}"
|
||||
val failure = assertFailsWith<IllegalArgumentException> { MarmotAppEvent.decode(dup) }
|
||||
assertTrue(failure.message!!.contains("duplicate"))
|
||||
}
|
||||
|
||||
/** A repeated key inside a nested VALUE is not a top-level duplicate. */
|
||||
@Test
|
||||
fun doesNotConfuseANestedKeyForATopLevelOne() {
|
||||
val nested = "{\"a\":1,\"a\":2}"
|
||||
val event = MarmotAppEvent.build(alice, 1210, nested, 1700000000L)
|
||||
assertEquals(event.id, MarmotAppEvent.decode(event.toJson()).id)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun rejectsAMismatchedId() {
|
||||
val event = MarmotAppEvent.build(alice, 9, "hi", 1700000000L)
|
||||
val tampered = event.toJson().replace("\"content\":\"hi\"", "\"content\":\"bye\"")
|
||||
val failure = assertFailsWith<IllegalArgumentException> { MarmotAppEvent.decode(tampered) }
|
||||
assertTrue(failure.message!!.contains("id does not match"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun rejectsAMissingMember() {
|
||||
val incomplete =
|
||||
"{\"id\":\"x\",\"pubkey\":\"" + alice + "\",\"created_at\":1,\"kind\":9,\"tags\":[]}"
|
||||
assertFailsWith<IllegalArgumentException> { MarmotAppEvent.decode(incomplete) }
|
||||
}
|
||||
|
||||
/** Escaping one more character than a peer does changes the hash. */
|
||||
@Test
|
||||
fun escapesExactlyTheNip01Set() {
|
||||
val awkward = "quote \"q\" backslash \\\\ newline \\n tab \\t"
|
||||
val event = MarmotAppEvent.build(alice, 9, awkward, 1700000000L)
|
||||
assertTrue(event.hasValidId(), "our serialization must agree with EventHasher's")
|
||||
assertEquals(awkward, MarmotAppEvent.decode(event.toJson()).content)
|
||||
}
|
||||
|
||||
// --- kind 1009 -------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun anEditNamesExactlyOneTarget() {
|
||||
val edit = MarmotMessageEdit("a".repeat(64), "fixed", 1700000000L, alice)
|
||||
val parsed = MarmotMessageEdit.fromAppEvent(edit.toAppEvent())!!
|
||||
assertEquals("a".repeat(64), parsed.targetId)
|
||||
assertEquals("fixed", parsed.replacement)
|
||||
|
||||
// An edit naming two targets leaves each client to pick one, and they
|
||||
// would not all pick the same.
|
||||
val twoTargets =
|
||||
MarmotAppEvent.build(
|
||||
alice,
|
||||
MarmotAppEvent.KIND_EDIT,
|
||||
"fixed",
|
||||
1700000000L,
|
||||
arrayOf(arrayOf("e", "a".repeat(64)), arrayOf("e", "b".repeat(64))),
|
||||
)
|
||||
assertNull(MarmotMessageEdit.fromAppEvent(twoTargets))
|
||||
}
|
||||
|
||||
/** Authorship is by ACCOUNT, so another device of the same account may edit. */
|
||||
@Test
|
||||
fun onlyTheOriginalAuthorsAccountMayEdit() {
|
||||
val bob = "b".repeat(64)
|
||||
val mine = MarmotMessageEdit("a".repeat(64), "fixed", 1700000000L, alice)
|
||||
assertTrue(MarmotMessageEdit.isAuthorized(mine, alice))
|
||||
|
||||
val theirs = MarmotMessageEdit("a".repeat(64), "vandalised", 1700000001L, bob)
|
||||
assertFalse(MarmotMessageEdit.isAuthorized(theirs, alice))
|
||||
}
|
||||
|
||||
/**
|
||||
* Two devices of one account can stamp the same second; without a
|
||||
* deterministic tie-break two readers would render different text for the
|
||||
* same message forever.
|
||||
*/
|
||||
@Test
|
||||
fun theLatestEditWinsAndTiesBreakDeterministically() {
|
||||
val target = "a".repeat(64)
|
||||
val older = MarmotMessageEdit(target, "first", 1700000000L, alice)
|
||||
val newer = MarmotMessageEdit(target, "second", 1700000001L, alice)
|
||||
assertEquals("second", MarmotMessageEdit.selectOverlay(listOf(newer, older))!!.replacement)
|
||||
assertEquals("second", MarmotMessageEdit.selectOverlay(listOf(older, newer))!!.replacement)
|
||||
|
||||
val tieA = MarmotMessageEdit(target, "aaa", 1700000005L, alice)
|
||||
val tieB = MarmotMessageEdit(target, "bbb", 1700000005L, alice)
|
||||
assertEquals(
|
||||
MarmotMessageEdit.selectOverlay(listOf(tieA, tieB))!!.replacement,
|
||||
MarmotMessageEdit.selectOverlay(listOf(tieB, tieA))!!.replacement,
|
||||
)
|
||||
}
|
||||
|
||||
// --- kind 1210 -------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun aSystemRowRoundTripsItsStructuredFields() {
|
||||
val row =
|
||||
MarmotSystemEvent(
|
||||
systemType = MarmotSystemType.MEMBER_ADDED,
|
||||
actor = alice,
|
||||
subject = "b".repeat(64),
|
||||
)
|
||||
val parsed = MarmotSystemEvent.fromAppEvent(row.toAppEvent(alice, 1700000000L))!!
|
||||
assertEquals(MarmotSystemType.MEMBER_ADDED, parsed.systemType)
|
||||
assertEquals(alice, parsed.actor)
|
||||
assertEquals("b".repeat(64), parsed.subject)
|
||||
assertNull(parsed.name)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aRenameCarriesTheNewName() {
|
||||
val row = MarmotSystemEvent(MarmotSystemType.GROUP_RENAMED, actor = alice, name = "Book Club")
|
||||
val parsed = MarmotSystemEvent.fromAppEvent(row.toAppEvent(alice, 1700000000L))!!
|
||||
assertEquals("Book Club", parsed.name)
|
||||
}
|
||||
|
||||
/**
|
||||
* The registry grows. Protocol processing MUST NOT reject an otherwise-valid
|
||||
* app payload just because its semantics are unfamiliar — the payload still
|
||||
* decodes; only the row interpretation is declined.
|
||||
*/
|
||||
@Test
|
||||
fun anUnknownSystemTypeIsDeclinedNotRejected() {
|
||||
val content = "{\"v\":1,\"system_type\":\"something_new\",\"text\":\"?\",\"data\":{}}"
|
||||
val event = MarmotAppEvent.build(alice, MarmotAppEvent.KIND_SYSTEM, content, 1700000000L)
|
||||
assertEquals(event.id, MarmotAppEvent.decode(event.toJson()).id)
|
||||
assertNull(MarmotSystemEvent.fromAppEvent(event))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user