diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/nip30303/ProjectArtifactVersionEvent.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/nip30303/ProjectArtifactVersionEvent.kt new file mode 100644 index 00000000..071399fb --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/nip30303/ProjectArtifactVersionEvent.kt @@ -0,0 +1,92 @@ +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.nip01Core.signers.eventTemplate +import com.vitorpamplona.quartz.nip31Alts.alt +import com.vitorpamplona.quartz.utils.TimeUtils +import press.mantra.compose.nostr.nip30303.tags.ArtifactVersionIdTag +import press.mantra.compose.nostr.nip30303.tags.ProjectIdTag + + +/** + * One source version is part of one project. + * + * Its own event rather than a line in a list the project carries. Every entity + * in this family is its own event and its own row, and the only list-shaped kinds + * here -- the three contributor lists -- are precisely the ones nothing applies. + * A membership is also something a group decided, and one quorum per decision is + * the grain this app signs at: a replaceable list would make "we added this" and + * "we removed that" the same event. + * + * It carries no creator. Who moved a version into a project is a fact about + * process, which the `FrostSigningSession` records; a membership signed in the + * batch that founds a project has the project's creator anyway. + */ +@Immutable +class ProjectArtifactVersionEvent( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, +) : Event(id, pubKey, createdAt, KIND, tags, content, sig) { + + fun projectIdReference() = tags.firstNotNullOfOrNull(ProjectIdTag::parse)?.ref + fun projectId() = projectIdReference()?.eventId + + fun artifactVersionIdReference() = tags.firstNotNullOfOrNull(ArtifactVersionIdTag::parse)?.ref + fun artifactVersionId() = artifactVersionIdReference()?.eventId + + companion object { + const val KIND = 30331 + const val ALT_DESCRIPTION = "ProjectArtifactVersion" + + /** + * The source versions [project] starts life with, ready to be signed + * with it. + * + * A project and its first contents go to the group as one batch, so this + * is built from the project *after* it has been authored under the + * group's key -- [project] is the unsigned event the session will sign, + * which is where the id these carry comes from. Working it out anywhere + * else would mean naming a project id before one exists: it is a hash + * over the group's key at the room's path, and neither is resolved until + * the proposal is made. + * + * They take the project's own timestamp, so the batch reads as one act + * rather than several events that happen to share a session. + * + * The same shape as `ArtifactVersionEvent.initialVersionOf`, and empty + * for a project founded with no contents -- which is allowed, unlike an + * artifact with no version: an empty project is a name waiting for work. + */ + fun membershipsOf( + project: Event, + artifactVersionIds: List, + ): List> = + artifactVersionIds.map { artifactVersionId -> + build( + projectId = project.id, + artifactVersionId = artifactVersionId, + createdAt = project.createdAt, + ) + } + + fun build( + projectId: HexKey, + artifactVersionId: HexKey, + createdAt: Long = TimeUtils.now(), + initializer: TagArrayBuilder.() -> Unit = {}, + ) = eventTemplate(KIND, "", createdAt) { + alt(ALT_DESCRIPTION) + addUnique(ProjectIdTag.assemble(projectId)) + addUnique(ArtifactVersionIdTag.assemble(artifactVersionId)) + initializer() + } + } +} diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/nip30303/ProjectEvent.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/nip30303/ProjectEvent.kt new file mode 100644 index 00000000..3fdc8d48 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/nip30303/ProjectEvent.kt @@ -0,0 +1,107 @@ +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.ContributorTag +import press.mantra.compose.nostr.nip30303.tags.CreatorTag +import press.mantra.compose.nostr.nip30303.tags.NameTag + + +/** + * A body of work: the source versions to translate from and the translations + * being produced against them, under one name. + * + * A project names two kinds of person and they are not the same shape. There is + * one [creator], so it is a [CreatorTag] -- a single-value tag, like every other + * field here. There may be several [owners], so each is a [ContributorTag] with + * [ContributorTag.ROLE_OWNER], which is what that tag is for. + * + * Both are written before the event is proposed, so they are inside the id the + * quorum signs. That makes them the group's statement about whose work this is + * rather than one device's note about who inserted a row -- and it is a group + * attestation, not proof of authorship: the creator does not sign this, the room + * does, so a quorum that signs without reading has credited whoever the proposer + * typed. `ProposedEvent.summarize` shows both for that reason. + * + * Which group a project belongs to is not a tag at all. It is signed by the + * group's threshold key at the room's derivation path, so it says so by existing. + */ +@Immutable +class ProjectEvent( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, +) : Event(id, pubKey, createdAt, KIND, tags, content, sig) { + + fun name() = tags.firstNotNullOfOrNull(NameTag::parse)?.name + + fun creator() = tags.firstNotNullOfOrNull(CreatorTag::parse)?.pubKey + + /** + * Everyone this project belongs to, in the order the event carries them. + * + * Filtered on the role rather than taking every `p` tag. Every contributor + * tag on a project is an owner today, and that is one future role away from + * being false -- at which point an unfiltered read would quietly hand back an + * owner list with somebody else in it. + * + * Empty is not a project this app wrote: [build] refuses it. It is what a + * reader sees when [ContributorTag.ROLE_OWNER] is missing from that tag's + * known roles, since the role then parses as null -- so treat empty as that + * bug rather than as a project nobody owns. + */ + fun owners() = tags.mapNotNull(ContributorTag::parse) + .filter { it.role == ContributorTag.ROLE_OWNER } + .map { it.pubKey } + + companion object { + const val KIND = 30330 + const val ALT_DESCRIPTION = "Project" + + /** + * [ownerPublicKeys] is a set and must not be empty. + * + * The caller always has an answer -- the room's own key, when the group + * owns what it makes -- and an empty set would make "owned by us" and + * "owner unstated" the same event, which leaves every reader needing to + * know the default. + */ + fun build( + name: String, + description: String, + creatorPublicKey: HexKey, + ownerPublicKeys: Set, + createdAt: Long = TimeUtils.now(), + initializer: TagArrayBuilder.() -> Unit = {}, + ) = eventTemplate(KIND, description, createdAt) { + require(ownerPublicKeys.isNotEmpty()) { "A project has at least one owner" } + + alt(ALT_DESCRIPTION) + addUnique(NameTag.assemble(name)) + addUnique(CreatorTag.assemble(creatorPublicKey)) + + // Sorted, because the id is a hash over the tags as emitted and a + // multi-select hands its selection over in no particular order -- + // the same reason DkgSession.participantPublicKeys sorts. + // + // addUniqueValueIfNew, never addUnique: addUnique keeps one tag per + // *name*, so three owners would come out as one and the loss is + // silent. Deduping on the pubkey is the right thing for a set. + ownerPublicKeys.sorted().forEach { ownerPublicKey -> + addUniqueValueIfNew( + ContributorTag.assemble(ownerPublicKey, ContributorTag.ROLE_OWNER) + ) + } + + initializer() + } + } +} diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/nip30303/ProjectTranslationArtifactVersionEvent.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/nip30303/ProjectTranslationArtifactVersionEvent.kt new file mode 100644 index 00000000..d2119485 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/nip30303/ProjectTranslationArtifactVersionEvent.kt @@ -0,0 +1,67 @@ +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.nip01Core.signers.eventTemplate +import com.vitorpamplona.quartz.nip31Alts.alt +import com.vitorpamplona.quartz.utils.TimeUtils +import press.mantra.compose.nostr.nip30303.tags.ProjectIdTag +import press.mantra.compose.nostr.nip30303.tags.TranslationArtifactVersionIdTag + + +/** + * One translation is part of one project. + * + * The other half of [ProjectArtifactVersionEvent], and the same shape for the + * same reasons -- see there. + */ +@Immutable +class ProjectTranslationArtifactVersionEvent( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, +) : Event(id, pubKey, createdAt, KIND, tags, content, sig) { + + fun projectIdReference() = tags.firstNotNullOfOrNull(ProjectIdTag::parse)?.ref + fun projectId() = projectIdReference()?.eventId + + fun translationArtifactVersionIdReference() = + tags.firstNotNullOfOrNull(TranslationArtifactVersionIdTag::parse)?.ref + fun translationArtifactVersionId() = translationArtifactVersionIdReference()?.eventId + + companion object { + const val KIND = 30332 + const val ALT_DESCRIPTION = "ProjectTranslationArtifactVersion" + + /** The translations [project] starts life with. See the sibling's note. */ + fun membershipsOf( + project: Event, + translationArtifactVersionIds: List, + ): List> = + translationArtifactVersionIds.map { translationArtifactVersionId -> + build( + projectId = project.id, + translationArtifactVersionId = translationArtifactVersionId, + createdAt = project.createdAt, + ) + } + + fun build( + projectId: HexKey, + translationArtifactVersionId: HexKey, + createdAt: Long = TimeUtils.now(), + initializer: TagArrayBuilder.() -> Unit = {}, + ) = eventTemplate(KIND, "", createdAt) { + alt(ALT_DESCRIPTION) + addUnique(ProjectIdTag.assemble(projectId)) + addUnique(TranslationArtifactVersionIdTag.assemble(translationArtifactVersionId)) + initializer() + } + } +} diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/nip30303/tags/ContributorTag.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/nip30303/tags/ContributorTag.kt index d73e9e3e..f01ffdbf 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/nip30303/tags/ContributorTag.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/nip30303/tags/ContributorTag.kt @@ -20,7 +20,27 @@ class ContributorTag( const val ROLE_PROOF_READER = "proofreader" const val ROLE_EDITOR = "editor" - private val KNOWN_ROLES = setOf(ROLE_TRANSLATOR, ROLE_PROOF_READER, ROLE_EDITOR) + /** + * Whoever the work belongs to, which need not be whoever is doing it. + * + * A project carries one of these per owner -- the room's own key when the + * group owns what it makes, and somebody else's when it does not. There + * is no matching creator role: a project has exactly one creator and a + * single-valued field gets a tag of its own, which is `CreatorTag`. + */ + const val ROLE_OWNER = "owner" + + // Four, where NIP-F4 defines host/cohost/editor. A role missing from this + // set is the quietest bug in the family: `parse` returns the tag with a + // null role, the string stays inside the signed event id, the event still + // verifies, and whatever was reading for that role finds nothing. Add to + // both halves or to neither. + private val KNOWN_ROLES = setOf( + ROLE_TRANSLATOR, + ROLE_PROOF_READER, + ROLE_EDITOR, + ROLE_OWNER, + ) fun isTagged(tag: Array) = tag.has(1) && tag[0] == TAG_NAME && tag[1].length == 64 diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/nip30303/tags/CreatorTag.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/nip30303/tags/CreatorTag.kt new file mode 100644 index 00000000..a3868e74 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/nip30303/tags/CreatorTag.kt @@ -0,0 +1,44 @@ +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.utils.ensure + +/** + * Whoever started the thing this tag is on. + * + * A tag of its own rather than a [ContributorTag] with a creator role, because + * there is exactly one creator and a single-valued field is what every other tag + * in this package is. The owners are the other shape -- a set, open-ended, and so + * [ContributorTag] with [ContributorTag.ROLE_OWNER]. + * + * The name is multi-character on purpose. NIP-01 indexes single-letter tag names + * and makes them queryable, so a `p` tag is an address: relays index it and a + * client may read it as a mention. These events live inside a Marmot group and + * are never on a relay as themselves, but `BroadcastGroupSignedEventScreen` can + * put one there, and a creator should travel in that as data rather than as a + * recipient. + */ +class CreatorTag( + val pubKey: HexKey, +) { + fun toTagArray() = assemble(pubKey = pubKey) + + companion object { + const val TAG_NAME = "creator" + + fun parse(tag: Array): CreatorTag? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + // Without this a malformed value parses, lands on the row, and names + // nobody. ContributorTag checks the same thing for the same reason. + ensure(tag[1].length == 64) { return null } + + return CreatorTag(pubKey = tag[1]) + } + + fun assemble(pubKey: HexKey): Array = arrayOf(TAG_NAME, pubKey) + + fun assemble(creatorTag: CreatorTag) = assemble(pubKey = creatorTag.pubKey) + } +} diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/nip30303/tags/ProjectIdTag.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/nip30303/tags/ProjectIdTag.kt new file mode 100644 index 00000000..a5817693 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/nip30303/tags/ProjectIdTag.kt @@ -0,0 +1,44 @@ +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 + +data class ProjectIdTag( + val ref: EventReference, +) { + constructor(eventId: String, relayHint: NormalizedRelayUrl?, pubkey: String?) : this( + EventReference(eventId, pubkey, relayHint) + ) + + companion object { + // Plain, like `artifactId`. Six tags in this package carry a stray `Tag` + // suffix in the wire value -- `chapterIdTag` and friends -- which is a + // copy-paste slip and is unfixable: those strings are inside event ids + // the group has signed, so renaming one invalidates every signature over + // it. Do not copy it and do not tidy it. + const val TAG_NAME = "projectId" + + fun parse(tag: Array): ProjectIdTag? { + 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 ProjectIdTag(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/commonTest/kotlin/press/mantra/compose/nostr/nip30303/ProjectEventTest.kt b/composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/nip30303/ProjectEventTest.kt new file mode 100644 index 00000000..e0c2fea9 --- /dev/null +++ b/composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/nip30303/ProjectEventTest.kt @@ -0,0 +1,207 @@ +package press.mantra.compose.nostr.nip30303 + +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate +import press.mantra.compose.nostr.nip30303.tags.ContributorTag +import press.mantra.compose.nostr.nip30303.tags.CreatorTag +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * A project names people two different ways, and both ways fail quietly. + * + * The creator is a [CreatorTag] and the owners are [ContributorTag]s with + * [ContributorTag.ROLE_OWNER]. Each has a failure mode that produces a valid, + * verifiable event carrying nobody: + * + * - a role missing from `ContributorTag`'s known set parses as null, so every + * owner vanishes while the role string stays inside the signed id; + * - `addUnique` keeps one tag per *name*, so three owners emitted with it + * become one, which reads like a bug in whatever collected them. + * + * Neither logs anything. These tests are the only thing that would say. + */ +class ProjectEventTest { + private val creator = "a".repeat(64) + private val owner1 = "1".repeat(64) + private val owner2 = "2".repeat(64) + private val owner3 = "3".repeat(64) + + private fun EventTemplate.asEvent() = ProjectEvent( + id = "e".repeat(64), + pubKey = creator, + createdAt = createdAt, + tags = tags, + content = content, + sig = "" + ) + + private fun project(owners: Set) = ProjectEvent.build( + name = "Gospel of John into Sesotho", + description = "The four chapters, with the Setswana back-translation.", + creatorPublicKey = creator, + ownerPublicKeys = owners + ).asEvent() + + // --- the role has to be known, or there are no owners at all --- + + @Test + fun `the owner role survives a round trip through ContributorTag`() { + val parsed = ContributorTag.parse( + ContributorTag.assemble(owner1, ContributorTag.ROLE_OWNER) + ) + + assertEquals(owner1, parsed?.pubKey) + // If ROLE_OWNER is ever dropped from KNOWN_ROLES this is the only place + // that says so. Everything downstream just sees a project with no owners. + assertEquals(ContributorTag.ROLE_OWNER, parsed?.role) + } + + // --- the creator tag --- + + @Test + fun `a creator tag round trips`() { + assertEquals(creator, CreatorTag.parse(CreatorTag.assemble(creator))?.pubKey) + } + + @Test + fun `a creator tag whose value is not a key is refused`() { + assertNull(CreatorTag.parse(arrayOf(CreatorTag.TAG_NAME, "not-a-key"))) + assertNull(CreatorTag.parse(arrayOf(CreatorTag.TAG_NAME))) + assertNull(CreatorTag.parse(arrayOf("p", creator))) + } + + // --- owners are a set, and all of them survive --- + + @Test + fun `three owners come back, sorted`() { + val event = project(setOf(owner3, owner1, owner2)) + + // `addUnique` here would leave one. Sorted because the id is a hash over + // the tags as emitted and a form hands its selection over in no + // particular order. + assertEquals(listOf(owner1, owner2, owner3), event.owners()) + } + + @Test + fun `an owner listed twice appears once`() { + assertEquals(listOf(owner1), project(setOf(owner1)).owners()) + } + + @Test + fun `a project with no owners cannot be built`() { + assertFailsWith { + ProjectEvent.build( + name = "Nobody's", + description = "", + creatorPublicKey = creator, + ownerPublicKeys = emptySet() + ) + } + } + + @Test + fun `owners ignores a contributor tag that is not an owner`() { + val event = ProjectEvent.build( + name = "Gospel of John into Sesotho", + description = "", + creatorPublicKey = creator, + ownerPublicKeys = setOf(owner1) + ) { + addUniqueValueIfNew( + ContributorTag.assemble(owner2, ContributorTag.ROLE_EDITOR) + ) + }.asEvent() + + assertEquals(listOf(owner1), event.owners()) + } + + // --- the creator and the owners are different tags, so one can be both --- + + @Test + fun `the creator may also be an owner`() { + val event = project(setOf(creator, owner1)) + + assertEquals(creator, event.creator()) + assertTrue(creator in event.owners()) + assertTrue(owner1 in event.owners()) + } + + @Test + fun `the creator is not read off the owner tags`() { + val event = project(setOf(owner1)) + + // Selecting the creator by position among p-tags would make these agree, + // which reads as working. + assertEquals(creator, event.creator()) + assertEquals(listOf(owner1), event.owners()) + } + + @Test + fun `name and description land where they are read from`() { + val event = project(setOf(owner1)) + + assertEquals("Gospel of John into Sesotho", event.name()) + assertTrue(event.content.startsWith("The four chapters")) + } + + // --- memberships borrow the project's id and timestamp --- + + @Test + fun `memberships name the project and share its timestamp`() { + val signedProject: Event = project(setOf(owner1)) + val versionId = "f".repeat(64) + + val sources = ProjectArtifactVersionEvent.membershipsOf( + project = signedProject, + artifactVersionIds = listOf(versionId) + ) + val translations = ProjectTranslationArtifactVersionEvent.membershipsOf( + project = signedProject, + translationArtifactVersionIds = listOf(versionId) + ) + + val source = ProjectArtifactVersionEvent( + id = "0".repeat(64), + pubKey = creator, + createdAt = sources.single().createdAt, + tags = sources.single().tags, + content = sources.single().content, + sig = "" + ) + + assertEquals(signedProject.id, source.projectId()) + assertEquals(versionId, source.artifactVersionId()) + assertEquals(signedProject.createdAt, source.createdAt) + + val translation = ProjectTranslationArtifactVersionEvent( + id = "0".repeat(64), + pubKey = creator, + createdAt = translations.single().createdAt, + tags = translations.single().tags, + content = translations.single().content, + sig = "" + ) + + assertEquals(signedProject.id, translation.projectId()) + assertEquals(versionId, translation.translationArtifactVersionId()) + assertEquals(signedProject.createdAt, translation.createdAt) + } + + @Test + fun `a project founded with no contents has no memberships`() { + val signedProject: Event = project(setOf(owner1)) + + assertTrue( + ProjectArtifactVersionEvent.membershipsOf(signedProject, emptyList()).isEmpty() + ) + assertTrue( + ProjectTranslationArtifactVersionEvent + .membershipsOf(signedProject, emptyList()) + .isEmpty() + ) + } +}