feat: Sprint 6 — disappearing messages, group info, account deletion

- Disappearing messages: ttlSecs param on enqueue/batchEnqueue RPCs,
  expires_at column (migration 007), server GC deletes expired messages,
  /disappear command with human-friendly duration parsing (30m, 1h, 1d)
- Group info: /group-info shows type, members, MLS epoch; /rename
  renames conversations; /members resolves usernames via resolveIdentity
- Account deletion: deleteAccount @23 RPC with transactional purge of
  all user data (deliveries, keys, channels), session invalidation,
  KT log preserved for auditability; /delete-account with confirmation
- Added epoch() accessor to GroupMember, enqueue_with_ttl client helper

All 35 server + 71 core + 14 E2E tests pass.
This commit is contained in:
2026-03-04 00:39:05 +01:00
parent 3350d765e5
commit fd21ea625c
13 changed files with 563 additions and 38 deletions

View File

@@ -9,7 +9,7 @@ use rusqlite::{params, Connection};
use crate::storage::{StorageError, Store};
/// Schema version after introducing the migration runner (existing DBs had 1).
const SCHEMA_VERSION: i32 = 7;
const SCHEMA_VERSION: i32 = 8;
/// Migrations: (migration_number, SQL). Files named NNN_name.sql, applied in order when N > user_version.
const MIGRATIONS: &[(i32, &str)] = &[
@@ -19,6 +19,7 @@ const MIGRATIONS: &[(i32, &str)] = &[
(5, include_str!("../migrations/004_federation.sql")),
(6, include_str!("../migrations/005_signing_key.sql")),
(7, include_str!("../migrations/006_kt_log.sql")),
(8, include_str!("../migrations/007_add_expiry.sql")),
];
/// Runs pending migrations on an open connection: applies any migration whose number is greater
@@ -133,6 +134,7 @@ impl Store for SqlStore {
recipient_key: &[u8],
channel_id: &[u8],
payload: Vec<u8>,
ttl_secs: Option<u32>,
) -> Result<u64, StorageError> {
let conn = self.lock_conn()?;
// Atomically get-and-increment the per-inbox sequence counter.
@@ -147,9 +149,16 @@ impl Store for SqlStore {
|row| row.get(0),
)
.map_err(|e| StorageError::Db(e.to_string()))?;
let expires_at: Option<i64> = ttl_secs.map(|ttl| {
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs() as i64;
now + ttl as i64
});
conn.execute(
"INSERT INTO deliveries (recipient_key, channel_id, seq, payload) VALUES (?1, ?2, ?3, ?4)",
params![recipient_key, channel_id, seq, payload],
"INSERT INTO deliveries (recipient_key, channel_id, seq, payload, expires_at) VALUES (?1, ?2, ?3, ?4, ?5)",
params![recipient_key, channel_id, seq, payload, expires_at],
)
.map_err(|e| StorageError::Db(e.to_string()))?;
Ok(seq as u64)
@@ -166,6 +175,7 @@ impl Store for SqlStore {
.prepare(
"SELECT id, seq, payload FROM deliveries
WHERE recipient_key = ?1 AND channel_id = ?2
AND (expires_at IS NULL OR expires_at > strftime('%s','now'))
ORDER BY seq ASC",
)
.map_err(|e| StorageError::Db(e.to_string()))?;
@@ -205,6 +215,7 @@ impl Store for SqlStore {
.prepare(
"SELECT id, seq, payload FROM deliveries
WHERE recipient_key = ?1 AND channel_id = ?2
AND (expires_at IS NULL OR expires_at > strftime('%s','now'))
ORDER BY seq ASC
LIMIT ?3",
)
@@ -237,7 +248,7 @@ impl Store for SqlStore {
let conn = self.lock_conn()?;
let count: i64 = conn
.query_row(
"SELECT COUNT(*) FROM deliveries WHERE recipient_key = ?1 AND channel_id = ?2",
"SELECT COUNT(*) FROM deliveries WHERE recipient_key = ?1 AND channel_id = ?2 AND (expires_at IS NULL OR expires_at > strftime('%s','now'))",
params![recipient_key, channel_id],
|row| row.get(0),
)
@@ -247,18 +258,26 @@ impl Store for SqlStore {
fn gc_expired_messages(&self, max_age_secs: u64) -> Result<usize, StorageError> {
let conn = self.lock_conn()?;
let cutoff = std::time::SystemTime::now()
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs()
.saturating_sub(max_age_secs);
let deleted = conn
.as_secs();
let cutoff = now.saturating_sub(max_age_secs);
// Delete messages older than max_age_secs based on created_at.
let deleted_age = conn
.execute(
"DELETE FROM deliveries WHERE created_at < ?1",
params![cutoff as i64],
)
.map_err(|e| StorageError::Db(e.to_string()))?;
Ok(deleted)
// Delete messages that have passed their per-message TTL expiry.
let deleted_ttl = conn
.execute(
"DELETE FROM deliveries WHERE expires_at IS NOT NULL AND expires_at <= ?1",
params![now as i64],
)
.map_err(|e| StorageError::Db(e.to_string()))?;
Ok(deleted_age + deleted_ttl)
}
fn upload_hybrid_key(
@@ -436,11 +455,13 @@ impl Store for SqlStore {
let sql = if limit == 0 {
"SELECT seq, payload FROM deliveries
WHERE recipient_key = ?1 AND channel_id = ?2
AND (expires_at IS NULL OR expires_at > strftime('%s','now'))
ORDER BY seq ASC".to_string()
} else {
format!(
"SELECT seq, payload FROM deliveries
WHERE recipient_key = ?1 AND channel_id = ?2
AND (expires_at IS NULL OR expires_at > strftime('%s','now'))
ORDER BY seq ASC
LIMIT {}",
limit
@@ -604,6 +625,91 @@ impl Store for SqlStore {
.map_err(|e| StorageError::Db(e.to_string()))?;
Ok(rows)
}
fn delete_account(&self, identity_key: &[u8]) -> Result<(), StorageError> {
let conn = self.lock_conn()?;
// Resolve the username for this identity key.
let username: Option<String> = conn
.query_row(
"SELECT username FROM user_identity_keys WHERE identity_key = ?1",
params![identity_key],
|row| row.get(0),
)
.optional()
.map_err(|e| StorageError::Db(e.to_string()))?;
// Use a transaction for atomicity.
conn.execute_batch("BEGIN IMMEDIATE")
.map_err(|e| StorageError::Db(e.to_string()))?;
let result = (|| -> Result<(), StorageError> {
// 1. Delete queued deliveries.
conn.execute(
"DELETE FROM deliveries WHERE recipient_key = ?1",
params![identity_key],
).map_err(|e| StorageError::Db(e.to_string()))?;
conn.execute(
"DELETE FROM delivery_seq_counters WHERE recipient_key = ?1",
params![identity_key],
).map_err(|e| StorageError::Db(e.to_string()))?;
// 2. Delete key packages.
conn.execute(
"DELETE FROM key_packages WHERE identity_key = ?1",
params![identity_key],
).map_err(|e| StorageError::Db(e.to_string()))?;
// 3. Delete hybrid keys.
conn.execute(
"DELETE FROM hybrid_keys WHERE identity_key = ?1",
params![identity_key],
).map_err(|e| StorageError::Db(e.to_string()))?;
// 4. Delete channel memberships.
conn.execute(
"DELETE FROM channels WHERE member_a = ?1 OR member_b = ?1",
params![identity_key],
).map_err(|e| StorageError::Db(e.to_string()))?;
// 5. Delete identity key mapping.
conn.execute(
"DELETE FROM user_identity_keys WHERE identity_key = ?1",
params![identity_key],
).map_err(|e| StorageError::Db(e.to_string()))?;
// 6. Delete user record (by username).
if let Some(ref uname) = username {
conn.execute(
"DELETE FROM users WHERE username = ?1",
params![uname],
).map_err(|e| StorageError::Db(e.to_string()))?;
}
// 7. Delete endpoints (table may not exist on older schemas).
let _ = conn.execute(
"DELETE FROM endpoints WHERE identity_key = ?1",
params![identity_key],
);
// Do NOT delete KT log entries — append-only for auditability.
Ok(())
})();
match result {
Ok(()) => {
conn.execute_batch("COMMIT")
.map_err(|e| StorageError::Db(e.to_string()))?;
Ok(())
}
Err(e) => {
let _ = conn.execute_batch("ROLLBACK");
Err(e)
}
}
}
}
/// Convenience extension for `rusqlite::OptionalExtension`.
@@ -677,8 +783,8 @@ mod tests {
let rk = [1u8; 32];
let ch = b"channel-1";
let seq0 = store.enqueue(&rk, ch, b"msg1".to_vec()).unwrap();
let seq1 = store.enqueue(&rk, ch, b"msg2".to_vec()).unwrap();
let seq0 = store.enqueue(&rk, ch, b"msg1".to_vec(), None).unwrap();
let seq1 = store.enqueue(&rk, ch, b"msg2".to_vec(), None).unwrap();
assert_eq!(seq0, 0);
assert_eq!(seq1, 1);
@@ -694,9 +800,9 @@ mod tests {
let rk = [5u8; 32];
let ch = b"ch";
store.enqueue(&rk, ch, b"a".to_vec()).unwrap();
store.enqueue(&rk, ch, b"b".to_vec()).unwrap();
store.enqueue(&rk, ch, b"c".to_vec()).unwrap();
store.enqueue(&rk, ch, b"a".to_vec(), None).unwrap();
store.enqueue(&rk, ch, b"b".to_vec(), None).unwrap();
store.enqueue(&rk, ch, b"c".to_vec(), None).unwrap();
let msgs = store.fetch_limited(&rk, ch, 2).unwrap();
assert_eq!(msgs, vec![(0u64, b"a".to_vec()), (1u64, b"b".to_vec())]);
@@ -712,8 +818,8 @@ mod tests {
let ch = b"ch";
assert_eq!(store.queue_depth(&rk, ch).unwrap(), 0);
store.enqueue(&rk, ch, b"x".to_vec()).unwrap();
store.enqueue(&rk, ch, b"y".to_vec()).unwrap();
store.enqueue(&rk, ch, b"x".to_vec(), None).unwrap();
store.enqueue(&rk, ch, b"y".to_vec(), None).unwrap();
assert_eq!(store.queue_depth(&rk, ch).unwrap(), 2);
}
@@ -756,8 +862,8 @@ mod tests {
let store = open_in_memory();
let rk = [4u8; 32];
store.enqueue(&rk, b"ch-a", b"a1".to_vec()).unwrap();
store.enqueue(&rk, b"ch-b", b"b1".to_vec()).unwrap();
store.enqueue(&rk, b"ch-a", b"a1".to_vec(), None).unwrap();
store.enqueue(&rk, b"ch-b", b"b1".to_vec(), None).unwrap();
let a_msgs = store.fetch(&rk, b"ch-a").unwrap();
assert_eq!(a_msgs, vec![(0u64, b"a1".to_vec())]);