From fa380e94e15a7d5f3dfb49308025b8d63c70f13d Mon Sep 17 00:00:00 2001 From: Kgothatso Ngako Date: Sat, 5 Sep 2026 20:21:00 +0200 Subject: [PATCH] feat: add a SubmissionEvent that carries a nip30303 event as its payload Every nip30303 kind so far describes a thing: an artifact, a dialect, a chapter, a translated chunk. None of them describes the act of putting one in front of a group, and until now nothing needed to -- a group event's sender was the author of the event inside it, so the two questions had one answer by construction. That construction is also the limit. It means a group can only ever hold work written by its own members under their own keys. A translation lifted from a public archive, a chapter transcribed by an outside contributor, an artifact somebody published years ago: none of it can go in without a member re-authoring it and taking the byline. Kind 30312 is the envelope that separates them. Its content is the payload event's JSON, whole -- same id, same pubKey, same signature, nothing rewritten to look like the submitter's work. The submitter signs for the envelope; the author still signs for the event. Two tags name what is inside so a client can decide whether it can apply a submission without parsing the content first: payloadKind the payload's kind payloadId the payload's id, with the author slot carrying the payload's author -- who, unusually for an id tag in this package, is often not the event's sender Kinds 30300-30311 are taken (30305 and 30307 by contributor lists), so 30312 is the next free one. A submission is not an endorsement and grants nothing. Who may submit is the group's business; this only makes the question expressible. The test covers the property the whole thing rests on: an event written by an outsider goes into an envelope, comes out of a JSON round trip with its id, author and signature intact, and does not acquire the submitter as its author. It also pins payload() returning null rather than something empty when the content will not parse -- which needed android.util.Log stubbing, since quartz logs on that path and unmocked Log methods throw, failing the test on the log line rather than on what it came to check. Nothing sends or reads one yet. Co-Authored-By: Claude Opus 5 --- composeApp/build.gradle.kts | 9 ++ .../compose/nostr/nip30303/SubmissionEvent.kt | 81 +++++++++++ .../nostr/nip30303/tags/PayloadIdTag.kt | 47 +++++++ .../nostr/nip30303/tags/PayloadKindTag.kt | 43 ++++++ .../nostr/nip30303/SubmissionEventTest.kt | 126 ++++++++++++++++++ 5 files changed, 306 insertions(+) create mode 100644 composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/nip30303/SubmissionEvent.kt create mode 100644 composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/nip30303/tags/PayloadIdTag.kt create mode 100644 composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/nip30303/tags/PayloadKindTag.kt create mode 100644 composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/nip30303/SubmissionEventTest.kt diff --git a/composeApp/build.gradle.kts b/composeApp/build.gradle.kts index 7d1eb406..71501a81 100644 --- a/composeApp/build.gradle.kts +++ b/composeApp/build.gradle.kts @@ -169,6 +169,15 @@ android { sourceCompatibility = JavaVersion.VERSION_21 targetCompatibility = JavaVersion.VERSION_21 } + testOptions { + unitTests { + // Quartz logs through android.util.Log on the error paths -- parsing a + // malformed event, for one. Unmocked, those methods throw, so a test + // covering such a path fails on the log line rather than on what it + // came to check. Default values let the code under test carry on. + isReturnDefaultValues = true + } + } } dependencies { diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/nip30303/SubmissionEvent.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/nip30303/SubmissionEvent.kt new file mode 100644 index 00000000..3a2abf79 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/nip30303/SubmissionEvent.kt @@ -0,0 +1,81 @@ +package press.mantra.compose.nostr.nip30303 + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate +import com.vitorpamplona.quartz.nip31Alts.alt +import com.vitorpamplona.quartz.utils.TimeUtils +import press.mantra.compose.nostr.nip30303.tags.PayloadIdTag +import press.mantra.compose.nostr.nip30303.tags.PayloadKindTag + +/** + * 30312 + * + * An envelope that carries one nip30303 event into a group. + * + * Every other nip30303 kind describes a thing -- an artifact, a dialect, a + * chapter, a translated chunk. A submission describes an act: *this member is + * putting this event in front of this group*. The two are separate on purpose, + * because they answer different questions and often have different answers. + * + * The payload travels whole, in [content], keeping its own id, author and + * signature. Nothing is rewritten to make it look like the submitter's work. + * That buys two things: + * + * - A group can take in work written by somebody who is not in it. A + * translation lifted from a public archive, a chapter transcribed by an + * outside contributor, an artifact somebody published years ago -- an admin + * submits it and the group applies it, with the original author still named + * on the row. + * - Authorship stops being a claim the transport makes. Before this, being + * the sender of a group message *was* being the author of the event inside + * it, so the only events a group could hold were ones its own members had + * written under their own keys. + * + * A submission is not an endorsement and grants nothing: a payload's author is + * whoever signed it, and who may submit is the group's business. + */ +@Immutable +class SubmissionEvent( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, +) : Event(id, pubKey, createdAt, KIND, tags, content, sig) { + + /** + * The submitted event, parsed out of [content]. + * + * Null when the content is not an event at all -- treat that as a + * submission that cannot be applied, not as an empty one. + */ + fun payload(): Event? = Event.fromJsonOrNull(content) + + fun payloadKind() = tags.firstNotNullOfOrNull(PayloadKindTag::parse)?.kind + + fun payloadIdReference() = tags.firstNotNullOfOrNull(PayloadIdTag::parse)?.ref + fun payloadId() = payloadIdReference()?.eventId + + /** Who wrote the payload, which is not who sent this submission. */ + fun payloadAuthor() = payloadIdReference()?.author + + companion object { + const val KIND = 30312 + const val ALT_DESCRIPTION = "Submission" + + fun build( + payload: Event, + createdAt: Long = TimeUtils.now(), + initializer: TagArrayBuilder.() -> Unit = {}, + ) = eventTemplate(KIND, payload.toJson(), createdAt) { + alt(ALT_DESCRIPTION) + addUnique(PayloadKindTag.assemble(payload.kind)) + addUnique(PayloadIdTag.assemble(eventId = payload.id, pubkey = payload.pubKey)) + initializer() + } + } +} diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/nip30303/tags/PayloadIdTag.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/nip30303/tags/PayloadIdTag.kt new file mode 100644 index 00000000..8cb1bdab --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/nip30303/tags/PayloadIdTag.kt @@ -0,0 +1,47 @@ +package press.mantra.compose.nostr.nip30303.tags + +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer +import com.vitorpamplona.quartz.nip01Core.tags.events.EventReference +import com.vitorpamplona.quartz.utils.arrayOfNotNull +import com.vitorpamplona.quartz.utils.ensure + +/** + * The event a submission carries, named on the submission itself. + * + * The payload is already in the submission's content, so this tag is not how a + * reader gets at it -- it is how they filter for it. The author slot matters + * more than usual here: on a submission it names whoever wrote the payload, + * who is not necessarily the member who submitted it. + */ +data class PayloadIdTag( + val ref: EventReference, +) { + constructor(eventId: String, relayHint: NormalizedRelayUrl?, pubkey: String?) : this( + EventReference(eventId, pubkey, relayHint) + ) + + companion object { + const val TAG_NAME = "payloadId" + + fun parse(tag: Array): PayloadIdTag? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(tag[1].length == 64) { return null } + + val relayHint = tag.getOrNull(2)?.let { RelayUrlNormalizer.normalizeOrNull(it) } + + return PayloadIdTag(tag[1], relayHint, tag.getOrNull(3)) + } + + fun assemble( + eventId: HexKey, + relay: NormalizedRelayUrl? = null, + pubkey: String? = null, + ) = arrayOfNotNull(TAG_NAME, eventId, relay?.url, pubkey) + + fun assemble(ref: EventReference) = assemble(ref.eventId, ref.relayHint, ref.author) + } +} diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/nip30303/tags/PayloadKindTag.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/nip30303/tags/PayloadKindTag.kt new file mode 100644 index 00000000..4634b251 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/nip30303/tags/PayloadKindTag.kt @@ -0,0 +1,43 @@ +package press.mantra.compose.nostr.nip30303.tags + +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.utils.ensure + +/** + * The kind of the event a submission carries. + * + * Lets a client decide whether it can apply a submission without parsing the + * payload JSON out of the content first. + */ +class PayloadKindTag( + val kind: Int, +) { + fun toTagArray() = assemble( + kind = kind, + ) + + companion object { + const val TAG_NAME = "payloadKind" + + fun parse(tag: Array): PayloadKindTag? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + + val kind = tag[1].toIntOrNull() ?: return null + return PayloadKindTag( + kind = kind, + ) + } + + fun assemble( + kind: Int, + ): Array = arrayOf( + TAG_NAME, + kind.toString() + ) + + fun assemble(payloadKindTag: PayloadKindTag) = assemble( + kind = payloadKindTag.kind, + ) + } +} diff --git a/composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/nip30303/SubmissionEventTest.kt b/composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/nip30303/SubmissionEventTest.kt new file mode 100644 index 00000000..90b36aa6 --- /dev/null +++ b/composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/nip30303/SubmissionEventTest.kt @@ -0,0 +1,126 @@ +package press.mantra.compose.nostr.nip30303 + +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.crypto.EventHasher +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotEquals +import kotlin.test.assertNull + +/** + * What a submission has to survive: the trip through a group. + * + * The envelope is only worth having if the event inside it comes out the other + * side unchanged -- same id, same author, same signature. The moment any of + * those is rewritten in transit, a group can no longer hold work by anyone but + * its own members, which is the whole reason submissions exist. + */ +class SubmissionEventTest { + private val submitter = "a".repeat(64) + private val outsider = "b".repeat(64) + + /** A dialect written by somebody who is not in the group. */ + private fun outsiderDialect(): Event { + val template = DialectEvent.build( + name = "Sesotho", + country = "Lesotho", + language = "st", + createdAt = 1_700_000_000L, + ) + return Event( + id = EventHasher.hashId( + pubKey = outsider, + createdAt = template.createdAt, + kind = template.kind, + tags = template.tags, + content = template.content, + ), + pubKey = outsider, + createdAt = template.createdAt, + kind = template.kind, + tags = template.tags, + content = template.content, + sig = "c".repeat(128), + ) + } + + /** Send a submission template and read it back the way inbound does. */ + private fun roundTrip(payload: Event): SubmissionEvent { + val template = SubmissionEvent.build(payload = payload, createdAt = 1_700_000_100L) + val onTheWire = Event.fromJson( + Event( + id = EventHasher.hashId( + pubKey = submitter, + createdAt = template.createdAt, + kind = template.kind, + tags = template.tags, + content = template.content, + ), + pubKey = submitter, + createdAt = template.createdAt, + kind = template.kind, + tags = template.tags, + content = template.content, + sig = "", + ).toJson() + ) + + return SubmissionEvent( + id = onTheWire.id, + pubKey = onTheWire.pubKey, + createdAt = onTheWire.createdAt, + tags = onTheWire.tags, + content = onTheWire.content, + sig = onTheWire.sig, + ) + } + + @Test + fun `the payload comes back as the event that went in`() { + val dialect = outsiderDialect() + + val payload = roundTrip(dialect).payload() + + assertEquals(dialect.id, payload?.id) + assertEquals(dialect.pubKey, payload?.pubKey) + assertEquals(dialect.kind, payload?.kind) + assertEquals(dialect.content, payload?.content) + assertEquals(dialect.sig, payload?.sig) + } + + @Test + fun `submitting does not make the submitter the author`() { + val dialect = outsiderDialect() + + val submission = roundTrip(dialect) + + assertEquals(submitter, submission.pubKey) + assertEquals(outsider, submission.payload()?.pubKey) + assertNotEquals(submission.pubKey, submission.payload()?.pubKey) + } + + @Test + fun `the envelope names what it carries without being opened`() { + val dialect = outsiderDialect() + + val submission = roundTrip(dialect) + + assertEquals(DialectEvent.KIND, submission.payloadKind()) + assertEquals(dialect.id, submission.payloadId()) + assertEquals(outsider, submission.payloadAuthor()) + } + + @Test + fun `a payload the group cannot read is null rather than empty`() { + val submission = SubmissionEvent( + id = "d".repeat(64), + pubKey = submitter, + createdAt = 1_700_000_100L, + tags = arrayOf(), + content = "not an event", + sig = "", + ) + + assertNull(submission.payload()) + } +}