feat(backup): add ConversationBackup domain models (BackupMessageRecord, BackupCursor, BackupManifest, RestoreRequest)

This commit is contained in:
null 2026-06-30 20:42:07 -05:00
parent 14bfbd04c8
commit 6b469357c1
1 changed files with 84 additions and 0 deletions

View File

@ -0,0 +1,84 @@
package app.closer.domain.model
/**
* Domain models for the E2EE conversation backup & restore system.
*
* The backup is a couple-key-encrypted snapshot of the couple's conversation history (messages +
* media refs) so a new/wiped device can restore it, and either partner can restore for the other.
* See the plan + `docs/Engineering_Reference_Manual.md` (backup/restore).
*/
/** Current backup wire-format version. Bump when the record/manifest shape changes. */
const val BACKUP_SCHEMA_VERSION = 1
/**
* One backed-up message. The `text` field keeps the message's existing `enc:v1:` value **verbatim**
* (no decrypt/re-encrypt on backup) the whole chunk is then wrapped in a couple-key `enc:v1:`
* envelope, so metadata (sender/timestamp/type) is also ciphertext at rest.
*/
data class BackupMessageRecord(
val messageId: String,
val conversationId: String,
val conversationType: String = "main",
val authorUserId: String = "",
val type: String = "text", // text | image | voice
val encText: String? = null, // existing enc:v1: body for text messages
val mediaUrl: String? = null, // tokenized Storage URL for image/voice
val durationMs: Long? = null,
val createdAt: Long = 0L, // resolved server timestamp (millis)
val deleted: Boolean = false,
val reactions: Map<String, String> = emptyMap()
)
/**
* A cursor into the message stream: incremental backup appends everything strictly after this point.
* Ordered by (createdAt, messageId) so ties are stable across both partners' devices.
*/
data class BackupCursor(
val createdAt: Long = 0L,
val messageId: String = ""
) {
fun isAfter(other: BackupCursor): Boolean =
createdAt != other.createdAt && createdAt > other.createdAt ||
(createdAt == other.createdAt && messageId > other.messageId)
companion object {
val ZERO = BackupCursor(0L, "")
}
}
/**
* Backup manifest (`couples/{id}/backup/manifest`). `generation` drives optimistic concurrency so the
* two partners' devices can both write without clobbering each other (CAS in a transaction).
*/
data class BackupManifest(
val schemaVersion: Int = BACKUP_SCHEMA_VERSION,
val generation: Long = 0L,
val snapshotUrl: String? = null,
val snapshotOwner: String = "", // uid whose Storage path holds the snapshot (for targeted cleanup)
val snapshotChecksum: String? = null,
val snapshotThroughCursor: BackupCursor = BackupCursor.ZERO,
val latestChunkSeq: Long = 0L,
val messageCount: Int = 0, // for the "Last backed up: N messages" indicator + rollback cross-check
val updatedAt: Long = 0L,
val updatedBy: String = ""
)
/** Lifecycle of a partner-assisted restore request (`couples/{id}/restore_requests/{recipientUid}`). */
enum class RestoreStatus { REQUESTED, READY, RESTORED, DECLINED, EXPIRED }
/**
* A recovering device (the recipient) asks its partner to re-share the couple key + refresh the backup.
* The partner writes [keybox] (the couple key ECIES-wrapped to [recipientPublicKey]) after confirming
* the out-of-band [verificationCode]. Never contains plaintext key material readable by the server.
*/
data class RestoreRequest(
val recipientUid: String = "",
val recipientPublicKey: String = "", // pub:v1:...
val requestNonce: String = "",
val keybox: String? = null, // keybox:v1:... (partner-written)
val status: RestoreStatus = RestoreStatus.REQUESTED,
val createdAt: Long = 0L,
val expiresAt: Long = 0L,
val fulfilledAt: Long? = null
)