feat(marmot): honour disappearing messages

`marmot.group.message-retention.v1` decoded into `MarmotGroupState.retention`
and then nothing read it. In a group with disappearing messages enabled, every
member's copy vanished on schedule except Amethyst's, which kept the plaintext
indefinitely — not a wire incompatibility, the group still worked, but a
privacy divergence from what that group was told it had.

Expiry is pinned per message when it enters the log, never recomputed. That is
the component's rule and it is the easy one to get wrong: a message keeps the
retention of its OWN source epoch, so changing the setting later must not
shorten, extend, or restore the expiry of a message that already exists.
Recomputing from the current setting would let one member retroactively
shorten everyone's history, or resurrect what should already be gone. First
write wins for the same reason — the ratchet rewinds on restart and relays
replay recent kind:445s, so the same message really is persisted twice, and a
second write that re-timed it would let a message postpone its own expiry
every time it was replayed.

The retention itself is read from the `0x8005` component with a fallback to a
legacy group's `0xF2EE` field, because the two profiles express the same
setting in different places and reading only one would silently treat half the
groups as having no expiry.

Expiring deletes rather than hides. This store is the only copy — the ratchet
moved past the ciphertext it came from long ago — so a message that is merely
filtered out of a read is still on disk, and a disappearing message that is
gone from disk but still on screen has not disappeared either. Both stores
rewrite their logs, reads prune first so a restart cannot show something that
fell due while the app was closed, and the front end is told what went so it
can drop those rows from a conversation already open.

Traffic is the clock: a group being read is a group whose expired messages
should already be gone. There is no timer, so a group nobody opens keeps its
messages until someone does — worth knowing, and better than a wakeup that
exists only to delete.

