feat(projects): apply a signed project, and refuse the three ways it can lie
Phase 3 of docs/project-feature.md. Three arms in `ChatMessage.applyInnerEvent`, which is where every nip30303 kind lands whether it arrived over the wire or was signed locally -- `FrostSigningManager` reuses this dispatch rather than repeating it. A project can now become a row; nothing proposes one yet. The project writes a chat line naming the project and not its creator. On a group-signed row `senderPublicKey` is the session's coordinator, so "X started the project" would be the transcript guessing at something the event states exactly. The memberships write no line, the way a version writes none beside its artifact: they arrive as later items of the batch that founds the project, which has already said so, and one added later is covered by the "The group signed ..." line `FrostSigningManager.announce` already writes. `fromProjectEvent` refuses an event with no name, no creator or no owners. The third refusal is the one worth having a test for and it is not about malformed events at all: it is what `ContributorTag.ROLE_OWNER` missing from `KNOWN_ROLES` looks like from here. The role parses as null, `owners()` comes back empty, the signature still verifies and nothing is logged, so the refusal is the alarm. `ProjectApplyJvmTest` reproduces it with a role deliberately not in the set. A membership whose version or project this device does not hold is refused by the foreign key, and the refusal is caught here rather than left to escape. `applySignedEvent` swallows throwables because the signature is valid either way, so an escaping one would report a session that succeeded as a session that failed and tell the group to abandon a ceremony whose signature is good. No call path wraps `applyInnerEvent` in a transaction -- checked, all three -- so catching it does not poison one. 1381 tests, no failures. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -37,6 +37,9 @@ import press.mantra.compose.nostr.nip30303.TranslationArtifactVersionContributor
|
||||
import press.mantra.compose.nostr.nip30303.TranslationArtifactVersionEvent
|
||||
import press.mantra.compose.nostr.nip30303.TranslationChapterEvent
|
||||
import press.mantra.compose.nostr.nip30303.TranslationChunkEvent
|
||||
import press.mantra.compose.nostr.nip30303.ProjectArtifactVersionEvent
|
||||
import press.mantra.compose.nostr.nip30303.ProjectEvent
|
||||
import press.mantra.compose.nostr.nip30303.ProjectTranslationArtifactVersionEvent
|
||||
import press.mantra.compose.nostr.nip30303.TranslationContributorListEvent
|
||||
import press.mantra.compose.nostr.nip30303.TranslationEvent
|
||||
import co.touchlab.kermit.Logger
|
||||
@@ -1270,6 +1273,136 @@ data class ChatMessage(
|
||||
)
|
||||
}
|
||||
}
|
||||
// A body of work the group has named: which source versions it
|
||||
// is translating and which translations it is producing.
|
||||
//
|
||||
// The memberships are their own events, signed in the same
|
||||
// session and applied after this one -- see
|
||||
// AddProjectViewModel.addProject. Nothing to do here but the
|
||||
// project itself.
|
||||
//
|
||||
// The line names the project and not its creator. On a
|
||||
// group-signed row `senderPublicKey` is the session's
|
||||
// coordinator, so "X started the project" would be the
|
||||
// transcript's own guess at something the event states exactly;
|
||||
// the creator and the owners are on the row, and the detail
|
||||
// screen is where they are read.
|
||||
ProjectEvent.KIND -> {
|
||||
MantraProject.fromProjectEvent(
|
||||
projectEvent = ProjectEvent(
|
||||
id = event.id,
|
||||
pubKey = event.pubKey,
|
||||
createdAt = event.createdAt,
|
||||
tags = event.tags,
|
||||
content = event.content,
|
||||
sig = event.sig
|
||||
),
|
||||
chatRoomId = groupId,
|
||||
)?.let { mantraProject ->
|
||||
database.mantraProjectDao().upsert(
|
||||
mantraProject.copy(
|
||||
marmotGroupEventId = marmotGroupEventId,
|
||||
groupSignedEventId = groupSignedEventId,
|
||||
)
|
||||
)
|
||||
|
||||
ChatMessage(
|
||||
giftWrapPayloadId = null,
|
||||
messageType = "project",
|
||||
marmotGroupEventId = marmotGroupEventId,
|
||||
marmotInnerEventId = marmotInnerEventId,
|
||||
senderPublicKey = senderPublicKey,
|
||||
isUserMessage = isUserMessage,
|
||||
chatRoomId = groupId,
|
||||
createdAt = createdAt,
|
||||
content = "Started the ${mantraProject.name} project"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// One source version is part of one project.
|
||||
//
|
||||
// No line of its own, the way a version writes none beside its
|
||||
// artifact and a chunk none beside its chapter: a membership
|
||||
// arrives as a later item of the batch that founds the project,
|
||||
// which has already said so. One added to an existing project
|
||||
// later is not silent either -- `FrostSigningManager.announce`
|
||||
// writes "The group signed ..." when the session completes, and
|
||||
// three more lines here would be a second account of it.
|
||||
//
|
||||
// The row may be refused: both references are foreign keys, so a
|
||||
// membership naming a version this device does not hold cannot be
|
||||
// written. That is the one-room scope decision doing its job
|
||||
// rather than an error to propagate -- `applySignedEvent`
|
||||
// swallows throwables because the signature is valid either way,
|
||||
// and letting this one reach it would report a session that
|
||||
// succeeded as a session that failed.
|
||||
ProjectArtifactVersionEvent.KIND -> {
|
||||
MantraProjectArtifactVersion.fromProjectArtifactVersionEvent(
|
||||
projectArtifactVersionEvent = ProjectArtifactVersionEvent(
|
||||
id = event.id,
|
||||
pubKey = event.pubKey,
|
||||
createdAt = event.createdAt,
|
||||
tags = event.tags,
|
||||
content = event.content,
|
||||
sig = event.sig
|
||||
),
|
||||
chatRoomId = groupId,
|
||||
)?.let { membership ->
|
||||
try {
|
||||
database.mantraProjectArtifactVersionDao().upsert(
|
||||
membership.copy(
|
||||
marmotGroupEventId = marmotGroupEventId,
|
||||
groupSignedEventId = groupSignedEventId,
|
||||
)
|
||||
)
|
||||
} catch (error: Throwable) {
|
||||
logger.w(
|
||||
"Project ${membership.projectId.take(8)} names artifact " +
|
||||
"version ${membership.artifactVersionId.take(8)}, which " +
|
||||
"this device does not hold",
|
||||
error
|
||||
)
|
||||
}
|
||||
}
|
||||
null
|
||||
}
|
||||
|
||||
// The other half. See the arm above.
|
||||
ProjectTranslationArtifactVersionEvent.KIND -> {
|
||||
MantraProjectTranslationArtifactVersion
|
||||
.fromProjectTranslationArtifactVersionEvent(
|
||||
projectTranslationArtifactVersionEvent =
|
||||
ProjectTranslationArtifactVersionEvent(
|
||||
id = event.id,
|
||||
pubKey = event.pubKey,
|
||||
createdAt = event.createdAt,
|
||||
tags = event.tags,
|
||||
content = event.content,
|
||||
sig = event.sig
|
||||
),
|
||||
chatRoomId = groupId,
|
||||
)?.let { membership ->
|
||||
try {
|
||||
database.mantraProjectTranslationArtifactVersionDao().upsert(
|
||||
membership.copy(
|
||||
marmotGroupEventId = marmotGroupEventId,
|
||||
groupSignedEventId = groupSignedEventId,
|
||||
)
|
||||
)
|
||||
} catch (error: Throwable) {
|
||||
logger.w(
|
||||
"Project ${membership.projectId.take(8)} names " +
|
||||
"translation " +
|
||||
membership.translationArtifactVersionId.take(8) +
|
||||
", which this device does not hold",
|
||||
error
|
||||
)
|
||||
}
|
||||
}
|
||||
null
|
||||
}
|
||||
|
||||
// A signing session's own protocol messages. FrostSigningManager
|
||||
// applies them and writes its own transcript lines naming who did
|
||||
// what, so an "unsupported" row here would be a second, worse
|
||||
|
||||
@@ -0,0 +1,368 @@
|
||||
package press.mantra.compose.database.model
|
||||
|
||||
import androidx.room3.Room
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
|
||||
import com.vitorpamplona.quartz.nip01Core.crypto.EventHasher
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate
|
||||
import com.vitorpamplona.quartz.nip31Alts.AltTag
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import press.mantra.compose.database.MantraDatabase
|
||||
import press.mantra.compose.database.builder.getRoomDatabase
|
||||
import press.mantra.compose.nostr.nip30303.ProjectArtifactVersionEvent
|
||||
import press.mantra.compose.nostr.nip30303.ProjectEvent
|
||||
import press.mantra.compose.nostr.nip30303.ProjectTranslationArtifactVersionEvent
|
||||
import press.mantra.compose.nostr.nip30303.tags.ContributorTag
|
||||
import press.mantra.compose.nostr.nip30303.tags.CreatorTag
|
||||
import press.mantra.compose.nostr.nip30303.tags.NameTag
|
||||
import press.mantra.compose.nostr.nip30303.tags.ProjectIdTag
|
||||
import kotlin.test.AfterTest
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertNull
|
||||
import kotlin.test.assertTrue
|
||||
import kotlin.test.fail
|
||||
import kotlin.time.Instant
|
||||
|
||||
/**
|
||||
* What a signed project turns into, and the three ways it is allowed not to.
|
||||
*
|
||||
* The apply arm is the only place a project row is written -- `FrostSigningManager`
|
||||
* reuses this dispatch rather than repeating it -- so it is also the only place
|
||||
* the event's claims about people become columns. Two of those claims are
|
||||
* required and the arm refuses the event rather than inventing them, which
|
||||
* matters more than it looks: the whole reason the creator and the owners are
|
||||
* tags is that they are inside the id the quorum signed, and a row that could
|
||||
* have been filled in from local knowledge would be worth less than no row.
|
||||
*
|
||||
* The third refusal is the owners disappearing while the event stays valid.
|
||||
* That is what `ContributorTag.ROLE_OWNER` missing from `KNOWN_ROLES` looks
|
||||
* like from here -- the role parses as null and `owners()` comes back empty --
|
||||
* and the only thing standing between that and a silent regression is this
|
||||
* test, since nothing logs and the signature still verifies.
|
||||
*/
|
||||
class ProjectApplyJvmTest {
|
||||
|
||||
private val db: MantraDatabase = getRoomDatabase(
|
||||
Room.inMemoryDatabaseBuilder<MantraDatabase>()
|
||||
)
|
||||
|
||||
@AfterTest
|
||||
fun closeDb() = db.close()
|
||||
|
||||
private val roomId = "b".repeat(64)
|
||||
private val signature = "0".repeat(128)
|
||||
|
||||
private val creator = "a".repeat(64)
|
||||
private val ownerOne = "1".repeat(64)
|
||||
private val ownerTwo = "2".repeat(64)
|
||||
private val ownerThree = "4".repeat(64)
|
||||
|
||||
private val artifactVersionId = "3".repeat(64)
|
||||
private val translationId = "5".repeat(64)
|
||||
|
||||
private suspend fun seed() {
|
||||
val nostrEventId = "e".repeat(64)
|
||||
db.nostrEventDao().upsert(
|
||||
NostrEvent(
|
||||
id = nostrEventId,
|
||||
pubKey = creator,
|
||||
kind = 0,
|
||||
tags = emptyArray(),
|
||||
content = "{}",
|
||||
sig = signature,
|
||||
)
|
||||
)
|
||||
db.profileDao().upsert(Profile(publicKey = creator, nostrEventId = nostrEventId))
|
||||
db.chatRoomDao().upsert(
|
||||
ChatRoom(
|
||||
id = roomId,
|
||||
userPublicKey = creator,
|
||||
subject = "a translation room",
|
||||
description = null,
|
||||
mlsGroupState = null,
|
||||
)
|
||||
)
|
||||
|
||||
val dialectId = "1".repeat(64)
|
||||
db.mantraDialectDao().upsert(
|
||||
MantraDialect(
|
||||
id = dialectId,
|
||||
publicKey = roomId,
|
||||
name = "Sesotho",
|
||||
country = "ZA",
|
||||
language = "st",
|
||||
signature = signature,
|
||||
chatRoomId = roomId,
|
||||
)
|
||||
)
|
||||
|
||||
val artifactId = "2".repeat(64)
|
||||
db.mantraArtifactDao().upsert(
|
||||
MantraArtifact(
|
||||
id = artifactId,
|
||||
publicKey = roomId,
|
||||
name = "To Kill a Mockingbird",
|
||||
url = "https://harper.com/2-kill-Bird",
|
||||
visibility = "private",
|
||||
dialectId = dialectId,
|
||||
license = "cc",
|
||||
chatRoomId = roomId,
|
||||
signature = signature,
|
||||
)
|
||||
)
|
||||
|
||||
db.mantraArtifactVersionDao().upsert(
|
||||
MantraArtifactVersion(
|
||||
id = artifactVersionId,
|
||||
artifactId = artifactId,
|
||||
publicKey = roomId,
|
||||
versionLabel = "1.0",
|
||||
signature = signature,
|
||||
chatRoomId = roomId,
|
||||
)
|
||||
)
|
||||
|
||||
db.mantraTranslationArtifactVersionDao().upsert(
|
||||
MantraTranslationArtifactVersion(
|
||||
id = translationId,
|
||||
publicKey = roomId,
|
||||
artifactVersionId = artifactVersionId,
|
||||
dialectId = dialectId,
|
||||
name = "Sesotho",
|
||||
visibility = "private",
|
||||
license = "cc",
|
||||
signature = signature,
|
||||
chatRoomId = roomId,
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
/** The event as the group signed it: authored by the room, id over its tags. */
|
||||
private fun signed(template: EventTemplate<*>): Event = Event(
|
||||
id = EventHasher.hashId(
|
||||
pubKey = roomId,
|
||||
createdAt = template.createdAt,
|
||||
kind = template.kind,
|
||||
tags = template.tags,
|
||||
content = template.content,
|
||||
),
|
||||
pubKey = roomId,
|
||||
createdAt = template.createdAt,
|
||||
kind = template.kind,
|
||||
tags = template.tags,
|
||||
content = template.content,
|
||||
sig = signature,
|
||||
)
|
||||
|
||||
private suspend fun apply(event: Event) = ChatMessage.applyInnerEvent(
|
||||
database = db,
|
||||
groupId = roomId,
|
||||
event = event,
|
||||
marmotGroupEventId = null,
|
||||
marmotInnerEventId = null,
|
||||
senderPublicKey = roomId,
|
||||
isUserMessage = false,
|
||||
createdAt = Instant.fromEpochSeconds(event.createdAt),
|
||||
)
|
||||
|
||||
private fun projectTemplate(owners: Set<String> = setOf(ownerOne)) = ProjectEvent.build(
|
||||
name = "Gospel of John into Sesotho",
|
||||
description = "The four chapters.",
|
||||
creatorPublicKey = creator,
|
||||
ownerPublicKeys = owners,
|
||||
createdAt = 1_000,
|
||||
)
|
||||
|
||||
// --- the project ---
|
||||
|
||||
@Test
|
||||
fun `a signed project becomes a row carrying its creator and every owner`() = runBlocking {
|
||||
seed()
|
||||
val project = signed(projectTemplate(setOf(ownerThree, ownerOne, ownerTwo)))
|
||||
|
||||
val line = apply(project) ?: fail("a project writes a chat line")
|
||||
|
||||
val stored = db.mantraProjectDao().getProjectById(project.id)
|
||||
?: fail("the project was not stored")
|
||||
|
||||
assertEquals("Gospel of John into Sesotho", stored.name)
|
||||
assertEquals("The four chapters.", stored.description)
|
||||
assertEquals(creator, stored.creatorPublicKey)
|
||||
assertEquals(setOf(ownerOne, ownerTwo, ownerThree), stored.ownerPublicKeySet())
|
||||
// The room authored it; the creator is a claim it carries, not its author.
|
||||
assertEquals(roomId, stored.publicKey)
|
||||
|
||||
assertEquals("project", line.messageType)
|
||||
assertTrue(line.content.contains("Gospel of John into Sesotho"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a project with no creator tag writes nothing`() = runBlocking {
|
||||
seed()
|
||||
|
||||
// Everything a project has except the creator, with the tag order the
|
||||
// builder uses so this is a plausible event rather than a mangled one.
|
||||
val template = ProjectEvent.build(
|
||||
name = "Gospel of John into Sesotho",
|
||||
description = "The four chapters.",
|
||||
creatorPublicKey = creator,
|
||||
ownerPublicKeys = setOf(ownerOne),
|
||||
createdAt = 1_000,
|
||||
)
|
||||
val withoutCreator = Event(
|
||||
id = "c".repeat(64),
|
||||
pubKey = roomId,
|
||||
createdAt = template.createdAt,
|
||||
kind = template.kind,
|
||||
tags = template.tags.filterNot { it.firstOrNull() == CreatorTag.TAG_NAME }
|
||||
.toTypedArray(),
|
||||
content = template.content,
|
||||
sig = signature,
|
||||
)
|
||||
|
||||
assertNull(apply(withoutCreator))
|
||||
assertNull(db.mantraProjectDao().getProjectById(withoutCreator.id))
|
||||
}
|
||||
|
||||
/**
|
||||
* The regression this file exists for.
|
||||
*
|
||||
* An unregistered role is dropped by `ContributorTag.parse`, so the owner
|
||||
* tags are still there, the event still verifies, and `owners()` returns
|
||||
* nothing. Built by hand with a role that is deliberately not in
|
||||
* `KNOWN_ROLES`, because the point is to reproduce what `ROLE_OWNER` would
|
||||
* look like if somebody removed it.
|
||||
*/
|
||||
@Test
|
||||
fun `a project whose owner role is not a known one writes nothing`() = runBlocking {
|
||||
seed()
|
||||
|
||||
val tags = TagArrayBuilder<ProjectEvent>()
|
||||
.addUnique(AltTag.assemble(ProjectEvent.ALT_DESCRIPTION))
|
||||
.addUnique(NameTag.assemble("Gospel of John into Sesotho"))
|
||||
.addUnique(CreatorTag.assemble(creator))
|
||||
.addUniqueValueIfNew(ContributorTag.assemble(ownerOne, "proprietor"))
|
||||
.build()
|
||||
|
||||
val event = Event(
|
||||
id = "d".repeat(64),
|
||||
pubKey = roomId,
|
||||
createdAt = 1_000,
|
||||
kind = ProjectEvent.KIND,
|
||||
tags = tags,
|
||||
content = "The four chapters.",
|
||||
sig = signature,
|
||||
)
|
||||
|
||||
assertNull(apply(event))
|
||||
assertNull(db.mantraProjectDao().getProjectById(event.id))
|
||||
}
|
||||
|
||||
// --- the memberships ---
|
||||
|
||||
@Test
|
||||
fun `memberships are stored and write no line of their own`() = runBlocking {
|
||||
seed()
|
||||
val project = signed(projectTemplate())
|
||||
apply(project)
|
||||
|
||||
val source = signed(
|
||||
ProjectArtifactVersionEvent.membershipsOf(project, listOf(artifactVersionId)).single()
|
||||
)
|
||||
val translation = signed(
|
||||
ProjectTranslationArtifactVersionEvent
|
||||
.membershipsOf(project, listOf(translationId))
|
||||
.single()
|
||||
)
|
||||
|
||||
// Null, the way a version writes none beside its artifact: the project's
|
||||
// own line has already said what happened.
|
||||
assertNull(apply(source))
|
||||
assertNull(apply(translation))
|
||||
|
||||
assertEquals(
|
||||
listOf("1.0"),
|
||||
db.mantraProjectArtifactVersionDao()
|
||||
.getArtifactVersionsByProjectId(project.id)
|
||||
.map { it.versionLabel }
|
||||
)
|
||||
assertEquals(
|
||||
listOf("Sesotho"),
|
||||
db.mantraProjectTranslationArtifactVersionDao()
|
||||
.getTranslationArtifactVersionsByProjectId(project.id)
|
||||
.map { it.name }
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a membership naming a version this device lacks leaves the project standing`() =
|
||||
runBlocking {
|
||||
seed()
|
||||
val project = signed(projectTemplate())
|
||||
apply(project)
|
||||
|
||||
val absent = "9".repeat(64)
|
||||
val source = signed(
|
||||
ProjectArtifactVersionEvent.membershipsOf(project, listOf(absent)).single()
|
||||
)
|
||||
|
||||
// The foreign key refuses it. What must not happen is the refusal
|
||||
// escaping: `applySignedEvent` would report a session that succeeded
|
||||
// as one that failed, and the group would be told to abandon a
|
||||
// ceremony whose signature is perfectly good.
|
||||
assertNull(apply(source))
|
||||
|
||||
assertTrue(
|
||||
db.mantraProjectArtifactVersionDao().getByProjectId(project.id).isEmpty()
|
||||
)
|
||||
assertEquals(
|
||||
"Gospel of John into Sesotho",
|
||||
db.mantraProjectDao().getProjectById(project.id)?.name
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a membership naming a project this device lacks is refused the same way`() =
|
||||
runBlocking {
|
||||
seed()
|
||||
val absentProject = "8".repeat(64)
|
||||
|
||||
val template = ProjectArtifactVersionEvent.build(
|
||||
projectId = absentProject,
|
||||
artifactVersionId = artifactVersionId,
|
||||
createdAt = 1_000,
|
||||
)
|
||||
|
||||
assertNull(apply(signed(template)))
|
||||
assertTrue(
|
||||
db.mantraProjectArtifactVersionDao().getByProjectId(absentProject).isEmpty()
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a membership missing the project it belongs to writes nothing`() = runBlocking {
|
||||
seed()
|
||||
val project = signed(projectTemplate())
|
||||
apply(project)
|
||||
|
||||
val template = ProjectArtifactVersionEvent.build(
|
||||
projectId = project.id,
|
||||
artifactVersionId = artifactVersionId,
|
||||
createdAt = 1_000,
|
||||
)
|
||||
val withoutProject = Event(
|
||||
id = "7".repeat(64),
|
||||
pubKey = roomId,
|
||||
createdAt = template.createdAt,
|
||||
kind = template.kind,
|
||||
tags = template.tags.filterNot { it.firstOrNull() == ProjectIdTag.TAG_NAME }
|
||||
.toTypedArray(),
|
||||
content = template.content,
|
||||
sig = signature,
|
||||
)
|
||||
|
||||
assertNull(apply(withoutProject))
|
||||
assertNull(db.mantraProjectArtifactVersionDao().getById(withoutProject.id))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user