The group's identity block has a typed editor for each thing the group signs about itself -- the profile form, the post composer, the relay editor, the schema editor -- and each builds one kind of event. This is the untyped way in: an unsigned nostr event as JSON, pasted whole from wherever it was made, proposed to the quorum like anything else, and filed by kind once it is signed. A pasted kind 0 becomes the profile, a kind 1 a post, a kind 31889 a curated schema, a kind 10002 the general relay list. The entry is "Propose event", last in the identity block, behind the same share-holder gate as every button in it and absent from a NIP-17 room like the block itself. The case it exists for is the schema. `npm run schema:dry` in the bitcoin.mov repo prints exactly the kind 31889 event it would publish, id, pubkey, signature and all; until now the only way to get that list under a group's key was to retype it field by field into the editor. Now it is pasted, checked, and signed. **Where the event lands is decided by nothing in this change, and that is the design.** `FrostSigningManager.complete` files every signed event as a `GroupSignedEvent`, and the group's screen reads its profile, posts, relay lists and schemas off those rows by kind, each reader verifying the room's signature for itself. A pasted kind 0 becomes the profile the same way a kind 0 from the profile form does, because by the time either is signed there is no telling them apart. A second path -- a switch on kind that wrote the pasted event into the right place -- would have been a second reading of the same rows, and two readings of one signature drift. So the persistence half of this feature is a test rather than code: `GroupEventProposalTest` takes a pasted kind 0, kind 1, kind 31889 and kind 10002 each through a real FROST quorum signature, in the shape `FrostSigningManager.advance` runs, and asserts that `GroupNostrProfile`, `GroupPost`, `GroupCuratedSchema` and `GroupRelayList` accept what comes out, under the group's key and coordinate rather than the pasted one. **What the paste says about its author and its time is ignored, and the screen says so.** `id`, `pubkey`, `sig` and `created_at` never leave `GroupEventProposal`: what goes to `proposeSigning` is the kind, the tags and the content, and the session resolves the room's key, hashes the id over it and stamps the time it opens at -- which is what every typed editor does too. `created_at` in particular had to go. Every kind here is replaceable or addressable, so a pasted timestamp older than the group's last event would make the new one *lose* to the old on every reader and the proposal would look as though it had done nothing. The explanatory line under the byline names both facts, because a paste with a `pubkey` in it is the one case where a member could reasonably expect otherwise. **Only kinds the screen has a place for, and only events that place would accept -- checked before the quorum is asked, not after.** A signature is the most expensive thing this app does, and the readers refuse rather than repair: a kind 0 whose content is not a profile, a blank kind 1, a kind 31889 with no visibility would each be signed, filed and shown nowhere, with nothing to tell the member their quorum was spent on nothing. So `GroupEventProposal.read` makes every check a reader makes, on the paste, and refuses with the reader's own reason -- the schema's reasons are the same `CuratedSchemaProblem`s and the same sentences the schema editor shows, since they are the same checks. `ACCEPTED_KINDS` is the set of kinds with a section on the group's screen: 0, 1, the four relay lists and 31889. Not the set `ChatMessage.applyInnerEvent` has an arm for. A subgroup certificate or a key state event has invariants a paste cannot be trusted to meet and a place in the room's life that a form is not, and the nip30303 kinds have editors of their own. A long-form article is a perfectly good event the group could sign; it is refused because the line is "has somewhere to land", and the refusal names the kinds that do. An empty kind 0 is accepted, since wiping a profile is a thing groups are entitled to do, and an empty relay list is accepted, since it is a withdrawal the relays card shows as "no relays" rather than as nothing -- the same two lines `GroupNostrProfile.of` and `GroupRelayList` already draw. **The preview is the signing screen's own words.** Above the paste is what the group would sign -- "Kind 31889 · Curated schema" over "bitcoin.mov · public · 1 field" -- read live on every change, since a paste is a few kilobytes and one JSON parse is cheaper than a debounce that would hide the answer for a beat. It is `ProposedEvent.summarize` on the reading, not a description of this screen's own, so that what a member reads before proposing is word for word what every other member reads before signing. Two descriptions of one event would drift. **And `ProposedEvent.summarize` gains arms for kind 0, kind 1 and the relay lists.** The three features before this one added none for their kinds, so a member asked to sign the group's profile has been shown "Event of kind 0" over a line of JSON, and a relay list as "Event of kind 10002" over nothing at all. A profile is now said by its name and what it says about itself, with "An empty profile" and "Unreadable profile" kept apart because a signer should know which; a post by its words, the same call the translated-passage arm makes; a relay list by which of the four it is and its hosts, or "No relays" for a withdrawal. `ProposedEventTest` is new and pins all of them, the schema arm from the last commit included, and that an unrecognised kind keeps its number -- refusing to describe an event is better than describing it wrongly. **The paste stays on every refusal.** A refused reading is drawn under the field and the button, pressed anyway, is answered rather than ignored; a failed proposal is a snackbar over the same paste. The JSON is the thing worth keeping, and a member who pasted two kilobytes of schema should not have to find them again because a visibility tag was missing. The field is set in a monospace face for the same reason: it is JSON, and a bracket that does not line up is what a member is looking for in it. `isActionPending` guards a double press, because a pasted kind 1 accumulates like any other post and a second session over the same paste is a second post on the record forever. **The byline is the group's**, as on the post composer and for its reason: whatever is pasted goes out in the group's name, and a paste naming some other `pubkey` goes out in the group's name anyway. 23 new tests. `GroupEventProposalTest` (12) covers the reading -- what is read and what is dropped, junk named as junk, the accepted set, an unreadable profile against an empty one, a blank post, a schema refused with the reader's reasons, a relay list with or without relays -- and the four signed chains that are this commit's promise. `ProposedEventTest` (5) pins how each kind is said to a signer. `ProposeGroupEventScreenJvmTest` (4) types into the screen and checks the preview, the two refusals and the inert button for a member with no share, and two more cases in `GroupNostrProfileSectionJvmTest` put the entry last in the identity block and take it away from a member with no share and from a NIP-17 room. 1347 tests pass -- 861 in `:composeApp:jvmTest`, 486 in `:composeApp:testDebugUnitTest` -- `:composeApp:compileDebugKotlinAndroid` is clean, and `m3Audit` meets every budget. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Pulled-From: curated/curated@392b90b8d5
189 lines
7.8 KiB
Kotlin
189 lines
7.8 KiB
Kotlin
package press.mantra.compose.nostr
|
|
|
|
import press.mantra.compose.network.serialization.CommonJson
|
|
import press.mantra.compose.nostr.curated.CuratedSchemaEvent
|
|
import press.mantra.compose.nostr.curated.CuratedSchemaProblem
|
|
import com.vitorpamplona.quartz.nip01Core.core.Event
|
|
import com.vitorpamplona.quartz.nip01Core.core.Kind
|
|
import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent
|
|
import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
|
|
import kotlinx.serialization.json.JsonArray
|
|
import kotlinx.serialization.json.JsonElement
|
|
import kotlinx.serialization.json.JsonObject
|
|
import kotlinx.serialization.json.JsonPrimitive
|
|
import kotlinx.serialization.json.contentOrNull
|
|
import kotlinx.serialization.json.intOrNull
|
|
|
|
/**
|
|
* An unsigned event a member pasted for the group to sign, read and checked
|
|
* before a quorum is asked for it.
|
|
*
|
|
* The typed editors -- the profile form, the post composer, the relay and
|
|
* schema editors -- each build one kind of event and know what it needs. This
|
|
* is the untyped way in: the event as JSON, the way another tool prints it
|
|
* (`npm run schema:dry` in the bitcoin.mov repo prints exactly the schema event
|
|
* it would publish), pasted whole. What comes out is the same thing the editors
|
|
* produce, a kind, tags and content for `FrostSigningRepository.proposeSigning`,
|
|
* and it is filed the same way when the quorum signs: `FrostSigningManager.complete`
|
|
* records it as a `GroupSignedEvent` and the group's screen reads its profile,
|
|
* posts, relay lists and schemas straight off those by kind.
|
|
*
|
|
* ### What is ignored
|
|
*
|
|
* `id`, `pubkey`, `sig` and `created_at`. The group is the author -- the
|
|
* session resolves the room's key and hashes the id over it -- and the time is
|
|
* when the session opens, which is what every typed editor does too. A pasted
|
|
* `created_at` in particular has to go: the kinds here are replaceable or
|
|
* addressable, so a stale one would make the new event *older* than the last and
|
|
* the proposal would look as though it did nothing. The screen says so.
|
|
*
|
|
* ### What is refused, and why before rather than after
|
|
*
|
|
* Only kinds the group's screen has a place for, and only events that place
|
|
* would accept. A signature is the most expensive thing this app does, and the
|
|
* readers refuse rather than repair: a kind 0 whose content is not a profile,
|
|
* a blank post, a schema with no visibility would each be signed, filed and
|
|
* shown nowhere, with nothing to tell the member their quorum was spent on
|
|
* nothing. So every check a reader makes is made here first, on the paste.
|
|
*/
|
|
object GroupEventProposal {
|
|
/**
|
|
* The kinds a pasted event may be: exactly the ones the group's screen reads.
|
|
*
|
|
* Not the kinds `ChatMessage.applyInnerEvent` has an arm for. A subgroup
|
|
* certificate or a key state event has invariants a paste cannot be trusted
|
|
* to meet and a place in the room's life that a form is not, and the
|
|
* nip30303 kinds have editors of their own. The line is "has a section on the
|
|
* group's screen", because that is where a member goes to see it landed.
|
|
*/
|
|
val ACCEPTED_KINDS: Set<Kind> =
|
|
setOf(MetadataEvent.KIND, TextNoteEvent.KIND, CuratedSchemaEvent.KIND) + GroupRelaySet.KINDS
|
|
|
|
sealed interface Reading
|
|
|
|
/** An event the group could sign and the screen could show. */
|
|
class Ready(
|
|
val kind: Kind,
|
|
val tags: Array<Array<String>>,
|
|
val content: String,
|
|
) : Reading {
|
|
/**
|
|
* The event as the signing screen will describe it, for a preview before
|
|
* the button is pressed. The id, author and signature are placeholders:
|
|
* `ProposedEvent.summarize` reads none of them.
|
|
*/
|
|
fun preview(): Event = Event(
|
|
id = "",
|
|
pubKey = "",
|
|
createdAt = 0,
|
|
kind = kind,
|
|
tags = tags,
|
|
content = content,
|
|
sig = "",
|
|
)
|
|
}
|
|
|
|
/** Why the paste cannot be proposed as it is. */
|
|
sealed interface Refused : Reading {
|
|
data object NotJson : Refused
|
|
|
|
/** JSON, but an array, a string, a number -- anything but an object. */
|
|
data object NotAnObject : Refused
|
|
|
|
data object NoKind : Refused
|
|
|
|
data class KindNotAccepted(val kind: Kind) : Refused
|
|
|
|
/** `tags` present but not a list of lists of strings. */
|
|
data object TagsMalformed : Refused
|
|
|
|
/** `content` present but not a string. */
|
|
data object ContentNotText : Refused
|
|
|
|
/** A kind 0 whose content will not parse as a profile. */
|
|
data object ProfileUnreadable : Refused
|
|
|
|
/** A kind 1 with nothing to say, which the posts reader would drop. */
|
|
data object PostBlank : Refused
|
|
|
|
/** A kind 31889 the schema reader would refuse, with its reasons. */
|
|
data class SchemaUnusable(val problems: List<CuratedSchemaProblem>) : Refused
|
|
}
|
|
|
|
private const val KEY_KIND = "kind"
|
|
private const val KEY_TAGS = "tags"
|
|
private const val KEY_CONTENT = "content"
|
|
|
|
/** The paste as something the group could sign, or the reason it is not. */
|
|
fun read(json: String): Reading {
|
|
val element = runCatching { CommonJson.parseToJsonElement(json) }.getOrNull()
|
|
?: return Refused.NotJson
|
|
|
|
val obj = element as? JsonObject ?: return Refused.NotAnObject
|
|
|
|
// A number or a numeric string: relays write the former and a hand
|
|
// types either, and there is nothing to be gained by refusing "1".
|
|
val kind = (obj[KEY_KIND] as? JsonPrimitive)?.intOrNull ?: return Refused.NoKind
|
|
if (kind !in ACCEPTED_KINDS) return Refused.KindNotAccepted(kind)
|
|
|
|
val tags = when (val raw = obj[KEY_TAGS]) {
|
|
null -> emptyArray()
|
|
else -> readTags(raw) ?: return Refused.TagsMalformed
|
|
}
|
|
|
|
val content = when (val raw = obj[KEY_CONTENT]) {
|
|
null -> ""
|
|
else -> (raw as? JsonPrimitive)?.takeIf { it.isString }?.content
|
|
?: return Refused.ContentNotText
|
|
}
|
|
|
|
return when (kind) {
|
|
// An empty kind 0 is a profile the group has wiped, which it is
|
|
// entitled to do; content that is not JSON is not a profile at all.
|
|
// The same line `GroupNostrProfile.of` draws.
|
|
MetadataEvent.KIND -> if (metadataOf(tags, content) == null) {
|
|
Refused.ProfileUnreadable
|
|
} else {
|
|
Ready(kind, tags, content)
|
|
}
|
|
|
|
// The line `AddGroupPostViewModel.proposePost` draws, for the reason
|
|
// it gives: a blank post cannot be told from a broken one and, kind:1
|
|
// not being replaceable, cannot be un-said.
|
|
TextNoteEvent.KIND -> if (content.isBlank()) {
|
|
Refused.PostBlank
|
|
} else {
|
|
Ready(kind, tags, content)
|
|
}
|
|
|
|
CuratedSchemaEvent.KIND -> CuratedSchemaEvent.read(tags, content).problems().let { problems ->
|
|
if (problems.isEmpty()) Ready(kind, tags, content) else Refused.SchemaUnusable(problems)
|
|
}
|
|
|
|
// A relay list with no relay in it is a list the group has withdrawn,
|
|
// which the relays card shows as "no relays" rather than as nothing.
|
|
else -> Ready(kind, tags, content)
|
|
}
|
|
}
|
|
|
|
private fun readTags(raw: JsonElement): Array<Array<String>>? {
|
|
val outer = raw as? JsonArray ?: return null
|
|
|
|
return outer.map { tag ->
|
|
val inner = tag as? JsonArray ?: return null
|
|
inner.map { value ->
|
|
(value as? JsonPrimitive)?.takeIf { it.isString }?.contentOrNull ?: return null
|
|
}.toTypedArray()
|
|
}.toTypedArray()
|
|
}
|
|
|
|
private fun metadataOf(tags: Array<Array<String>>, content: String) = MetadataEvent(
|
|
id = "",
|
|
pubKey = "",
|
|
createdAt = 0,
|
|
tags = tags,
|
|
content = content,
|
|
sig = "",
|
|
).contactMetaData()
|
|
}
|