feat: sign an artifact into the library instead of submitting one
Adding an artifact no longer creates one. It opens a signing session over an ArtifactEvent, and the artifact appears -- on every member's device at once, authored by the group's shared key rather than by whoever typed it -- when enough members have signed. The same trade the dialects made: a submission says "I am putting this in front of the group" and the group's only recourse afterwards is social, while a signature is the group saying it and it takes a quorum to say. A library is the group's. **The first version.** This is the part the dialect had no answer for. An artifact was creating an initial ArtifactVersion as a second submitted event, and that cannot survive the change: a chapter attaches to a version rather than to an artifact, so an artifact without one is inert, but a version cannot be submitted before the artifact it points at exists, cannot have its own quorum without costing a second signing session per form, and cannot be invented locally -- an invented id differs on every device, so members would silently disagree about which version a chapter hangs off while every screen showed the same artifact. So the label rides on the artifact as an `artifactVersion` tag and the row is derived from the signed artifact's own fields when it is applied. Same bytes in, same row out, everywhere. It is a rumor, because nobody signed it; what the group signed is the artifact that declares it. **What went away.** MantraDao.addArtifact and its way up through the repository. Nothing called it once the screen proposed instead, and leaving a path that authors an artifact under a member's key while the UI insists on a quorum would have double-created the version besides. **Tests.** Three files, and each was checked against a broken implementation rather than only against a working one: deriving the version from the clock, dropping the label from the proposal, authoring the derived row as its reader, and losing the signature on the way out of the session are all caught. SignedArtifactTest runs a real 2-of-3 quorum over an actual proposal, because the claim worth holding -- the row is the group's, and carries proof of it -- is invisible when it breaks. Not covered: applyInnerEvent's two upserts, which need a database no test here stands up, and AddArtifactViewModel, which is plumbing across two dispatchers over a template the tests already pin. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -9,7 +9,6 @@ import com.vitorpamplona.quartz.nip01Core.crypto.EventHasher
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate
|
||||
import press.mantra.compose.database.MantraDatabase
|
||||
import press.mantra.compose.database.model.ChatMessage
|
||||
import press.mantra.compose.database.model.MantraArtifact
|
||||
import press.mantra.compose.database.model.MantraArtifactVersion
|
||||
import press.mantra.compose.database.model.MantraChapter
|
||||
import press.mantra.compose.database.model.MantraChunk
|
||||
@@ -19,7 +18,6 @@ import press.mantra.compose.database.model.MantraTranslationChapter
|
||||
import press.mantra.compose.database.model.MantraTranslationChunk
|
||||
import press.mantra.compose.database.model.MarmotInnerEvent
|
||||
import press.mantra.compose.database.model.intermdiate.LocalChatRoom
|
||||
import press.mantra.compose.nostr.nip30303.ArtifactEvent
|
||||
import press.mantra.compose.nostr.nip30303.ArtifactVersionEvent
|
||||
import press.mantra.compose.nostr.nip30303.ChapterEvent
|
||||
import press.mantra.compose.nostr.nip30303.ChunkEvent
|
||||
@@ -156,62 +154,6 @@ abstract class MantraDao(
|
||||
|
||||
}
|
||||
|
||||
@Transaction
|
||||
open suspend fun addArtifact(
|
||||
localChatRoom: LocalChatRoom,
|
||||
name: String,
|
||||
url: String,
|
||||
versionLabel: String,
|
||||
dialectId: HexKey,
|
||||
userPublicKey: HexKey,
|
||||
visibility: String = DEFAULT_VISIBILITY,
|
||||
license: String = DEFAULT_LICENSE,
|
||||
): MantraArtifact? {
|
||||
// TODO: Verify the active user is an admin of the chat room before allowing this.
|
||||
val artifactEventTemplate = ArtifactEvent.build(
|
||||
name = name,
|
||||
url = url,
|
||||
visibility = visibility,
|
||||
license = license,
|
||||
dialectId = dialectId,
|
||||
)
|
||||
|
||||
val mantraArtifact = MantraArtifact.fromArtifactEventTemplate(
|
||||
artifactEventTemplate = artifactEventTemplate,
|
||||
chatRoomId = localChatRoom.chatRoom.id,
|
||||
userPublicKey = userPublicKey,
|
||||
) ?: return null
|
||||
|
||||
// Persist the artifact locally and submit it to the group.
|
||||
//
|
||||
// This covers the "private" visibility case. Permissioned artifacts
|
||||
// (published as a PublicMessage) and public artifacts (published as a
|
||||
// plain nostr event) are not implemented yet.
|
||||
return try {
|
||||
database.mantraArtifactDao().upsert(mantraArtifact)
|
||||
|
||||
submitToGroup(
|
||||
chatRoomId = mantraArtifact.chatRoomId,
|
||||
submitterPublicKey = userPublicKey,
|
||||
payload = rumorOf(artifactEventTemplate, userPublicKey),
|
||||
text = "Added $name to artifacts",
|
||||
)
|
||||
|
||||
// Every artifact starts with an initial version.
|
||||
addArtifactVersionInternal(
|
||||
localChatRoom = localChatRoom,
|
||||
artifactId = mantraArtifact.id,
|
||||
versionLabel = versionLabel,
|
||||
userPublicKey = userPublicKey,
|
||||
)
|
||||
|
||||
mantraArtifact
|
||||
} catch (error: Throwable) {
|
||||
logger.e("Failed to add artifact \"$name\" to chat room ${localChatRoom.chatRoom.id}", error)
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
@Transaction
|
||||
open suspend fun addArtifactVersion(
|
||||
localChatRoom: LocalChatRoom,
|
||||
|
||||
@@ -602,15 +602,17 @@ data class ChatMessage(
|
||||
)
|
||||
}
|
||||
ArtifactEvent.KIND -> {
|
||||
val artifactEvent = ArtifactEvent(
|
||||
id = event.id,
|
||||
pubKey = event.pubKey,
|
||||
createdAt = event.createdAt,
|
||||
tags = event.tags,
|
||||
content = event.content,
|
||||
sig = event.sig,
|
||||
)
|
||||
|
||||
MantraArtifact.fromArtifactEvent(
|
||||
artifactEvent = ArtifactEvent(
|
||||
id = event.id,
|
||||
pubKey = event.pubKey,
|
||||
createdAt = event.createdAt,
|
||||
tags = event.tags,
|
||||
content = event.content,
|
||||
sig = event.sig,
|
||||
),
|
||||
artifactEvent = artifactEvent,
|
||||
chatRoomId = groupId,
|
||||
)?.let { mantraArtifact ->
|
||||
database.mantraArtifactDao().upsert(
|
||||
@@ -619,6 +621,23 @@ data class ChatMessage(
|
||||
)
|
||||
)
|
||||
|
||||
// An artifact arrives with the version it starts life
|
||||
// with, derived here rather than sent, so that every
|
||||
// device holding the artifact holds the same first
|
||||
// version. Nothing else can hang off an artifact until
|
||||
// one exists -- a chapter attaches to a version, not to
|
||||
// an artifact -- so an artifact without one is inert.
|
||||
MantraArtifactVersion.initialVersionOf(
|
||||
artifactEvent = artifactEvent,
|
||||
chatRoomId = groupId,
|
||||
)?.let { initialVersion ->
|
||||
database.mantraArtifactVersionDao().upsert(
|
||||
initialVersion.copy(
|
||||
marmotGroupEventId = marmotGroupEventId,
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
ChatMessage(
|
||||
giftWrapPayloadId = null,
|
||||
messageType = "artifact",
|
||||
|
||||
@@ -10,6 +10,7 @@ import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate
|
||||
import com.vitorpamplona.quartz.nip31Alts.AltTag
|
||||
import press.mantra.compose.database.model.traits.OptionalNostrEventEntity
|
||||
import press.mantra.compose.database.model.traits.TimestampedEntity
|
||||
import press.mantra.compose.nostr.nip30303.ArtifactEvent
|
||||
import press.mantra.compose.nostr.nip30303.ArtifactVersionEvent
|
||||
import press.mantra.compose.nostr.nip30303.tags.ArtifactIdTag
|
||||
import press.mantra.compose.nostr.nip30303.tags.ArtifactVersionMetadataTag
|
||||
@@ -83,6 +84,42 @@ data class MantraArtifactVersion(
|
||||
}
|
||||
|
||||
companion object {
|
||||
/**
|
||||
* The version an artifact starts life with, derived from the artifact.
|
||||
*
|
||||
* The group signs an artifact; it does not sign this. So the first
|
||||
* version cannot be an event proposed on its own -- that would cost a
|
||||
* second quorum for one form -- and it cannot be invented by whichever
|
||||
* device notices the artifact first, because an invented id differs on
|
||||
* every device holding the same artifact and none of them would agree
|
||||
* about which version a chapter hangs off. Deriving it from the signed
|
||||
* artifact's own fields gives every device the same row from the same
|
||||
* bytes, which is the only property that matters here.
|
||||
*
|
||||
* It is a rumor -- empty signature -- because nobody signed it. What the
|
||||
* group signed is the artifact that declares it.
|
||||
*
|
||||
* Null when the artifact declares no version, which is every artifact
|
||||
* written before it did.
|
||||
*/
|
||||
fun initialVersionOf(
|
||||
artifactEvent: ArtifactEvent,
|
||||
chatRoomId: HexKey,
|
||||
): MantraArtifactVersion? {
|
||||
val versionLabel = artifactEvent.versionLabel() ?: return null
|
||||
|
||||
return fromArtifactVersionEventTemplate(
|
||||
artifactVersionEventTemplate = ArtifactVersionEvent.build(
|
||||
content = versionLabel,
|
||||
createdAt = artifactEvent.createdAt,
|
||||
) {
|
||||
addUnique(ArtifactIdTag.assemble(artifactEvent.id))
|
||||
},
|
||||
chatRoomId = chatRoomId,
|
||||
userPublicKey = artifactEvent.pubKey,
|
||||
)
|
||||
}
|
||||
|
||||
fun fromArtifactVersionEventTemplate(
|
||||
artifactVersionEventTemplate: EventTemplate<ArtifactVersionEvent>,
|
||||
chatRoomId: HexKey,
|
||||
|
||||
@@ -131,28 +131,6 @@ class DatabaseMantraRepository(
|
||||
)
|
||||
}
|
||||
|
||||
override suspend fun addArtifact(
|
||||
localChatRoom: LocalChatRoom,
|
||||
name: String,
|
||||
url: String,
|
||||
versionLabel: String,
|
||||
dialectId: HexKey,
|
||||
userPublicKey: HexKey,
|
||||
visibility: String,
|
||||
license: String,
|
||||
): MantraArtifact? {
|
||||
return database.mantraDao().addArtifact(
|
||||
localChatRoom = localChatRoom,
|
||||
name = name,
|
||||
url = url,
|
||||
versionLabel = versionLabel,
|
||||
dialectId = dialectId,
|
||||
userPublicKey = userPublicKey,
|
||||
visibility = visibility,
|
||||
license = license
|
||||
)
|
||||
}
|
||||
|
||||
override suspend fun addArtifactVersion(
|
||||
localChatRoom: LocalChatRoom,
|
||||
artifactId: HexKey,
|
||||
|
||||
@@ -9,6 +9,7 @@ import com.vitorpamplona.quartz.nip22Comments.RootScope
|
||||
import com.vitorpamplona.quartz.nip31Alts.alt
|
||||
import com.vitorpamplona.quartz.nip50Search.SearchableEvent
|
||||
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||
import press.mantra.compose.nostr.nip30303.tags.ArtifactVersionMetadataTag
|
||||
import press.mantra.compose.nostr.nip30303.tags.DialectIdTag
|
||||
import press.mantra.compose.nostr.nip30303.tags.LicenseTag
|
||||
import press.mantra.compose.nostr.nip30303.tags.UrlTag
|
||||
@@ -31,6 +32,17 @@ class ArtifactEvent(
|
||||
fun dialectIdReference() = tags.firstNotNullOfOrNull(DialectIdTag::parse)?.ref
|
||||
fun dialectId() = dialectIdReference()?.eventId
|
||||
|
||||
/**
|
||||
* The label of the version this artifact starts life with.
|
||||
*
|
||||
* Carried on the artifact rather than in an event of its own because the
|
||||
* group signs the artifact and nothing else. A first version proposed
|
||||
* separately would need either a second quorum for one form or an id each
|
||||
* device invents for itself, and invented ids differ on every device holding
|
||||
* the same artifact. Null on artifacts written before this was declared.
|
||||
*/
|
||||
fun versionLabel() = tags.firstNotNullOfOrNull(ArtifactVersionMetadataTag::parse)?.versionLabel
|
||||
|
||||
companion object {
|
||||
const val KIND = 30300
|
||||
const val ALT_DESCRIPTION = "Artifact"
|
||||
@@ -41,6 +53,7 @@ class ArtifactEvent(
|
||||
visibility: String,
|
||||
license: String,
|
||||
dialectId: String,
|
||||
versionLabel: String,
|
||||
createdAt: Long = TimeUtils.now(),
|
||||
initializer: TagArrayBuilder<ArtifactEvent>.() -> Unit = {},
|
||||
) = eventTemplate(KIND, name, createdAt) {
|
||||
@@ -51,6 +64,7 @@ class ArtifactEvent(
|
||||
addUnique(
|
||||
DialectIdTag.assemble(dialectId)
|
||||
)
|
||||
addUnique(ArtifactVersionMetadataTag.assemble(versionLabel))
|
||||
initializer()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -92,17 +92,6 @@ interface MantraRepository {
|
||||
userPublicKey: HexKey,
|
||||
): MantraDialect?
|
||||
|
||||
suspend fun addArtifact(
|
||||
localChatRoom: LocalChatRoom,
|
||||
name: String,
|
||||
url: String,
|
||||
versionLabel: String,
|
||||
dialectId: HexKey,
|
||||
userPublicKey: HexKey,
|
||||
visibility: String = DEFAULT_VISIBILITY,
|
||||
license: String = DEFAULT_LICENSE,
|
||||
): MantraArtifact?
|
||||
|
||||
suspend fun addArtifactVersion(
|
||||
localChatRoom: LocalChatRoom,
|
||||
artifactId: HexKey,
|
||||
@@ -176,17 +165,6 @@ interface MantraRepository {
|
||||
userPublicKey: HexKey,
|
||||
): MantraDialect? = null
|
||||
|
||||
override suspend fun addArtifact(
|
||||
localChatRoom: LocalChatRoom,
|
||||
name: String,
|
||||
url: String,
|
||||
versionLabel: String,
|
||||
dialectId: HexKey,
|
||||
userPublicKey: HexKey,
|
||||
visibility: String,
|
||||
license: String,
|
||||
): MantraArtifact? = null
|
||||
|
||||
override suspend fun addArtifactVersion(
|
||||
localChatRoom: LocalChatRoom,
|
||||
artifactId: HexKey,
|
||||
|
||||
@@ -54,6 +54,7 @@ import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import press.mantra.compose.database.model.ChatRoom
|
||||
import press.mantra.compose.database.model.intermdiate.LocalChatRoom
|
||||
import press.mantra.compose.repository.ChatRepository
|
||||
import press.mantra.compose.repository.FrostSigningRepository
|
||||
import press.mantra.compose.repository.NostrRepository
|
||||
import press.mantra.compose.ui.composable.navigation.routes.ChatRoomDetailRoute
|
||||
import press.mantra.compose.ui.composable.navigation.routes.Route
|
||||
@@ -62,7 +63,7 @@ import press.mantra.compose.ui.theme.TorchTheme
|
||||
import press.mantra.compose.ui.view.state.ChatRoomMessagingUIState
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import press.mantra.compose.repository.MantraRepository
|
||||
import press.mantra.compose.ui.composable.navigation.routes.ArtifactDetailRoute
|
||||
import press.mantra.compose.ui.composable.navigation.routes.FrostSigningRoute
|
||||
import press.mantra.compose.ui.composable.navigation.routes.ImplementationPendingRoute
|
||||
import press.mantra.compose.ui.view.model.AddArtifactViewModel
|
||||
import press.mantra.compose.ui.view.state.AddArtifactUIState
|
||||
@@ -77,6 +78,7 @@ fun AddArtifactScreen(
|
||||
nostrRepository: NostrRepository,
|
||||
chatRepository: ChatRepository,
|
||||
mantraRepository: MantraRepository,
|
||||
frostSigningRepository: FrostSigningRepository,
|
||||
onNavigateToRouteAndPopUpInclusive: (Route) -> Unit,
|
||||
onNavigateToRoute: (Route) -> Unit,
|
||||
) {
|
||||
@@ -88,7 +90,8 @@ fun AddArtifactScreen(
|
||||
nostrRepository = nostrRepository,
|
||||
chatRepository = chatRepository,
|
||||
activeUserPublicKey = activeUserPublicKey,
|
||||
mantraRepository = mantraRepository
|
||||
mantraRepository = mantraRepository,
|
||||
frostSigningRepository = frostSigningRepository
|
||||
)
|
||||
)
|
||||
|
||||
@@ -115,11 +118,12 @@ fun AddArtifactScreen(
|
||||
// one is picked; dialects are defined from the group detail screen.
|
||||
var selectedDialectId: String? by remember { mutableStateOf(null) }
|
||||
|
||||
// Of the required fields this is the only one the screen cannot ask
|
||||
// for again: a dialect has to already exist, and nothing here can
|
||||
// create one. So an unpicked dialect is a dead end rather than
|
||||
// something to submit and be told about, and the button says so.
|
||||
val canAddArtifact = selectedDialectId != null
|
||||
// The two things the form cannot ask for again. A dialect has to
|
||||
// already exist and nothing here can create one; a shared key has to
|
||||
// have been ceremonied and this is not where that happens. Either
|
||||
// missing is a dead end rather than something to propose and be told
|
||||
// about afterwards, and the button says so.
|
||||
val canAddArtifact = selectedDialectId != null && addArtifactUIState.canSign
|
||||
|
||||
// M3 gives a FAB no `enabled`, so borrow the disabled colours every
|
||||
// other button in the app uses rather than inventing a shade here.
|
||||
@@ -168,15 +172,18 @@ fun AddArtifactScreen(
|
||||
urlField = urlFieldState,
|
||||
versionLabelField = versionLabelFieldState,
|
||||
dialectId = selectedDialectId,
|
||||
onSuccess = { artifactId ->
|
||||
// Open the newly created artifact, removing this
|
||||
// add screen from the back stack.
|
||||
onSuccess = { sessionId ->
|
||||
// Onto the session rather than to the
|
||||
// artifact. Nothing has been created yet
|
||||
// -- the artifact appears when enough
|
||||
// members sign -- so a detail screen for
|
||||
// a row that does not exist would read as
|
||||
// a failure.
|
||||
onNavigateToRouteAndPopUpInclusive.invoke(
|
||||
ArtifactDetailRoute(
|
||||
FrostSigningRoute(
|
||||
activeUserPublicKey = activeUserPublicKey,
|
||||
artifactId = artifactId,
|
||||
chatRoomId = chatRoomId,
|
||||
relayHint = relayHint
|
||||
sessionId = sessionId
|
||||
)
|
||||
)
|
||||
},
|
||||
@@ -191,9 +198,9 @@ fun AddArtifactScreen(
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.Add,
|
||||
contentDescription = "Add artifact"
|
||||
contentDescription = "Propose artifact"
|
||||
)
|
||||
Text("Add Artifact")
|
||||
Text("Propose Artifact")
|
||||
}
|
||||
}
|
||||
)
|
||||
@@ -209,6 +216,16 @@ fun AddArtifactScreen(
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Text("Add Artifact to the group library")
|
||||
|
||||
if (!addArtifactUIState.canSign) {
|
||||
Text(
|
||||
text = "This group has no shared key, so it cannot sign an " +
|
||||
"artifact into its library. Run a shared key ceremony first.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.error
|
||||
)
|
||||
}
|
||||
|
||||
OutlinedTextField(
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
.background(BottomAppBarDefaults.containerColor),
|
||||
@@ -385,6 +402,7 @@ private fun AddArtifactScreenPreview() {
|
||||
nostrRepository = NostrRepository.NO_OP_NOSTR_REPOSITORY,
|
||||
chatRepository = ChatRepository.NO_OP_CHAT_REPOSITORY,
|
||||
mantraRepository = MantraRepository.NO_OP_MANTRA_REPOSITORY,
|
||||
frostSigningRepository = FrostSigningRepository.NO_OP_FROST_SIGNING_REPOSITORY,
|
||||
onNavigateToRouteAndPopUpInclusive = {},
|
||||
onNavigateToRoute = {}
|
||||
)
|
||||
|
||||
@@ -304,7 +304,15 @@ private fun WhatIsBeingSigned(event: Event?) {
|
||||
.joinToString(" · ")
|
||||
}
|
||||
|
||||
ArtifactEvent.KIND -> "New artifact" to event.content
|
||||
ArtifactEvent.KIND -> "New artifact" to ArtifactEvent(
|
||||
event.id, event.pubKey, event.createdAt, event.tags, event.content, event.sig
|
||||
).let { artifact ->
|
||||
// The url is the substance of an artifact -- signing one is putting
|
||||
// the group's name to what it points at -- so it goes next to the
|
||||
// name rather than being left for the detail screen afterwards.
|
||||
listOfNotNull(event.content, artifact.versionLabel(), artifact.url())
|
||||
.joinToString(" · ")
|
||||
}
|
||||
|
||||
ChapterEvent.KIND -> "New chapter" to ChapterEvent(
|
||||
event.id, event.pubKey, event.createdAt, event.tags, event.content, event.sig
|
||||
|
||||
@@ -830,9 +830,12 @@ fun MantraNavHost(
|
||||
nostrRepository = databaseNostrRepository,
|
||||
chatRepository = databaseChatRepository,
|
||||
mantraRepository = databaseMantraRepository,
|
||||
onNavigateToRouteAndPopUpInclusive = { chatRoomDetailRoute ->
|
||||
frostSigningRepository = databaseFrostSigningRepository,
|
||||
onNavigateToRouteAndPopUpInclusive = { signingRoute ->
|
||||
// Replace this add screen so back returns to the group rather
|
||||
// than to a form whose proposal has already gone out.
|
||||
navController.navigate(
|
||||
route = chatRoomDetailRoute
|
||||
route = signingRoute
|
||||
) {
|
||||
popUpTo(route) {
|
||||
inclusive = true
|
||||
|
||||
@@ -19,6 +19,8 @@ import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.IO
|
||||
import kotlinx.coroutines.launch
|
||||
import press.mantra.compose.database.model.intermdiate.LocalChatRoom
|
||||
import press.mantra.compose.nostr.nip30303.ArtifactEvent
|
||||
import press.mantra.compose.repository.FrostSigningRepository
|
||||
import press.mantra.compose.repository.MantraRepository
|
||||
import press.mantra.compose.ui.view.state.AddArtifactUIState
|
||||
|
||||
@@ -30,6 +32,7 @@ class AddArtifactViewModel(
|
||||
val nostrRepository: NostrRepository,
|
||||
val chatRepository: ChatRepository,
|
||||
val mantraRepository: MantraRepository,
|
||||
val frostSigningRepository: FrostSigningRepository,
|
||||
): ViewModel() {
|
||||
|
||||
var addArtifactUIState: AddArtifactUIState by mutableStateOf(initialAddArtifactUIState)
|
||||
@@ -52,11 +55,25 @@ class AddArtifactViewModel(
|
||||
AddArtifactUIState.Loaded(
|
||||
localChatRoom = localChatRoom,
|
||||
dialects = mantraRepository.getDialects(localChatRoom.chatRoom.id),
|
||||
canSign = frostSigningRepository.canSign(chatRoomId),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Asks the group to sign an artifact into its library.
|
||||
*
|
||||
* The artifact is not created here and does not exist yet. What goes out is
|
||||
* a proposal to sign it, and the artifact appears -- on every member's
|
||||
* device at once, authored by the group's shared key rather than by whoever
|
||||
* typed it -- when enough members have signed.
|
||||
*
|
||||
* That is the difference from submitting one. A submission says "I am
|
||||
* putting this in front of the group" and the group's only recourse
|
||||
* afterwards is social. A signature is the group saying it, and it takes a
|
||||
* quorum to say. A library is the group's, so the second is the honest one.
|
||||
*/
|
||||
fun addArtifact(
|
||||
localChatRoom: LocalChatRoom,
|
||||
nameField: TextFieldState,
|
||||
@@ -65,57 +82,65 @@ class AddArtifactViewModel(
|
||||
dialectId: HexKey?,
|
||||
visibility: String = MantraRepository.DEFAULT_VISIBILITY,
|
||||
license: String = MantraRepository.DEFAULT_LICENSE,
|
||||
onSuccess: (artifactId: String) -> Unit,
|
||||
onSuccess: (sessionId: String) -> Unit,
|
||||
onFailure: () -> Unit
|
||||
) {
|
||||
localChatRoom.chatRoom.toMlsGroup()?.let { mlsGroup ->
|
||||
val name = nameField.text.toString()
|
||||
val url = urlField.text.toString()
|
||||
val versionLabel = versionLabelField.text.toString()
|
||||
val name = nameField.text.toString()
|
||||
val url = urlField.text.toString()
|
||||
val versionLabel = versionLabelField.text.toString()
|
||||
|
||||
// addArtifact requires an existing source dialect; they are defined
|
||||
// from the group detail screen, not here.
|
||||
if (name.isBlank() || url.isBlank() || versionLabel.isBlank() || dialectId.isNullOrBlank()) {
|
||||
onFailure.invoke()
|
||||
return
|
||||
}
|
||||
// An artifact requires an existing source dialect; they are signed into
|
||||
// existence from the group detail screen, not here.
|
||||
if (name.isBlank() || url.isBlank() || versionLabel.isBlank() || dialectId.isNullOrBlank()) {
|
||||
onFailure.invoke()
|
||||
return
|
||||
}
|
||||
|
||||
// Guard against double submits from repeated FAB taps.
|
||||
if (isActionPending.value) return
|
||||
isActionPending.value = true
|
||||
// Guard against double submits from repeated FAB taps.
|
||||
if (isActionPending.value) return
|
||||
isActionPending.value = true
|
||||
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
val artifact = runCatching {
|
||||
mantraRepository.addArtifact(
|
||||
localChatRoom = localChatRoom,
|
||||
name = name,
|
||||
url = url,
|
||||
versionLabel = versionLabel,
|
||||
dialectId = dialectId,
|
||||
userPublicKey = activeUserPublicKey,
|
||||
visibility = visibility,
|
||||
license = license,
|
||||
)
|
||||
}.onFailure { error ->
|
||||
logger.e("Failed to add artifact", error)
|
||||
}.getOrNull()
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
// The version label rides on the artifact rather than following it as
|
||||
// a second event. The group signs the artifact; a first version
|
||||
// proposed on its own would cost a second quorum for one form, and
|
||||
// every device derives the same first version from what was signed.
|
||||
val artifactEventTemplate = ArtifactEvent.build(
|
||||
name = name,
|
||||
url = url,
|
||||
visibility = visibility,
|
||||
license = license,
|
||||
dialectId = dialectId,
|
||||
versionLabel = versionLabel,
|
||||
)
|
||||
|
||||
if (artifact != null) {
|
||||
nameField.clearText()
|
||||
urlField.clearText()
|
||||
versionLabelField.clearText()
|
||||
val session = runCatching {
|
||||
frostSigningRepository.proposeSigning(
|
||||
localChatRoom = localChatRoom,
|
||||
userPublicKey = activeUserPublicKey,
|
||||
kind = artifactEventTemplate.kind,
|
||||
tags = artifactEventTemplate.tags,
|
||||
content = artifactEventTemplate.content,
|
||||
)
|
||||
}.onFailure { error ->
|
||||
logger.e("Failed to propose an artifact for signing", error)
|
||||
}.getOrNull()
|
||||
|
||||
viewModelScope.launch(Dispatchers.Main) {
|
||||
onSuccess.invoke(artifact.id)
|
||||
}
|
||||
} else {
|
||||
viewModelScope.launch(Dispatchers.Main) {
|
||||
onFailure.invoke()
|
||||
}
|
||||
if (session != null) {
|
||||
nameField.clearText()
|
||||
urlField.clearText()
|
||||
versionLabelField.clearText()
|
||||
|
||||
viewModelScope.launch(Dispatchers.Main) {
|
||||
onSuccess.invoke(session.id)
|
||||
}
|
||||
} else {
|
||||
viewModelScope.launch(Dispatchers.Main) {
|
||||
onFailure.invoke()
|
||||
}
|
||||
|
||||
isActionPending.value = false
|
||||
}
|
||||
|
||||
isActionPending.value = false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -129,7 +154,8 @@ class AddArtifactViewModel(
|
||||
initialAddArtifactUIState: AddArtifactUIState = AddArtifactUIState.Loading,
|
||||
nostrRepository: NostrRepository,
|
||||
chatRepository: ChatRepository,
|
||||
mantraRepository: MantraRepository
|
||||
mantraRepository: MantraRepository,
|
||||
frostSigningRepository: FrostSigningRepository
|
||||
): ViewModelProvider.Factory = viewModelFactory {
|
||||
initializer {
|
||||
AddArtifactViewModel(
|
||||
@@ -139,9 +165,10 @@ class AddArtifactViewModel(
|
||||
initialAddArtifactUIState = initialAddArtifactUIState,
|
||||
nostrRepository = nostrRepository,
|
||||
chatRepository = chatRepository,
|
||||
mantraRepository = mantraRepository
|
||||
mantraRepository = mantraRepository,
|
||||
frostSigningRepository = frostSigningRepository
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,13 @@ sealed interface AddArtifactUIState {
|
||||
data class Loaded(
|
||||
val localChatRoom: LocalChatRoom,
|
||||
val dialects: List<MantraDialect> = emptyList(),
|
||||
|
||||
/**
|
||||
* Whether the group holds a shared key. An artifact is signed into
|
||||
* existence now rather than submitted, so a group without one cannot
|
||||
* add one here at all.
|
||||
*/
|
||||
val canSign: Boolean = false,
|
||||
): AddArtifactUIState
|
||||
|
||||
data class Error(
|
||||
@@ -15,4 +22,3 @@ sealed interface AddArtifactUIState {
|
||||
|
||||
data object Loading: AddArtifactUIState
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
package press.mantra.compose.database.model
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.crypto.EventHasher
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertNotEquals
|
||||
import kotlin.test.assertNotNull
|
||||
import kotlin.test.assertNull
|
||||
import press.mantra.compose.nostr.nip30303.ArtifactEvent
|
||||
import press.mantra.compose.nostr.nip30303.tags.ArtifactVersionMetadataTag
|
||||
|
||||
/**
|
||||
* The first version of an artifact is derived, not delivered.
|
||||
*
|
||||
* The group signs an artifact and nothing else, so the version it starts life
|
||||
* with is not an event anybody sent: every device builds the row for itself out
|
||||
* of the artifact it already holds. That only works while every device builds
|
||||
* the *same* row, and nothing about the ids would show it if they stopped —
|
||||
* they are content hashes, opaque hex either way. What would show is a group
|
||||
* that quietly disagrees about which version a chapter hangs off, with the
|
||||
* artifact looking identical on every screen.
|
||||
*/
|
||||
class InitialArtifactVersionTest {
|
||||
private val groupKey = "a".repeat(64)
|
||||
private val dialectId = "b".repeat(64)
|
||||
private val chatRoomId = "room"
|
||||
|
||||
private fun signedArtifact(
|
||||
name: String = "In Detention",
|
||||
versionLabel: String = "1.0",
|
||||
createdAt: Long = 1_700_000_000,
|
||||
): ArtifactEvent {
|
||||
val template = ArtifactEvent.build(
|
||||
name = name,
|
||||
url = "example.com",
|
||||
visibility = "private",
|
||||
license = "cc",
|
||||
dialectId = dialectId,
|
||||
versionLabel = versionLabel,
|
||||
createdAt = createdAt,
|
||||
)
|
||||
|
||||
// Hashed rather than made up, so two fixtures that differ are two
|
||||
// different artifacts here for the same reason they would be in the app.
|
||||
return ArtifactEvent(
|
||||
id = EventHasher.hashId(
|
||||
pubKey = groupKey,
|
||||
createdAt = template.createdAt,
|
||||
kind = template.kind,
|
||||
tags = template.tags,
|
||||
content = template.content
|
||||
),
|
||||
pubKey = groupKey,
|
||||
createdAt = template.createdAt,
|
||||
tags = template.tags,
|
||||
content = template.content,
|
||||
sig = "d".repeat(128)
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the derived version is a function of the artifact and nothing else`() {
|
||||
// Every input has to come off the artifact. Reading the clock here would
|
||||
// still agree with itself twice in a row -- and disagree between two
|
||||
// devices that applied the same artifact minutes apart, which is the
|
||||
// case nobody can reproduce on demand. So the timestamp is checked
|
||||
// against the artifact's rather than against a second derivation.
|
||||
val artifact = signedArtifact(createdAt = 1_700_000_000)
|
||||
|
||||
val version = MantraArtifactVersion.initialVersionOf(artifact, chatRoomId)
|
||||
|
||||
assertNotNull(version)
|
||||
assertEquals(1_700_000_000, version.createdAt.epochSeconds)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `two devices derive the same first version from the same artifact`() {
|
||||
val artifact = signedArtifact()
|
||||
|
||||
val mine = MantraArtifactVersion.initialVersionOf(artifact, chatRoomId)
|
||||
val theirs = MantraArtifactVersion.initialVersionOf(artifact, chatRoomId)
|
||||
|
||||
assertNotNull(mine)
|
||||
assertEquals(mine.id, theirs?.id)
|
||||
assertEquals(mine.createdAt, theirs?.createdAt)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an artifact signed at a different moment derives a different version`() {
|
||||
// The artifact's own timestamp is bound into the derived id, so two
|
||||
// proposals identical but for when they were made stay two artifacts
|
||||
// with two first versions rather than colliding on one row.
|
||||
val first = MantraArtifactVersion.initialVersionOf(signedArtifact(createdAt = 1_700_000_000), chatRoomId)
|
||||
val second = MantraArtifactVersion.initialVersionOf(signedArtifact(createdAt = 1_700_000_001), chatRoomId)
|
||||
|
||||
assertNotNull(first)
|
||||
assertNotNull(second)
|
||||
assertNotEquals(first.id, second.id)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the derived version hangs off the artifact and carries what it declared`() {
|
||||
val artifact = signedArtifact(versionLabel = "First Edition")
|
||||
|
||||
val version = MantraArtifactVersion.initialVersionOf(artifact, chatRoomId)
|
||||
|
||||
assertEquals(artifact.id, version?.artifactId)
|
||||
assertEquals("First Edition", version?.versionLabel)
|
||||
// Authored by whoever authored the artifact -- the group, once signed --
|
||||
// and unsigned, because nobody signed this.
|
||||
assertEquals(groupKey, version?.publicKey)
|
||||
assertEquals("", version?.signature)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the label is bound into the id rather than hung beside it`() {
|
||||
// Two artifacts alike but for the label must not derive one version
|
||||
// between them: the id has to come from the whole event, or a group
|
||||
// renaming a version would leave the row it replaces in place.
|
||||
val first = MantraArtifactVersion.initialVersionOf(signedArtifact(versionLabel = "1.0"), chatRoomId)
|
||||
val second = MantraArtifactVersion.initialVersionOf(signedArtifact(versionLabel = "2.0"), chatRoomId)
|
||||
|
||||
assertNotNull(first)
|
||||
assertNotNull(second)
|
||||
assertNotEquals(first.id, second.id)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an artifact that declares no version derives none`() {
|
||||
// Artifacts written before the artifact carried its first version.
|
||||
val declared = signedArtifact()
|
||||
val silent = ArtifactEvent(
|
||||
id = declared.id,
|
||||
pubKey = declared.pubKey,
|
||||
createdAt = declared.createdAt,
|
||||
tags = declared.tags.filterNot { it.firstOrNull() == ArtifactVersionMetadataTag.TAG_NAME }
|
||||
.toTypedArray(),
|
||||
content = declared.content,
|
||||
sig = declared.sig
|
||||
)
|
||||
|
||||
assertNull(MantraArtifactVersion.initialVersionOf(silent, chatRoomId))
|
||||
}
|
||||
}
|
||||
@@ -66,7 +66,8 @@ class RumorIdAgreementTest {
|
||||
url = "example.com",
|
||||
visibility = "private",
|
||||
license = "cc",
|
||||
dialectId = other
|
||||
dialectId = other,
|
||||
versionLabel = "1.0"
|
||||
)
|
||||
|
||||
val entity = MantraArtifact.fromArtifactEventTemplate(
|
||||
|
||||
@@ -0,0 +1,227 @@
|
||||
package press.mantra.compose.managers
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray
|
||||
import com.vitorpamplona.quartz.nip01Core.crypto.EventHasher
|
||||
import com.vitorpamplona.quartz.nip01Core.crypto.Nip01Crypto
|
||||
import fr.acinq.bitcoin.ByteVector
|
||||
import fr.acinq.bitcoin.ByteVector32
|
||||
import fr.acinq.bitcoin.PrivateKey
|
||||
import fr.acinq.bitcoin.crypto.frost.Frost
|
||||
import fr.acinq.bitcoin.crypto.frost.IndividualNonce
|
||||
import fr.acinq.bitcoin.crypto.frost.KeyMaterial
|
||||
import fr.acinq.bitcoin.crypto.frost.SecretNonce
|
||||
import fr.acinq.bitcoin.crypto.frost.Session
|
||||
import fr.acinq.bitcoin.crypto.frost.TweakCache
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertNotEquals
|
||||
import kotlin.test.assertNotNull
|
||||
import kotlin.test.assertTrue
|
||||
import press.mantra.compose.database.model.FrostSigningSession
|
||||
import press.mantra.compose.database.model.MantraArtifact
|
||||
import press.mantra.compose.database.model.MantraArtifactVersion
|
||||
import press.mantra.compose.extensions.toHex
|
||||
import press.mantra.compose.nostr.nip30303.ArtifactEvent
|
||||
|
||||
/**
|
||||
* An artifact the group signed, from proposal to rows, against real FROST.
|
||||
*
|
||||
* Adding an artifact used to write a row and submit it; the row said the
|
||||
* submitter wrote it, because they had. Now the group signs it, and the claim
|
||||
* this test exists to hold is that the artifact every device ends up with is
|
||||
* the group's: authored by the threshold key, carrying a signature that
|
||||
* verifies, with an id every member arrives at independently.
|
||||
*
|
||||
* None of that is visible when it breaks. A row whose author is the proposer
|
||||
* looks exactly like a row whose author is the group -- both are opaque hex --
|
||||
* and a group that disagrees about the id has two artifacts that look like one.
|
||||
*/
|
||||
class SignedArtifactTest {
|
||||
private val participants = 3
|
||||
private val threshold = 2
|
||||
private val chatRoomId = "room"
|
||||
|
||||
/** The member who filled in the form. Nothing they own should end up on the row. */
|
||||
private val proposer = "9".repeat(64)
|
||||
private val dialectId = "b".repeat(64)
|
||||
|
||||
/** Stands in for a completed ceremony; the test is about what gets signed, not the DKG. */
|
||||
private val keyMaterial: KeyMaterial = Frost.trustedDealerKeygen(
|
||||
thresholdSecretKey = PrivateKey(
|
||||
ByteVector32("1c0ffee0000000000000000000000000000000000000000000000000000000a1")
|
||||
),
|
||||
nParticipants = participants,
|
||||
threshold = threshold
|
||||
)
|
||||
|
||||
private val tweakCache: TweakCache = TweakCache.create(keyMaterial.thresholdPublicKey)
|
||||
|
||||
/** The group's nostr identity: the x-only key a BIP-340 signature verifies against. */
|
||||
private val groupPubKey = tweakCache.tweakedPublicKey.value.toHex()
|
||||
|
||||
private fun proposalTemplate(versionLabel: String = "1.0") = ArtifactEvent.build(
|
||||
name = "In Detention",
|
||||
url = "https://example.com/in-detention",
|
||||
visibility = "private",
|
||||
license = "cc",
|
||||
dialectId = dialectId,
|
||||
versionLabel = versionLabel,
|
||||
createdAt = 1_700_000_000L,
|
||||
)
|
||||
|
||||
/**
|
||||
* Exactly what `FrostSigningManager.unsignedEventOf` does, and it must stay
|
||||
* exactly that: the proposer's fields re-authored under the group's key.
|
||||
*/
|
||||
private fun unsignedEventOf(template: com.vitorpamplona.quartz.nip01Core.signers.EventTemplate<*>) = Event(
|
||||
id = EventHasher.hashId(
|
||||
pubKey = groupPubKey,
|
||||
createdAt = template.createdAt,
|
||||
kind = template.kind,
|
||||
tags = template.tags,
|
||||
content = template.content
|
||||
),
|
||||
pubKey = groupPubKey,
|
||||
createdAt = template.createdAt,
|
||||
kind = template.kind,
|
||||
tags = template.tags,
|
||||
content = template.content,
|
||||
sig = ""
|
||||
)
|
||||
|
||||
private fun sessionOver(unsignedEvent: Event) = FrostSigningSession(
|
||||
id = "s".repeat(64),
|
||||
chatRoomId = chatRoomId,
|
||||
coordinatorPublicKey = proposer,
|
||||
userPublicKey = proposer,
|
||||
dkgSessionId = "k".repeat(64),
|
||||
threshold = threshold,
|
||||
participantCount = participants,
|
||||
signerId = 0,
|
||||
unsignedEventJson = unsignedEvent.toJson(),
|
||||
eventId = unsignedEvent.id,
|
||||
nonceRandom = "f".repeat(64)
|
||||
)
|
||||
|
||||
/** A quorum signing the session's event, in the manager's order. */
|
||||
private fun groupSignature(session: FrostSigningSession): String {
|
||||
val message = ByteVector(session.eventId.hexToByteArray())
|
||||
val signerIds = listOf(0, 1)
|
||||
|
||||
val nonces = signerIds.map { signerId ->
|
||||
SecretNonce.generate(
|
||||
sessionRandom = ByteVector32("a".repeat(63) + "${signerId + 1}"),
|
||||
secretShare = keyMaterial.secretShares[signerId],
|
||||
publicShare = keyMaterial.publicShares[signerId],
|
||||
tweakedThresholdPublicKey = tweakCache.tweakedPublicKey,
|
||||
message = message,
|
||||
extraInput = null
|
||||
)
|
||||
}
|
||||
|
||||
val signingSession = Session.create(
|
||||
aggregatedNonce = IndividualNonce.aggregate(nonces.map { it.second }).right!!,
|
||||
signerIds = signerIds.map { it.toUInt() },
|
||||
signerPublicShares = signerIds.map { keyMaterial.publicShares[it] },
|
||||
nParticipants = participants,
|
||||
threshold = threshold,
|
||||
tweakCache = tweakCache,
|
||||
message = message
|
||||
)
|
||||
|
||||
val partials = signerIds.mapIndexed { position, signerId ->
|
||||
signingSession.sign(
|
||||
nonces[position].first,
|
||||
keyMaterial.secretShares[signerId],
|
||||
signerId.toUInt()
|
||||
).right!!
|
||||
}
|
||||
|
||||
return signingSession.aggregateSigs(partials).right!!.toByteArray().toHex()
|
||||
}
|
||||
|
||||
/** Everything from the form to the row a device holds afterwards. */
|
||||
private fun signedArtifactEvent(versionLabel: String = "1.0"): ArtifactEvent {
|
||||
val session = sessionOver(unsignedEventOf(proposalTemplate(versionLabel)))
|
||||
val signed = FrostSigningManager.signedEvent(session, groupSignature(session))
|
||||
|
||||
return ArtifactEvent(
|
||||
signed.id, signed.pubKey, signed.createdAt, signed.tags, signed.content, signed.sig
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the artifact the group signs is authored by the group, not the proposer`() {
|
||||
val artifact = MantraArtifact.fromArtifactEvent(signedArtifactEvent(), chatRoomId)
|
||||
|
||||
assertNotNull(artifact)
|
||||
assertEquals(groupPubKey, artifact.publicKey)
|
||||
assertNotEquals(proposer, artifact.publicKey)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the row's id is the id the group put its signature to`() {
|
||||
// Every device builds this row from the same signed event, so the id has
|
||||
// to be the one that was signed rather than anything recomputed from the
|
||||
// proposer. Otherwise members converge on nothing and each holds its own
|
||||
// copy of what is meant to be one artifact.
|
||||
val session = sessionOver(unsignedEventOf(proposalTemplate()))
|
||||
val signed = FrostSigningManager.signedEvent(session, groupSignature(session))
|
||||
|
||||
val artifact = MantraArtifact.fromArtifactEvent(
|
||||
ArtifactEvent(signed.id, signed.pubKey, signed.createdAt, signed.tags, signed.content, signed.sig),
|
||||
chatRoomId
|
||||
)
|
||||
|
||||
assertEquals(session.eventId, artifact?.id)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the signature on the row verifies against the row's own id and author`() {
|
||||
// The payoff of signing rather than submitting: the row carries proof the
|
||||
// group made it, checkable by anybody holding it.
|
||||
val artifact = MantraArtifact.fromArtifactEvent(signedArtifactEvent(), chatRoomId)
|
||||
|
||||
assertNotNull(artifact)
|
||||
assertTrue(
|
||||
Nip01Crypto.verify(
|
||||
signature = artifact.signature.hexToByteArray(),
|
||||
hash = artifact.id.hexToByteArray(),
|
||||
pubKey = artifact.publicKey.hexToByteArray()
|
||||
),
|
||||
"an artifact row should carry a signature the group's key made over its own id"
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `what the form asked for is what the group signed`() {
|
||||
// The fields travel as tags through a session that knows nothing about
|
||||
// artifacts. Anything dropped in there is signed away silently.
|
||||
val artifact = MantraArtifact.fromArtifactEvent(signedArtifactEvent(), chatRoomId)
|
||||
|
||||
assertEquals("In Detention", artifact?.name)
|
||||
assertEquals("https://example.com/in-detention", artifact?.url)
|
||||
assertEquals("private", artifact?.visibility)
|
||||
assertEquals("cc", artifact?.license)
|
||||
assertEquals(dialectId, artifact?.dialectId)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the artifact arrives with the first version hanging off it`() {
|
||||
// Nothing sends this row: each device derives it from the artifact it
|
||||
// just applied. A chapter attaches to a version rather than to an
|
||||
// artifact, so an artifact that arrives without one is inert.
|
||||
val signed = signedArtifactEvent(versionLabel = "First Edition")
|
||||
|
||||
val artifact = MantraArtifact.fromArtifactEvent(signed, chatRoomId)
|
||||
val version = MantraArtifactVersion.initialVersionOf(signed, chatRoomId)
|
||||
|
||||
assertNotNull(version)
|
||||
assertEquals(artifact?.id, version.artifactId)
|
||||
assertEquals("First Edition", version.versionLabel)
|
||||
assertEquals(groupPubKey, version.publicKey)
|
||||
// Derived, not signed: the group signed the artifact that declares it.
|
||||
assertEquals("", version.signature)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
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.assertNull
|
||||
import press.mantra.compose.nostr.nip30303.tags.ArtifactVersionMetadataTag
|
||||
|
||||
/**
|
||||
* What the form collects has to reach the members deciding whether to sign it.
|
||||
*
|
||||
* An artifact proposal leaves the proposer's device as a kind, a tag array and
|
||||
* a string, and everything a member is shown before signing -- and every row
|
||||
* built afterwards -- is read back out of those. A field that does not survive
|
||||
* the trip is not a visible failure: the artifact still appears, just without
|
||||
* a url, or a source dialect, or a first version, on every device but the one
|
||||
* that typed it.
|
||||
*/
|
||||
class ArtifactEventTest {
|
||||
private val groupKey = "a".repeat(64)
|
||||
private val dialectId = "b".repeat(64)
|
||||
|
||||
/** The event a member actually receives: bytes, with no template behind it. */
|
||||
private fun readBack(template: com.vitorpamplona.quartz.nip01Core.signers.EventTemplate<ArtifactEvent>) =
|
||||
ArtifactEvent(
|
||||
id = EventHasher.hashId(
|
||||
pubKey = groupKey,
|
||||
createdAt = template.createdAt,
|
||||
kind = template.kind,
|
||||
tags = template.tags,
|
||||
content = template.content,
|
||||
),
|
||||
pubKey = groupKey,
|
||||
createdAt = template.createdAt,
|
||||
tags = template.tags,
|
||||
content = template.content,
|
||||
sig = "c".repeat(128),
|
||||
)
|
||||
|
||||
private fun proposal(versionLabel: String = "1.0") = ArtifactEvent.build(
|
||||
name = "In Detention",
|
||||
url = "https://example.com/in-detention",
|
||||
visibility = "private",
|
||||
license = "cc",
|
||||
dialectId = dialectId,
|
||||
versionLabel = versionLabel,
|
||||
createdAt = 1_700_000_000L,
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `every field the form collects survives the trip through the tags`() {
|
||||
val artifact = readBack(proposal())
|
||||
|
||||
assertEquals("In Detention", artifact.content)
|
||||
assertEquals("https://example.com/in-detention", artifact.url())
|
||||
assertEquals("private", artifact.visibility())
|
||||
assertEquals("cc", artifact.license())
|
||||
assertEquals(dialectId, artifact.dialectId())
|
||||
assertEquals("1.0", artifact.versionLabel())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the proposal is the artifact's kind, so the signing screen can describe it`() {
|
||||
// The session carries a kind and nothing else to go on. Get this wrong
|
||||
// and members are asked to sign "event of kind 30300" -- a question
|
||||
// nobody can answer.
|
||||
assertEquals(ArtifactEvent.KIND, proposal().kind)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an artifact declares one version, whatever an initializer adds`() {
|
||||
// addUnique, not add: two labels would leave receivers deriving two
|
||||
// different first versions depending on which one they read first.
|
||||
val template = ArtifactEvent.build(
|
||||
name = "In Detention",
|
||||
url = "https://example.com/in-detention",
|
||||
visibility = "private",
|
||||
license = "cc",
|
||||
dialectId = dialectId,
|
||||
versionLabel = "1.0",
|
||||
) {
|
||||
addUnique(ArtifactVersionMetadataTag.assemble("2.0"))
|
||||
}
|
||||
|
||||
assertEquals(
|
||||
1,
|
||||
template.tags.count { it.firstOrNull() == ArtifactVersionMetadataTag.TAG_NAME }
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an artifact from before the label existed reads back as declaring none`() {
|
||||
// Not an error: it is what every artifact submitted the old way looks
|
||||
// like, and they have to keep parsing rather than failing to load.
|
||||
val template = proposal()
|
||||
val older = Event(
|
||||
id = "d".repeat(64),
|
||||
pubKey = groupKey,
|
||||
createdAt = template.createdAt,
|
||||
kind = template.kind,
|
||||
tags = template.tags
|
||||
.filterNot { it.firstOrNull() == ArtifactVersionMetadataTag.TAG_NAME }
|
||||
.toTypedArray(),
|
||||
content = template.content,
|
||||
sig = "",
|
||||
)
|
||||
|
||||
val artifact = ArtifactEvent(
|
||||
older.id, older.pubKey, older.createdAt, older.tags, older.content, older.sig
|
||||
)
|
||||
|
||||
assertNull(artifact.versionLabel())
|
||||
// Everything else still reads, so the artifact itself is unharmed.
|
||||
assertEquals(dialectId, artifact.dialectId())
|
||||
assertEquals("https://example.com/in-detention", artifact.url())
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user