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()) + } +}