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
|
||||
|
||||
Reference in New Issue
Block a user