Expiry stays advisory by design, as the component says: the duration is
authenticated but the base is the sender's own `created_at`, so it inherits
the trust already placed in an MLS-authenticated sender and is not a guarantee
against a hostile one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016kCuA6tc4JQzHPCDd39GHq
This commit is contained in:
Claude
2026-09-09 23:15:18 +00:00
parent d7b5884000
commit b3b4fdaf43
9 changed files with 533 additions and 4 deletions
@@ -3800,6 +3800,15 @@ class Account(
marmotGroupList.addMessage(groupId, note)
}
// A disappearing message that is gone from disk but still on screen
// has not disappeared. Drop it from the conversation as it expires,
// rather than waiting for the next read to omit it.
marmotManager.onMessagesExpired = { groupId, expiredIds ->
expiredIds.forEach { id ->
cache.getNoteIfExists(id)?.let { marmotGroupList.removeMessage(groupId, it) }
}
}
scope.launch(Dispatchers.IO) {
marmotManager.restoreAll()
@@ -22,6 +22,7 @@ package com.vitorpamplona.amethyst.model.marmot
import com.vitorpamplona.amethyst.model.preferences.KeyStoreEncryption
import com.vitorpamplona.quartz.marmot.mls.group.MarmotMessageStore
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.utils.Log
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.sync.Mutex
@@ -111,7 +112,7 @@ class AndroidMarmotMessageStore(
override suspend fun delete(nostrGroupId: String) {
withContext(Dispatchers.IO) {
writeMutex.withLock {
for (file in listOf(messagesFile(nostrGroupId), epochsFile(nostrGroupId), snapshotFile(nostrGroupId))) {
for (file in listOf(messagesFile(nostrGroupId), epochsFile(nostrGroupId), snapshotFile(nostrGroupId), expiriesFile(nostrGroupId))) {
if (file.exists() && !file.delete()) {
Log.w(TAG) { "delete($nostrGroupId): failed to remove ${file.absolutePath}" }
}
@@ -166,6 +167,82 @@ class AndroidMarmotMessageStore(
}
}
private fun expiriesFile(nostrGroupId: String): File = File(groupDir(nostrGroupId), "expiries")
/**
* When a message stops being displayable, for a group that expires them.
*
* Encrypted like the messages: an expiry names an inner event id and says
* roughly when it was sent, which is conversation metadata.
*
* First write wins. The expiry is pinned to the retention of the message's
* own source epoch, so re-persisting the same message after a restart —
* which happens, because the ratchet rewinds and relays replay — must not
* re-time it under whatever the setting has since become.
*/
override suspend fun recordExpiry(
nostrGroupId: String,
innerEventId: String,
expiresAtSecs: Long,
) = withContext(Dispatchers.IO) {
writeMutex.withLock {
try {
val existing = readAllFrom(expiriesFile(nostrGroupId)).toMutableList()
if (existing.any { it.substringBefore(' ') == innerEventId }) return@withLock
existing.add("$innerEventId $expiresAtSecs")
writeAllTo(expiriesFile(nostrGroupId), existing)
} catch (e: Exception) {
Log.e(TAG, "recordExpiry($nostrGroupId) FAILED: ${e.message}", e)
}
}
}
override suspend fun loadExpiries(nostrGroupId: String): Map<String, Long> =
withContext(Dispatchers.IO) {
try {
readAllFrom(expiriesFile(nostrGroupId))
.mapNotNull { line ->
val parts = line.trim().split(' ')
if (parts.size != 2) return@mapNotNull null
val at = parts[1].toLongOrNull() ?: return@mapNotNull null
parts[0] to at
}.toMap()
} catch (e: Exception) {
Log.e(TAG, "loadExpiries($nostrGroupId) FAILED: ${e.message}", e)
emptyMap()
}
}
/**
* Delete messages and forget their expiries, rewriting both logs.
*
* A rewrite rather than a tombstone: the point of a disappearing message
* is that the plaintext is gone from disk, and this store holds the only
* copy — the ratchet moved past the ciphertext it came from long ago.
*/
override suspend fun removeMessages(
nostrGroupId: String,
innerEventIds: Set<String>,
) = withContext(Dispatchers.IO) {
if (innerEventIds.isEmpty()) return@withContext
writeMutex.withLock {
try {
val kept =
readAll(nostrGroupId).filter { json ->
val id = Event.fromJsonOrNull(json)?.id
id == null || id !in innerEventIds
}
writeAll(nostrGroupId, kept)
val keptExpiries =
readAllFrom(expiriesFile(nostrGroupId)).filter { it.substringBefore(' ') !in innerEventIds }
writeAllTo(expiriesFile(nostrGroupId), keptExpiries)
} catch (e: Exception) {
Log.e(TAG, "removeMessages($nostrGroupId) FAILED: ${e.message}", e)
}
}
}
private fun snapshotFile(nostrGroupId: String): File = File(groupDir(nostrGroupId), "snapshot")
/**
@@ -731,6 +731,11 @@ class GroupEventHandler(
// `MarmotGroupList.isDisplayableFeedMessage`.
account.marmotGroupList.addMessage(result.groupId, innerNote)
// Traffic is the natural clock for disappearing messages: a
// group being read is a group whose expired messages should
// already be gone.
manager.pruneExpiredMessages(result.groupId)
// Persist the decrypted plaintext so the message
// survives an app restart. Marmot/MLS application
// messages cannot be re-decrypted once the ratchet
@@ -27,6 +27,7 @@ import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageBundleStore
import com.vitorpamplona.quartz.marmot.mls.group.MarmotMessageStore
import com.vitorpamplona.quartz.marmot.mls.group.MlsGroupStateStore
import com.vitorpamplona.quartz.marmot.protocolCore.MarmotPublishObligationStore
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
@@ -153,6 +154,7 @@ class FileMarmotMessageStore(
file(nostrGroupId).deleteOrWarn("FileMarmotMessageStore", "group messages")
epochFile(nostrGroupId).deleteOrWarn("FileMarmotMessageStore", "group message epochs")
snapshotFile(nostrGroupId).deleteOrWarn("FileMarmotMessageStore", "group system-row baseline")
expiryFile(nostrGroupId).deleteOrWarn("FileMarmotMessageStore", "group message expiries")
}
private fun snapshotFile(id: String) = File(dir, "$id.snapshot")
@@ -167,6 +169,56 @@ class FileMarmotMessageStore(
override suspend fun loadGroupSnapshot(nostrGroupId: String): String? = snapshotFile(nostrGroupId).takeIf { it.exists() }?.readText()
private fun expiryFile(id: String) = File(dir, "$id.expiries")
/**
* First write wins: an expiry is pinned to the retention of the message's
* own source epoch, so re-persisting the same message after a replay must
* not re-time it under a setting that has since changed.
*/
override suspend fun recordExpiry(
nostrGroupId: String,
innerEventId: String,
expiresAtSecs: Long,
) {
val target = expiryFile(nostrGroupId)
if (target.exists() && target.readLines().any { it.substringBefore(' ') == innerEventId }) return
SecureFileIO.appendText(target, "$innerEventId $expiresAtSecs\n")
}
override suspend fun loadExpiries(nostrGroupId: String): Map<String, Long> =
expiryFile(nostrGroupId)
.takeIf { it.exists() }
?.readLines()
?.mapNotNull { line ->
val parts = line.trim().split(' ')
if (parts.size != 2) return@mapNotNull null
val at = parts[1].toLongOrNull() ?: return@mapNotNull null
parts[0] to at
}?.toMap()
?: emptyMap()
/** Rewrites both logs: a disappearing message has to actually leave the disk. */
override suspend fun removeMessages(
nostrGroupId: String,
innerEventIds: Set<String>,
) {
if (innerEventIds.isEmpty()) return
val target = file(nostrGroupId)
if (target.exists()) {
val kept =
target.readLines().filter { line ->
line.isNotBlank() && Event.fromJsonOrNull(line)?.id !in innerEventIds
}
SecureFileIO.writeBytesAtomic(target, (kept.joinToString("\n") + if (kept.isEmpty()) "" else "\n").encodeToByteArray())
}
val expiries = expiryFile(nostrGroupId)
if (expiries.exists()) {
val kept = expiries.readLines().filter { it.isNotBlank() && it.substringBefore(' ') !in innerEventIds }
SecureFileIO.writeBytesAtomic(expiries, (kept.joinToString("\n") + if (kept.isEmpty()) "" else "\n").encodeToByteArray())
}
}
private fun epochFile(id: String) = File(dir, "$id.epochs")
override suspend fun recordEpoch(
@@ -168,6 +168,9 @@ private suspend fun MarmotManager.ingestGroupEvent(ge: GroupEvent): MarmotIngest
// MLS ratchets once we decrypt; future reads of the same ciphertext
// would fail — persist the plaintext now so restarts/replays see it.
persistDecryptedMessage(result.groupId, result.innerEventJson, result.epoch)
// Traffic is the natural clock for expiry: a group that is being
// read is a group whose expired messages should already be gone.
pruneExpiredMessages(result.groupId)
MarmotIngestResult.Message(result)
}
@@ -1094,9 +1094,14 @@ class MarmotManager(
) {
try {
messageStore?.appendMessage(nostrGroupId, innerEventJson)
if (epoch != null) {
Event.fromJsonOrNull(innerEventJson)?.let { messageStore?.recordEpoch(nostrGroupId, it.id, epoch) }
val parsed = Event.fromJsonOrNull(innerEventJson)
if (epoch != null && parsed != null) {
messageStore?.recordEpoch(nostrGroupId, parsed.id, epoch)
}
// Pinned here, at the moment the message enters the log, because
// this is the last point at which the retention of its delivering
// epoch is still the group's current retention.
parsed?.let { pinExpiry(nostrGroupId, it) }
} catch (e: Exception) {
Log.w("MarmotManager", "Failed to persist Marmot message for $nostrGroupId", e)
}
@@ -1238,12 +1243,89 @@ class MarmotManager(
*/
suspend fun loadStoredMessages(nostrGroupId: HexKey): List<String> =
try {
messageStore?.loadMessages(nostrGroupId) ?: emptyList()
val store = messageStore ?: return emptyList()
// Prune before reading, so a restart cannot show a message that
// expired while the app was closed. Filtering the read alone would
// leave it on disk, and disk is the whole point: the ratchet moved
// past the ciphertext long ago, so this store is the only copy.
pruneExpiredMessages(nostrGroupId)
store.loadMessages(nostrGroupId)
} catch (e: Exception) {
Log.w("MarmotManager", "Failed to load persisted messages for $nostrGroupId", e)
emptyList()
}
/**
* The group's disappearing-message duration in seconds, or 0 when off.
*
* Read from the current profile's `0x8005` component, falling back to a
* legacy group's `0xF2EE` field — a legacy group carries the same setting
* in the monolithic blob, and reading only the component would silently
* treat every legacy group as having no expiry at all.
*/
fun retentionSeconds(nostrGroupId: HexKey): Long {
val fromComponent = groupState(nostrGroupId)?.retention?.disappearingMessageSecs
if (fromComponent != null) return fromComponent.toLong()
return groupMetadata(nostrGroupId)?.disappearingMessageSecs?.toLong() ?: 0L
}
/**
* Pin when [innerEvent] stops being displayable, if this group expires
* messages at all.
*
* Pinned at persist time and never recomputed, because the component says
* a message keeps the retention of its OWN source epoch: a later change to
* the setting must not shorten, extend, or restore the expiry of a message
* that already exists.
*
* The base is the sender's own `created_at`, which the component
* acknowledges is only as trustworthy as the MLS-authenticated sender —
* expiry is advisory, not a deletion guarantee against a hostile member.
*/
private suspend fun pinExpiry(
nostrGroupId: HexKey,
innerEvent: Event,
) {
val seconds = retentionSeconds(nostrGroupId)
if (seconds <= 0L) return
try {
messageStore?.recordExpiry(nostrGroupId, innerEvent.id, innerEvent.createdAt + seconds)
} catch (e: Exception) {
Log.w("MarmotManager", "Failed to pin expiry for ${innerEvent.id} in $nostrGroupId", e)
}
}
/**
* Delete every message whose pinned expiry has passed.
*
* @return the inner event ids that were removed, so a front end can drop
* them from a conversation it is already showing rather than waiting for
* the next read.
*/
suspend fun pruneExpiredMessages(
nostrGroupId: HexKey,
nowSecs: Long = TimeUtils.now(),
): Set<HexKey> {
val store = messageStore ?: return emptySet()
return try {
val expired = store.loadExpiries(nostrGroupId).filterValues { it <= nowSecs }.keys
if (expired.isEmpty()) return emptySet()
store.removeMessages(nostrGroupId, expired)
Log.d("MarmotManager") { "expired ${expired.size} message(s) in ${nostrGroupId.take(8)}…" }
onMessagesExpired?.invoke(nostrGroupId, expired)
expired
} catch (e: Exception) {
Log.w("MarmotManager", "Failed to expire messages for $nostrGroupId", e)
emptySet()
}
}
/**
* Called for every message this client expires, so a front end can drop it
* from a conversation that is already on screen.
*/
var onMessagesExpired: ((nostrGroupId: HexKey, innerEventIds: Set<HexKey>) -> Unit)? = null
/**
* Remove a member from a group.
* Returns the commit GroupEvent to publish.
@@ -0,0 +1,238 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.commons.marmot
import com.vitorpamplona.quartz.marmot.appComponents.MessageRetentionV1
import com.vitorpamplona.quartz.marmot.mip01Groups.MarmotGroupData
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal
import com.vitorpamplona.quartz.utils.TimeUtils
import kotlinx.coroutines.runBlocking
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertTrue
/**
* Disappearing messages — `marmot.group.message-retention.v1`, component
* `0x8005`.
*
* The component's own rules are what these assert, and two of them are easy to
* get wrong in ways nobody notices until a message that should be gone is
* still there:
*
* - every message pins the retention of its OWN source epoch, so changing the
* setting later must not shorten, extend, or restore an existing message's
* expiry; and
* - a retry of the same MLS message reuses the same pinned value, which
* matters because the ratchet rewinds on restart and relays replay.
*
* Expiry is advisory by design: the duration is authenticated but the base is
* the sender's own `created_at`, so it inherits the trust already placed in an
* MLS-authenticated sender and is not a guarantee against a hostile one.
*/
class MarmotRetentionTest {
private val nostrGroupId = "e".repeat(64)
private class Fixture {
val signer = NostrSignerInternal(KeyPair())
val mlsStore = SnapshotStateStore()
val messageStore = SnapshotMessageStore()
val manager = MarmotManager(signer, mlsStore, messageStore, SnapshotBundleStore(), publisher = ACCEPTING_RELAY)
}
private suspend fun Fixture.createGroup(secs: ULong?) =
manager.createGroup(
nostrGroupId,
// Version 3: the legacy `0xF2EE` blob only carries
// `disappearing_message_secs` from v3 on, so that v1/v2 stays
// byte-for-byte what MDK's older parser accepts.
MarmotGroupData(
nostrGroupId = nostrGroupId,
name = "retention",
relays = listOf("wss://relay.invalid"),
disappearingMessageSecs = secs,
version = 3,
),
)
private suspend fun MarmotManager.storedIds(): List<String> = loadStoredMessages(nostrGroupId).mapNotNull { Event.fromJsonOrNull(it)?.id }
@Test
fun `a group with no retention keeps its messages`() =
runBlocking {
val f = Fixture()
f.createGroup(null)
val sent = f.manager.buildTextMessage(nostrGroupId, "keep me")
assertEquals(0L, f.manager.retentionSeconds(nostrGroupId))
assertTrue(f.manager.pruneExpiredMessages(nostrGroupId, TimeUtils.now() + 10_000_000).isEmpty())
assertTrue(sent.innerEvent.id in f.manager.storedIds())
}
@Test
fun `a message outlives its retention and is deleted, not merely hidden`() =
runBlocking {
val f = Fixture()
f.createGroup(60uL)
val sent = f.manager.buildTextMessage(nostrGroupId, "gone in a minute")
assertEquals(60L, f.manager.retentionSeconds(nostrGroupId))
val after = (sent.innerEvent.createdAt) + 61
assertEquals(setOf(sent.innerEvent.id), f.manager.pruneExpiredMessages(nostrGroupId, after))
// Gone from the log itself. A disappearing message that is only
// filtered out of a read is still on disk, and this store holds the
// only copy — the ratchet moved past the ciphertext long ago.
assertFalse(sent.innerEvent.id in f.manager.storedIds())
assertTrue(f.messageStore.loadExpiries(nostrGroupId).isEmpty())
}
@Test
fun `a message inside its window is untouched`() =
runBlocking {
val f = Fixture()
f.createGroup(3600uL)
val sent = f.manager.buildTextMessage(nostrGroupId, "still fresh")
assertTrue(f.manager.pruneExpiredMessages(nostrGroupId, sent.innerEvent.createdAt + 60).isEmpty())
assertTrue(sent.innerEvent.id in f.manager.storedIds())
}
@Test
fun `expiry is pinned at the source epoch and a later change does not re-time it`() =
runBlocking {
// The rule that is easiest to get wrong: recomputing expiry from
// the CURRENT setting would let one member shorten everyone's
// history retroactively, or restore what should already be gone.
val f = Fixture()
f.createGroup(60uL)
val sent = f.manager.buildTextMessage(nostrGroupId, "pinned at sixty")
f.manager.updateGroupMetadata(
nostrGroupId,
MarmotGroupData(
nostrGroupId = nostrGroupId,
name = "retention",
relays = listOf("wss://relay.invalid"),
disappearingMessageSecs = 86_400uL,
version = 3,
),
)
assertEquals(86_400L, f.manager.retentionSeconds(nostrGroupId))
// Still expires on the old sixty seconds, not the new day.
assertEquals(
setOf(sent.innerEvent.id),
f.manager.pruneExpiredMessages(nostrGroupId, sent.innerEvent.createdAt + 61),
)
}
@Test
fun `re-persisting the same message reuses its pinned expiry`() =
runBlocking {
// The ratchet rewinds on restart and relays replay recent kind:445
// events, so the same message really is persisted twice. If the
// second write re-timed it, a message could keep postponing its own
// expiry every time it was replayed.
val f = Fixture()
f.createGroup(60uL)
val sent = f.manager.buildTextMessage(nostrGroupId, "replayed")
val pinned = f.messageStore.loadExpiries(nostrGroupId)[sent.innerEvent.id]
f.manager.updateGroupMetadata(
nostrGroupId,
MarmotGroupData(
nostrGroupId = nostrGroupId,
name = "retention",
relays = listOf("wss://relay.invalid"),
disappearingMessageSecs = 86_400uL,
version = 3,
),
)
f.manager.persistDecryptedMessage(nostrGroupId, sent.innerEvent.toJson())
assertEquals(pinned, f.messageStore.loadExpiries(nostrGroupId)[sent.innerEvent.id])
}
@Test
fun `reading a group expires whatever fell due while it was closed`() =
runBlocking {
// The restart case: nothing is running to notice the moment a
// message falls due, so the read itself has to. The message is
// back-dated, which is also the component's documented caveat —
// the base is the sender's own `created_at`, so expiry is only as
// trustworthy as the MLS-authenticated sender.
val f = Fixture()
f.createGroup(60uL)
val stale =
Event(
id = "1".repeat(64),
pubKey = f.signer.pubKey,
createdAt = TimeUtils.now() - 3600,
kind = 9,
tags = emptyArray(),
content = "sent an hour ago",
sig = "",
)
f.manager.persistDecryptedMessage(nostrGroupId, stale.toJson())
assertFalse(stale.id in f.manager.storedIds())
}
@Test
fun `an expiring client tells the front end which messages went`() =
runBlocking {
// A message gone from disk but still on screen has not disappeared.
val f = Fixture()
f.createGroup(60uL)
val sent = f.manager.buildTextMessage(nostrGroupId, "drop me from the view")
val announced = mutableListOf<String>()
f.manager.onMessagesExpired = { _, ids -> announced.addAll(ids) }
f.manager.pruneExpiredMessages(nostrGroupId, sent.innerEvent.createdAt + 61)
assertEquals(listOf(sent.innerEvent.id), announced)
}
@Test
fun `the current profile reads retention from its own component`() =
runBlocking {
// The legacy blob only carries the setting from v3, so in practice
// a group created by the reference client expresses it as component
// `0x8005` instead. Both have to reach the same answer or a
// disappearing message stops disappearing at the profile boundary.
val f = Fixture()
f.manager.createCurrentProfileGroup(
nostrGroupId = nostrGroupId,
relays = listOf("wss://relay.invalid"),
retention = MessageRetentionV1(120uL),
)
assertEquals(120L, f.manager.retentionSeconds(nostrGroupId))
val sent = f.manager.buildTextMessage(nostrGroupId, "two minutes")
assertTrue(f.manager.pruneExpiredMessages(nostrGroupId, sent.innerEvent.createdAt + 60).isEmpty())
assertEquals(
setOf(sent.innerEvent.id),
f.manager.pruneExpiredMessages(nostrGroupId, sent.innerEvent.createdAt + 121),
)
}
}
@@ -23,6 +23,7 @@ package com.vitorpamplona.amethyst.commons.marmot
import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageBundleStore
import com.vitorpamplona.quartz.marmot.mls.group.MarmotMessageStore
import com.vitorpamplona.quartz.marmot.mls.group.MlsGroupStateStore
import com.vitorpamplona.quartz.nip01Core.core.Event
// In-memory stand-ins for the durable stores a MarmotManager needs.
//
@@ -67,6 +68,7 @@ class SnapshotStateStore : MlsGroupStateStore {
class SnapshotMessageStore : MarmotMessageStore {
private val messages = mutableMapOf<String, MutableList<String>>()
private val snapshots = mutableMapOf<String, String>()
private val expiries = mutableMapOf<String, MutableMap<String, Long>>()
override suspend fun appendMessage(
nostrGroupId: String,
@@ -81,6 +83,7 @@ class SnapshotMessageStore : MarmotMessageStore {
override suspend fun delete(nostrGroupId: String) {
messages.remove(nostrGroupId)
snapshots.remove(nostrGroupId)
expiries.remove(nostrGroupId)
}
override suspend fun recordGroupSnapshot(
@@ -91,6 +94,27 @@ class SnapshotMessageStore : MarmotMessageStore {
}
override suspend fun loadGroupSnapshot(nostrGroupId: String): String? = snapshots[nostrGroupId]
// Disappearing messages. First write wins, mirroring the durable stores:
// an expiry is pinned to its message's own source epoch and a replay must
// not re-time it.
override suspend fun recordExpiry(
nostrGroupId: String,
innerEventId: String,
expiresAtSecs: Long,
) {
expiries.getOrPut(nostrGroupId) { mutableMapOf() }.putIfAbsent(innerEventId, expiresAtSecs)
}
override suspend fun loadExpiries(nostrGroupId: String): Map<String, Long> = expiries[nostrGroupId]?.toMap() ?: emptyMap()
override suspend fun removeMessages(
nostrGroupId: String,
innerEventIds: Set<String>,
) {
messages[nostrGroupId]?.removeAll { json -> Event.fromJsonOrNull(json)?.id in innerEventIds }
expiries[nostrGroupId]?.keys?.removeAll(innerEventIds)
}
}
class SnapshotBundleStore : KeyPackageBundleStore {
@@ -111,4 +111,43 @@ interface MarmotMessageStore {
/** The last recorded snapshot, or null when there is no baseline yet. */
suspend fun loadGroupSnapshot(nostrGroupId: String): String? = null
// ── Disappearing messages ────────────────────────────────────────────────
//
// The three members below are one feature and are implemented together or
// not at all: expiries that are never recorded read back empty, and an
// empty set is never removed. A store that implements none of them simply
// keeps every message forever, which is a client that cannot honour
// `marmot.group.message-retention.v1` — degraded, not broken, and the same
// shape of optionality as [recordEpoch].
//
// Expiry is pinned per message rather than recomputed: the component says
// a message keeps the retention of its OWN source epoch, so a later change
// must not re-time a message that already exists.
/**
* Remember when [innerEventId] stops being displayable.
*
* @param expiresAtSecs absolute Unix seconds, already `created_at + secs`.
*/
suspend fun recordExpiry(
nostrGroupId: String,
innerEventId: String,
expiresAtSecs: Long,
) = Unit
/** Inner event id → its pinned expiry, for what was recorded. */
suspend fun loadExpiries(nostrGroupId: String): Map<String, Long> = emptyMap()
/**
* Delete these messages, and any expiry recorded for them, permanently.
*
* The deletion is the feature: a disappearing message that is merely
* hidden is still on disk, and this store is the only durable copy — the
* MLS ratchet has long since moved past the ciphertext it came from.
*/
suspend fun removeMessages(
nostrGroupId: String,
innerEventIds: Set<String>,
) = Unit
}