refactor: call it a chronicle, and keep "archive" for what a user does to a chat
Archiving a chat is an ordinary thing a user will want to do to a conversation, and it is not this. This is the group's signed record, handed to a member who joined after the work was done so their room stops being empty. Two unrelated meanings of one word in one app is a bug waiting to be written, and `ChatRoom.archiveRequestedAt` is exactly where they would have met: a column on the chat row, named for the thing that is not the chat. So the whole feature is Chronicle now -- `press.mantra.compose.nostr.chronicle`, `ChronicleEvent` (30327), `ChronicleRequestEvent` (30328), the three tags, `ChronicleManager`, `docs/member-chronicle.md`. The kind numbers do not move; only the words do. **The wire tags move too**, `archiveId` -> `chronicleId` and `archivePage` -> `chroniclePage`, which is free exactly once. Both kinds are new and there is no old build to stay compatible with -- the design note says so in as many words -- so the alternative was carrying the old spelling on the wire forever to save a rename that costs nothing today. The recipient tag stays `p`; it was never ours. **Schema v14, because two things had the old word written into stored data.** `ChatRoom.archiveRequestedAt` becomes `chronicleRequestedAt`, renamed rather than dropped and re-added: while it is set it is the only record that a device with an empty room has already asked the group for its history, and a device that lost it mid-flight would ask again on its next launch, and the one after that. The three `ChatMessage.messageType` strings become their `chronicle*` spellings, rewritten rather than left to a legacy constant the way `dkgApprovalNeeded` was. These lines cannot be regenerated -- a chronicle is announced once, when it is requested, sent and applied -- and an unrecognised type is not skipped by the transcript. It renders as an ordinary chat bubble, so "Caught up on 12 items" would come back attributed to a member as something they said. `MIGRATION_13_14` does both, because Room can rename a column and cannot rewrite rows in the same breath. `ALTER TABLE ... RENAME COLUMN` needs SQLite 3.25, which `getRoomDatabase` guarantees by pinning `BundledSQLiteDriver`, and the column is in no index, no foreign key, and there is not a view or trigger in the database -- so nothing has to move with it. Five tests hold the two halves apart: the value survives, the column keeps its position, a room that never asked still reads as never having asked, the three types are rewritten, and every other type is left alone. **`isArchivable` is `isChroniclable`**, on the "recyclable" pattern, and it keeps its job unchanged: the allowlist that stands between a replayed `GroupKeyStateEvent` and the apply path. No behaviour change beyond the rename. 797 tests pass. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
a69d80d38f
commit
ea11e8b233
33 files changed
+6506
-606
No files matched your search
@@ -520,7 +520,7 @@ class GroupKeyStateTest {
|
||||
// `isSignedByGroup` is the caller that knows a threshold key and a path.
|
||||
// `isSignedByRoom` is the same three checks with the walk already done, and
|
||||
// it is what a member holding neither has to work from -- see
|
||||
// docs/member-archive.md. What these pin down is that a room id alone is
|
||||
// docs/member-chronicle.md. What these pin down is that a room id alone is
|
||||
// enough, and that it is enough for any kind rather than only a key state.
|
||||
|
||||
/** An event of [kind] authored and signed by [author]'s room at [path]. */
|
||||
@@ -575,11 +575,11 @@ class GroupKeyStateTest {
|
||||
|
||||
@Test
|
||||
fun `the verifier does not care what kind it is looking at`() {
|
||||
// The point of splitting it out: an archive carries documents rather than
|
||||
// The point of splitting it out: a chronicle carries documents rather than
|
||||
// key states, and none of the three checks knows the difference.
|
||||
//
|
||||
// Note the last one. A GroupKeyStateEvent signed by the room passes here
|
||||
// exactly as a dialect does, which is why the archive needs an allowlist
|
||||
// exactly as a dialect does, which is why the chronicle needs an allowlist
|
||||
// of kinds on top of this and cannot treat "the group signed it" as
|
||||
// permission to apply it.
|
||||
listOf(
|
||||
@@ -629,7 +629,7 @@ class GroupKeyStateTest {
|
||||
|
||||
@Test
|
||||
fun `claiming the room as author proves nothing without the signature`() {
|
||||
// The forgery an archive would otherwise carry: write the room's id into
|
||||
// The forgery a chronicle would otherwise carry: write the room's id into
|
||||
// pubKey -- every member knows it, it is in the h tag of every kind:445
|
||||
// the group has ever sent -- and put anything at all in sig. The author
|
||||
// check and the id check both pass. The signature is the whole feature.
|
||||
@@ -677,7 +677,7 @@ class GroupKeyStateTest {
|
||||
|
||||
@Test
|
||||
fun `malformed input is a no rather than a throw`() {
|
||||
// Everything here arrives off the wire inside somebody else's archive, so
|
||||
// Everything here arrives off the wire inside somebody else's chronicle, so
|
||||
// the failure mode has to be a verdict. A throw would take down the whole
|
||||
// inbound transaction the page is being applied in.
|
||||
val signed = signedByRoom()
|
||||
|
||||
+99
-99
@@ -1,4 +1,4 @@
|
||||
package press.mantra.compose.nostr.archive
|
||||
package press.mantra.compose.nostr.chronicle
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import kotlin.test.Test
|
||||
@@ -8,9 +8,9 @@ import kotlin.test.assertFalse
|
||||
import kotlin.test.assertNotNull
|
||||
import kotlin.test.assertNull
|
||||
import kotlin.test.assertTrue
|
||||
import press.mantra.compose.nostr.archive.tags.ArchiveIdTag
|
||||
import press.mantra.compose.nostr.archive.tags.ArchivePageTag
|
||||
import press.mantra.compose.nostr.archive.tags.ArchiveRecipientTag
|
||||
import press.mantra.compose.nostr.chronicle.tags.ChronicleIdTag
|
||||
import press.mantra.compose.nostr.chronicle.tags.ChroniclePageTag
|
||||
import press.mantra.compose.nostr.chronicle.tags.ChronicleRecipientTag
|
||||
import press.mantra.compose.nostr.frost.FrostSigningEvents
|
||||
import press.mantra.compose.nostr.frost.GroupKeyStateEvent
|
||||
import press.mantra.compose.nostr.nip30303.ArtifactEvent
|
||||
@@ -28,16 +28,16 @@ import press.mantra.compose.nostr.nip30303.TranslationContributorListEvent
|
||||
import press.mantra.compose.nostr.nip30303.TranslationEvent
|
||||
|
||||
/**
|
||||
* The envelope an archive travels in: what it carries, what it refuses, and the
|
||||
* The envelope a chronicle travels in: what it carries, what it refuses, and the
|
||||
* order it puts things in.
|
||||
*
|
||||
* Nothing here verifies a signature -- that is `GroupKeyStateTest`'s half, and
|
||||
* `ArchiveManager`'s at apply time. What these cover is the framing, and the two
|
||||
* `ChronicleManager`'s at apply time. What these cover is the framing, and the two
|
||||
* bounds a page has to hold whoever sent it.
|
||||
*/
|
||||
class ArchiveEventTest {
|
||||
class ChronicleEventTest {
|
||||
private val recipient = "d".repeat(64)
|
||||
private val archiveId = "e".repeat(64)
|
||||
private val chronicleId = "e".repeat(64)
|
||||
|
||||
private fun payload(
|
||||
kind: Int,
|
||||
@@ -62,7 +62,7 @@ class ArchiveEventTest {
|
||||
payload(ArtifactEvent.KIND, "an artifact"),
|
||||
)
|
||||
|
||||
val decoded = ArchiveEvent.decodePage(ArchiveEvent.encodePage(events))
|
||||
val decoded = ChronicleEvent.decodePage(ChronicleEvent.encodePage(events))
|
||||
|
||||
assertNotNull(decoded)
|
||||
assertEquals(events.map { it.content }, decoded.map { it.content })
|
||||
@@ -78,26 +78,26 @@ class ArchiveEventTest {
|
||||
fun `a page of one is still an array`() {
|
||||
// The contrast with FrostSigningEvents.encodeProposal, which keeps a bare
|
||||
// object for a batch of one so that builds predating batching can read
|
||||
// it. Both archive kinds are new, so there is no such build and no reason
|
||||
// it. Both chronicle kinds are new, so there is no such build and no reason
|
||||
// to carry the second shape.
|
||||
val encoded = ArchiveEvent.encodePage(listOf(payload(DialectEvent.KIND)))
|
||||
val encoded = ChronicleEvent.encodePage(listOf(payload(DialectEvent.KIND)))
|
||||
|
||||
assertTrue(encoded.startsWith("["), "a page is always an array")
|
||||
assertTrue(encoded.endsWith("]"))
|
||||
assertEquals(1, ArchiveEvent.decodePage(encoded)?.size)
|
||||
assertEquals(1, ChronicleEvent.decodePage(encoded)?.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a page that is not an array is not a page`() {
|
||||
assertNull(ArchiveEvent.decodePage(payload(DialectEvent.KIND).toJson()))
|
||||
assertNull(ArchiveEvent.decodePage(""))
|
||||
assertNull(ArchiveEvent.decodePage("not json"))
|
||||
assertNull(ArchiveEvent.decodePage("[unclosed"))
|
||||
assertNull(ChronicleEvent.decodePage(payload(DialectEvent.KIND).toJson()))
|
||||
assertNull(ChronicleEvent.decodePage(""))
|
||||
assertNull(ChronicleEvent.decodePage("not json"))
|
||||
assertNull(ChronicleEvent.decodePage("[unclosed"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an empty page is refused rather than read as an empty archive`() {
|
||||
assertNull(ArchiveEvent.decodePage("[]"))
|
||||
fun `an empty page is refused rather than read as an empty chronicle`() {
|
||||
assertNull(ChronicleEvent.decodePage("[]"))
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -105,29 +105,29 @@ class ArchiveEventTest {
|
||||
// All-or-nothing here, and per-payload at verify time. They answer
|
||||
// different questions: a page that will not parse has lost its framing,
|
||||
// so a page quietly shortened by one element would report a complete
|
||||
// archive on its page count while holding less than it says. A payload
|
||||
// chronicle on its page count while holding less than it says. A payload
|
||||
// whose signature does not verify is a well-framed page with one bad
|
||||
// event in it, and dropping its honest neighbours would let one forgery
|
||||
// deny an entire archive.
|
||||
// deny an entire chronicle.
|
||||
val good = payload(DialectEvent.KIND)
|
||||
|
||||
assertNull(ArchiveEvent.decodePage("[${good.toJson()},\"not an event\"]"))
|
||||
assertNull(ArchiveEvent.decodePage("[\"not an event\",${good.toJson()}]"))
|
||||
assertNull(ChronicleEvent.decodePage("[${good.toJson()},\"not an event\"]"))
|
||||
assertNull(ChronicleEvent.decodePage("[\"not an event\",${good.toJson()}]"))
|
||||
}
|
||||
|
||||
// ---- The two caps ----------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `a page over the event cap is refused on the way in`() {
|
||||
val overCap = (0..ArchiveEvent.MAX_PAGE_EVENTS).map {
|
||||
val overCap = (0..ChronicleEvent.MAX_PAGE_EVENTS).map {
|
||||
payload(DialectEvent.KIND, id = it.toString().padStart(64, '0'))
|
||||
}
|
||||
assertEquals(ArchiveEvent.MAX_PAGE_EVENTS + 1, overCap.size)
|
||||
assertEquals(ChronicleEvent.MAX_PAGE_EVENTS + 1, overCap.size)
|
||||
|
||||
assertNull(ArchiveEvent.decodePage(ArchiveEvent.encodePage(overCap)))
|
||||
assertNull(ChronicleEvent.decodePage(ChronicleEvent.encodePage(overCap)))
|
||||
|
||||
// And exactly at the cap is fine, so the bound is not off by one.
|
||||
assertNotNull(ArchiveEvent.decodePage(ArchiveEvent.encodePage(overCap.dropLast(1))))
|
||||
assertNotNull(ChronicleEvent.decodePage(ChronicleEvent.encodePage(overCap.dropLast(1))))
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -137,30 +137,30 @@ class ArchiveEventTest {
|
||||
// count cap set above what MAX_PAGE_BYTES can hold is a check that never
|
||||
// runs, and the page-over-the-cap test above passes for the wrong reason.
|
||||
// Both caps have to be re-sized together; this is what says so.
|
||||
val atCap = (0 until ArchiveEvent.MAX_PAGE_EVENTS).map {
|
||||
val atCap = (0 until ChronicleEvent.MAX_PAGE_EVENTS).map {
|
||||
payload(DialectEvent.KIND, id = it.toString().padStart(64, '0'))
|
||||
}
|
||||
val page = ArchiveEvent.encodePage(atCap)
|
||||
val page = ChronicleEvent.encodePage(atCap)
|
||||
|
||||
assertTrue(
|
||||
page.encodeToByteArray().size <= ArchiveEvent.MAX_PAGE_BYTES,
|
||||
page.encodeToByteArray().size <= ChronicleEvent.MAX_PAGE_BYTES,
|
||||
"MAX_PAGE_EVENTS payloads take ${page.encodeToByteArray().size} bytes, " +
|
||||
"over the ${ArchiveEvent.MAX_PAGE_BYTES}-byte cap: the event cap can never fire"
|
||||
"over the ${ChronicleEvent.MAX_PAGE_BYTES}-byte cap: the event cap can never fire"
|
||||
)
|
||||
assertEquals(ArchiveEvent.MAX_PAGE_EVENTS, ArchiveEvent.decodePage(page)?.size)
|
||||
assertEquals(ChronicleEvent.MAX_PAGE_EVENTS, ChronicleEvent.decodePage(page)?.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a page over the byte cap is refused on the way in`() {
|
||||
// Checked before the JSON is parsed, because the cap exists to bound the
|
||||
// work a remote party can ask this device to do. An archive is the second
|
||||
// work a remote party can ask this device to do. A chronicle is the second
|
||||
// place in this protocol where somebody else sets that size.
|
||||
val huge = ArchiveEvent.encodePage(
|
||||
listOf(payload(DialectEvent.KIND, content = "x".repeat(ArchiveEvent.MAX_PAGE_BYTES)))
|
||||
val huge = ChronicleEvent.encodePage(
|
||||
listOf(payload(DialectEvent.KIND, content = "x".repeat(ChronicleEvent.MAX_PAGE_BYTES)))
|
||||
)
|
||||
assertTrue(huge.encodeToByteArray().size > ArchiveEvent.MAX_PAGE_BYTES)
|
||||
assertTrue(huge.encodeToByteArray().size > ChronicleEvent.MAX_PAGE_BYTES)
|
||||
|
||||
assertNull(ArchiveEvent.decodePage(huge))
|
||||
assertNull(ChronicleEvent.decodePage(huge))
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -168,15 +168,15 @@ class ArchiveEventTest {
|
||||
// A multi-byte payload that fits as characters and does not as bytes. If
|
||||
// this were measured in characters the cap would let through up to four
|
||||
// times what it says.
|
||||
val wide = "世".repeat(ArchiveEvent.MAX_PAGE_BYTES / 2)
|
||||
val page = ArchiveEvent.encodePage(listOf(payload(DialectEvent.KIND, content = wide)))
|
||||
val wide = "世".repeat(ChronicleEvent.MAX_PAGE_BYTES / 2)
|
||||
val page = ChronicleEvent.encodePage(listOf(payload(DialectEvent.KIND, content = wide)))
|
||||
|
||||
assertTrue(page.length < ArchiveEvent.MAX_PAGE_BYTES)
|
||||
assertTrue(page.encodeToByteArray().size > ArchiveEvent.MAX_PAGE_BYTES)
|
||||
assertNull(ArchiveEvent.decodePage(page))
|
||||
assertTrue(page.length < ChronicleEvent.MAX_PAGE_BYTES)
|
||||
assertTrue(page.encodeToByteArray().size > ChronicleEvent.MAX_PAGE_BYTES)
|
||||
assertNull(ChronicleEvent.decodePage(page))
|
||||
}
|
||||
|
||||
// ---- What an archive may carry ---------------------------------------
|
||||
// ---- What a chronicle may carry ---------------------------------------
|
||||
|
||||
@Test
|
||||
fun `the allowlist is every kind the group signs, and only those`() {
|
||||
@@ -189,72 +189,72 @@ class ArchiveEventTest {
|
||||
TranslationArtifactVersionEvent.KIND,
|
||||
TranslationChapterEvent.KIND,
|
||||
TranslationChunkEvent.KIND,
|
||||
).forEach { assertTrue(ArchiveEvent.isArchivable(it), "kind $it should be archivable") }
|
||||
).forEach { assertTrue(ChronicleEvent.isChroniclable(it), "kind $it should be chroniclable") }
|
||||
|
||||
assertEquals(8, ArchiveEvent.ARCHIVABLE_KINDS.size)
|
||||
assertEquals(8, ChronicleEvent.CHRONICLABLE_KINDS.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the translated text is archivable, now that the group signs it`() {
|
||||
fun `the translated text is chroniclable, now that the group signs it`() {
|
||||
// Worth its own case because it was the feature's headline limitation for
|
||||
// as long as `saveTranslation` wrote the row and queued the member's own
|
||||
// rumor. An archive can only carry what the receiver can check, so a
|
||||
// rumor. A chronicle can only carry what the receiver can check, so a
|
||||
// translation nobody signed could not travel, and a new member got the
|
||||
// whole structure and none of the prose.
|
||||
assertTrue(ArchiveEvent.isArchivable(TranslationChunkEvent.KIND))
|
||||
assertTrue(ChronicleEvent.isChroniclable(TranslationChunkEvent.KIND))
|
||||
|
||||
// Same shape, same fix: the version an artifact starts life with used to
|
||||
// be derived on arrival, so it was authored by the group with no
|
||||
// signature to show for it. It is the second item of the artifact's batch
|
||||
// now.
|
||||
assertTrue(ArchiveEvent.isArchivable(ArtifactVersionEvent.KIND))
|
||||
assertTrue(ChronicleEvent.isChroniclable(ArtifactVersionEvent.KIND))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the kinds nobody signs are still left out`() {
|
||||
// Nothing builds one of these at all; the inbound arm exists and no
|
||||
// producer does.
|
||||
assertFalse(ArchiveEvent.isArchivable(TranslationEvent.KIND))
|
||||
assertFalse(ChronicleEvent.isChroniclable(TranslationEvent.KIND))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an archive may not carry a key state, however well signed`() {
|
||||
fun `a chronicle may not carry a key state, however well signed`() {
|
||||
// The attack the allowlist exists for, and the reason it is an allowlist
|
||||
// rather than a denylist. A GroupKeyStateEvent is signed by the room and
|
||||
// passes verification perfectly, so an archive carrying an old one is a
|
||||
// passes verification perfectly, so a chronicle carrying an old one is a
|
||||
// validly signed statement about what the room signs with, replayed by
|
||||
// whoever kept a copy. Nothing but this stops it.
|
||||
assertFalse(ArchiveEvent.isArchivable(GroupKeyStateEvent.KIND))
|
||||
assertNull(ArchiveEvent.applyRank(GroupKeyStateEvent.KIND))
|
||||
assertFalse(ChronicleEvent.isChroniclable(GroupKeyStateEvent.KIND))
|
||||
assertNull(ChronicleEvent.applyRank(GroupKeyStateEvent.KIND))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an archive carries documents, not envelopes or protocol`() {
|
||||
assertFalse(ArchiveEvent.isArchivable(SubmissionEvent.KIND))
|
||||
assertFalse(ArchiveEvent.isArchivable(ArchiveEvent.KIND))
|
||||
assertFalse(ArchiveEvent.isArchivable(ArchiveRequestEvent.KIND))
|
||||
fun `a chronicle carries documents, not envelopes or protocol`() {
|
||||
assertFalse(ChronicleEvent.isChroniclable(SubmissionEvent.KIND))
|
||||
assertFalse(ChronicleEvent.isChroniclable(ChronicleEvent.KIND))
|
||||
assertFalse(ChronicleEvent.isChroniclable(ChronicleRequestEvent.KIND))
|
||||
FrostSigningEvents.ALL.forEach {
|
||||
assertFalse(ArchiveEvent.isArchivable(it), "frost kind $it")
|
||||
assertFalse(ChronicleEvent.isChroniclable(it), "frost kind $it")
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the contributor lists are left out while nothing applies them`() {
|
||||
// Not an oversight. ChatMessage.applyInnerEvent has no arm that writes a
|
||||
// row for any of these, so archiving them would cost bytes and restore
|
||||
// row for any of these, so chronicling them would cost bytes and restore
|
||||
// nothing. They belong in the list on the day that changes.
|
||||
assertFalse(ArchiveEvent.isArchivable(TranslationArtifactVersionContributorListEvent.KIND))
|
||||
assertFalse(ArchiveEvent.isArchivable(TranslationChapterContributorListEvent.KIND))
|
||||
assertFalse(ArchiveEvent.isArchivable(TranslationContributorListEvent.KIND))
|
||||
assertFalse(ChronicleEvent.isChroniclable(TranslationArtifactVersionContributorListEvent.KIND))
|
||||
assertFalse(ChronicleEvent.isChroniclable(TranslationChapterContributorListEvent.KIND))
|
||||
assertFalse(ChronicleEvent.isChroniclable(TranslationContributorListEvent.KIND))
|
||||
}
|
||||
|
||||
// ---- Dependency order ------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `the order is the foreign keys, not the kind numbers`() {
|
||||
fun rank(kind: Int) = assertNotNull(ArchiveEvent.applyRank(kind), "kind $kind has no rank")
|
||||
fun rank(kind: Int) = assertNotNull(ChronicleEvent.applyRank(kind), "kind $kind has no rank")
|
||||
|
||||
// Every one of these is a foreign key in Room, so an archive applied the
|
||||
// Every one of these is a foreign key in Room, so a chronicle applied the
|
||||
// other way round is a constraint violation rather than a wrong answer.
|
||||
assertTrue(rank(DialectEvent.KIND) < rank(ArtifactEvent.KIND))
|
||||
assertTrue(rank(ArtifactEvent.KIND) < rank(ArtifactVersionEvent.KIND))
|
||||
@@ -300,7 +300,7 @@ class ArchiveEventTest {
|
||||
TranslationChapterEvent.KIND,
|
||||
TranslationChunkEvent.KIND,
|
||||
),
|
||||
ArchiveEvent.inApplyOrder(jumbled).map { it.kind }
|
||||
ChronicleEvent.inApplyOrder(jumbled).map { it.kind }
|
||||
)
|
||||
}
|
||||
|
||||
@@ -308,7 +308,7 @@ class ArchiveEventTest {
|
||||
fun `two of a kind keep the order they were handed in`() {
|
||||
// Nothing orders two chapters of one version, and keeping the caller's
|
||||
// order is one less thing that differs between two members assembling the
|
||||
// same archive.
|
||||
// same chronicle.
|
||||
val chapters = listOf(
|
||||
payload(ChapterEvent.KIND, "one", id = "1".repeat(64)),
|
||||
payload(ChapterEvent.KIND, "two", id = "2".repeat(64)),
|
||||
@@ -317,24 +317,24 @@ class ArchiveEventTest {
|
||||
|
||||
assertEquals(
|
||||
listOf("one", "two", "three"),
|
||||
ArchiveEvent.inApplyOrder(chapters).map { it.content }
|
||||
ChronicleEvent.inApplyOrder(chapters).map { it.content }
|
||||
)
|
||||
}
|
||||
|
||||
// ---- The tags --------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `a page says which archive it is, where in it, and who for`() {
|
||||
val template = ArchiveEvent.build(
|
||||
fun `a page says which chronicle it is, where in it, and who for`() {
|
||||
val template = ChronicleEvent.build(
|
||||
payloads = listOf(payload(DialectEvent.KIND)),
|
||||
archiveId = archiveId,
|
||||
chronicleId = chronicleId,
|
||||
index = 2,
|
||||
count = 5,
|
||||
recipient = recipient,
|
||||
createdAt = 1_700_000_100L
|
||||
)
|
||||
|
||||
val readBack = ArchiveEvent(
|
||||
val readBack = ChronicleEvent(
|
||||
id = "f".repeat(64),
|
||||
pubKey = "b".repeat(64),
|
||||
createdAt = template.createdAt,
|
||||
@@ -343,7 +343,7 @@ class ArchiveEventTest {
|
||||
sig = ""
|
||||
)
|
||||
|
||||
assertEquals(archiveId, readBack.archiveId())
|
||||
assertEquals(chronicleId, readBack.chronicleId())
|
||||
assertEquals(2, readBack.page()?.index)
|
||||
assertEquals(5, readBack.page()?.count)
|
||||
assertEquals(recipient, readBack.recipient())
|
||||
@@ -351,31 +351,31 @@ class ArchiveEventTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a page outside its own archive is not a page`() {
|
||||
fun `a page outside its own chronicle is not a page`() {
|
||||
// Refused rather than clamped: the pair is how a receiver decides it has
|
||||
// everything, so a repaired one would let a truncated archive read as
|
||||
// everything, so a repaired one would let a truncated chronicle read as
|
||||
// complete.
|
||||
assertNull(ArchivePageTag.parse(arrayOf(ArchivePageTag.TAG_NAME, "5", "3")))
|
||||
assertNull(ArchivePageTag.parse(arrayOf(ArchivePageTag.TAG_NAME, "-1", "3")))
|
||||
assertNull(ArchivePageTag.parse(arrayOf(ArchivePageTag.TAG_NAME, "0", "0")))
|
||||
assertNull(ArchivePageTag.parse(arrayOf(ArchivePageTag.TAG_NAME, "0")))
|
||||
assertNull(ArchivePageTag.parse(arrayOf(ArchivePageTag.TAG_NAME, "one", "3")))
|
||||
assertNull(ChroniclePageTag.parse(arrayOf(ChroniclePageTag.TAG_NAME, "5", "3")))
|
||||
assertNull(ChroniclePageTag.parse(arrayOf(ChroniclePageTag.TAG_NAME, "-1", "3")))
|
||||
assertNull(ChroniclePageTag.parse(arrayOf(ChroniclePageTag.TAG_NAME, "0", "0")))
|
||||
assertNull(ChroniclePageTag.parse(arrayOf(ChroniclePageTag.TAG_NAME, "0")))
|
||||
assertNull(ChroniclePageTag.parse(arrayOf(ChroniclePageTag.TAG_NAME, "one", "3")))
|
||||
|
||||
assertEquals(0, ArchivePageTag.parse(arrayOf(ArchivePageTag.TAG_NAME, "0", "1"))?.index)
|
||||
assertTrue(ArchivePageTag.parse(arrayOf(ArchivePageTag.TAG_NAME, "0", "1"))!!.isOnlyPage())
|
||||
assertEquals(0, ChroniclePageTag.parse(arrayOf(ChroniclePageTag.TAG_NAME, "0", "1"))?.index)
|
||||
assertTrue(ChroniclePageTag.parse(arrayOf(ChroniclePageTag.TAG_NAME, "0", "1"))!!.isOnlyPage())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an archive id and a recipient have to be there to be read`() {
|
||||
assertNull(ArchiveIdTag.parse(arrayOf(ArchiveIdTag.TAG_NAME)))
|
||||
assertNull(ArchiveIdTag.parse(arrayOf(ArchiveIdTag.TAG_NAME, " ")))
|
||||
assertNull(ArchiveIdTag.parse(arrayOf("other", archiveId)))
|
||||
assertEquals(archiveId, ArchiveIdTag.parse(arrayOf(ArchiveIdTag.TAG_NAME, archiveId))?.archiveId)
|
||||
fun `a chronicle id and a recipient have to be there to be read`() {
|
||||
assertNull(ChronicleIdTag.parse(arrayOf(ChronicleIdTag.TAG_NAME)))
|
||||
assertNull(ChronicleIdTag.parse(arrayOf(ChronicleIdTag.TAG_NAME, " ")))
|
||||
assertNull(ChronicleIdTag.parse(arrayOf("other", chronicleId)))
|
||||
assertEquals(chronicleId, ChronicleIdTag.parse(arrayOf(ChronicleIdTag.TAG_NAME, chronicleId))?.chronicleId)
|
||||
|
||||
assertNull(ArchiveRecipientTag.parse(arrayOf(ArchiveRecipientTag.TAG_NAME, "short")))
|
||||
assertNull(ChronicleRecipientTag.parse(arrayOf(ChronicleRecipientTag.TAG_NAME, "short")))
|
||||
assertEquals(
|
||||
recipient,
|
||||
ArchiveRecipientTag.parse(arrayOf(ArchiveRecipientTag.TAG_NAME, recipient))?.pubKey
|
||||
ChronicleRecipientTag.parse(arrayOf(ChronicleRecipientTag.TAG_NAME, recipient))?.pubKey
|
||||
)
|
||||
}
|
||||
|
||||
@@ -387,34 +387,34 @@ class ArchiveEventTest {
|
||||
// security -- the inbound one is the boundary -- but a page nobody can
|
||||
// read back is better failed where it was made.
|
||||
assertFailsWith<IllegalArgumentException> {
|
||||
ArchiveEvent.build(emptyList(), archiveId, 0, 1, recipient)
|
||||
ChronicleEvent.build(emptyList(), chronicleId, 0, 1, recipient)
|
||||
}
|
||||
assertFailsWith<IllegalArgumentException> {
|
||||
ArchiveEvent.build(
|
||||
payloads = (0..ArchiveEvent.MAX_PAGE_EVENTS).map {
|
||||
ChronicleEvent.build(
|
||||
payloads = (0..ChronicleEvent.MAX_PAGE_EVENTS).map {
|
||||
payload(DialectEvent.KIND, id = it.toString().padStart(64, '0'))
|
||||
},
|
||||
archiveId = archiveId,
|
||||
chronicleId = chronicleId,
|
||||
index = 0,
|
||||
count = 1,
|
||||
recipient = recipient
|
||||
)
|
||||
}
|
||||
assertFailsWith<IllegalArgumentException> {
|
||||
ArchiveEvent.build(
|
||||
ChronicleEvent.build(
|
||||
payloads = listOf(payload(GroupKeyStateEvent.KIND)),
|
||||
archiveId = archiveId,
|
||||
chronicleId = chronicleId,
|
||||
index = 0,
|
||||
count = 1,
|
||||
recipient = recipient
|
||||
)
|
||||
}
|
||||
assertFailsWith<IllegalArgumentException> {
|
||||
ArchiveEvent.build(
|
||||
ChronicleEvent.build(
|
||||
payloads = listOf(
|
||||
payload(DialectEvent.KIND, content = "x".repeat(ArchiveEvent.MAX_PAGE_BYTES))
|
||||
payload(DialectEvent.KIND, content = "x".repeat(ChronicleEvent.MAX_PAGE_BYTES))
|
||||
),
|
||||
archiveId = archiveId,
|
||||
chronicleId = chronicleId,
|
||||
index = 0,
|
||||
count = 1,
|
||||
recipient = recipient
|
||||
@@ -424,9 +424,9 @@ class ArchiveEventTest {
|
||||
|
||||
@Test
|
||||
fun `a request carries nothing but what it is`() {
|
||||
val template = ArchiveRequestEvent.build(createdAt = 1_700_000_100L)
|
||||
val template = ChronicleRequestEvent.build(createdAt = 1_700_000_100L)
|
||||
|
||||
assertEquals(ArchiveRequestEvent.KIND, template.kind)
|
||||
assertEquals(ChronicleRequestEvent.KIND, template.kind)
|
||||
assertEquals("", template.content)
|
||||
// The room is the envelope and the asker is the MLS sender, so there is
|
||||
// nothing left for the event itself to say.
|
||||
+11
-11
@@ -1,4 +1,4 @@
|
||||
package press.mantra.compose.nostr.archive
|
||||
package press.mantra.compose.nostr.chronicle
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray
|
||||
@@ -39,24 +39,24 @@ import press.mantra.compose.nostr.nip30303.TranslationChapterEvent
|
||||
import press.mantra.compose.nostr.nip30303.TranslationChunkEvent
|
||||
|
||||
/**
|
||||
* The assumption the whole archive rests on: a row can be turned back into the
|
||||
* The assumption the whole chronicle rests on: a row can be turned back into the
|
||||
* event the group signed.
|
||||
*
|
||||
* Signed events are not stored as events. `FrostSigningManager.complete` applies
|
||||
* them and what survives is a `Mantra*` row, so an archive has to rebuild each
|
||||
* them and what survives is a `Mantra*` row, so a chronicle has to rebuild each
|
||||
* one with `toXEvent()` and hope it comes out byte-identical. If it does not,
|
||||
* the id changes, the signature no longer covers it, and every receiver drops
|
||||
* the payload as a forgery -- silently, one kind at a time.
|
||||
*
|
||||
* That is what the round-trip note on each `toXEvent` claims and what nothing
|
||||
* asserted until now. Every kind in `ArchiveEvent.ARCHIVABLE_KINDS` is checked
|
||||
* asserted until now. Every kind in `ChronicleEvent.CHRONICLABLE_KINDS` is checked
|
||||
* here against a real FROST quorum, because a signature that verifies is the
|
||||
* only evidence that the rebuild is faithful -- comparing fields would only
|
||||
* prove the test and the code agree about which fields matter.
|
||||
*
|
||||
* A kind that fails here cannot be archived at all, whatever the allowlist says.
|
||||
* A kind that fails here cannot be chronicled at all, whatever the allowlist says.
|
||||
*/
|
||||
class ArchiveRoundTripTest {
|
||||
class ChronicleRoundTripTest {
|
||||
private val participants = 3
|
||||
private val threshold = 2
|
||||
|
||||
@@ -267,7 +267,7 @@ class ArchiveRoundTripTest {
|
||||
@Test
|
||||
fun `an artifact version survives the trip through a row`() {
|
||||
// The second fault of the same shape, found the moment this kind became
|
||||
// archivable: `toArtifactVersionEvent` emitted [artifactId, alt] where
|
||||
// chroniclable: `toArtifactVersionEvent` emitted [artifactId, alt] where
|
||||
// `build` emits [alt, artifactId]. It had been that way for as long as
|
||||
// nothing called it, which is what makes an unused rebuild dangerous
|
||||
// rather than merely dead -- the claim in its comment was never checked.
|
||||
@@ -300,7 +300,7 @@ class ArchiveRoundTripTest {
|
||||
|
||||
@Test
|
||||
fun `a translated chunk survives the trip through a row`() {
|
||||
// The translated text. Archivable only because the app stopped saving it
|
||||
// The translated text. Chroniclable only because the app stopped saving it
|
||||
// as its author's rumor and started asking the group to sign it -- until
|
||||
// then a new member got the whole structure and none of the prose.
|
||||
assertRoundTrips(
|
||||
@@ -401,14 +401,14 @@ class ArchiveRoundTripTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `every archivable kind has a round-trip test above`() {
|
||||
fun `every chroniclable kind has a round-trip test above`() {
|
||||
// The list and this file have to move together. A kind added to the
|
||||
// allowlist without a case here is one nobody has checked can be rebuilt,
|
||||
// and the failure is silent: the receiver drops it as a forgery.
|
||||
assertEquals(
|
||||
8,
|
||||
ArchiveEvent.ARCHIVABLE_KINDS.size,
|
||||
"an archivable kind was added or removed; add or remove its round-trip case"
|
||||
ChronicleEvent.CHRONICLABLE_KINDS.size,
|
||||
"a chroniclable kind was added or removed; add or remove its round-trip case"
|
||||
)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user