Skip to main content

vector_core/db/
community.rs

1//! Persistence for Community protocol local state (GROUP_PROTOCOL.md).
2//!
3//! Stores the secrets this account *holds*: the server-root key and per-channel keys
4//! (epoch-tagged). The DB is already account-scoped (`account_dir(npub)/vector.db`), so
5//! there is no npub column — a row belongs to whichever account's DB it lives in.
6//!
7//! At-rest encryption: when Local Encryption is on, every secret BLOB and every identifying
8//! metadata field (names, relays, roles, banlist, owner attestation, invite material) is wrapped
9//! with the account's ENCRYPTION_KEY before it touches disk and unwrapped on read, via the
10//! `enc_*`/`dec_*` helpers below. A raw DB then reveals no WHO/WHERE/WHAT. The discriminators
11//! (32-byte raw key vs 60-byte ciphertext; `looks_encrypted` for text) let a half-migrated DB
12//! read back correctly, so the toggle/PIN-rekey flows and the one-time backfill are safe to re-run.
13
14use nostr_sdk::prelude::{Keys, PublicKey, SecretKey};
15use nostr_sdk::prelude::ToBech32;
16use rusqlite::{params, OptionalExtension};
17
18use crate::community::{Channel, ChannelId, ChannelKey, Community, CommunityId, Epoch, ServerRootKey};
19
20fn now_secs() -> i64 {
21    std::time::SystemTime::now()
22        .duration_since(std::time::UNIX_EPOCH)
23        .map(|d| d.as_secs() as i64)
24        .unwrap_or(0)
25}
26
27fn to_32(bytes: &[u8]) -> Result<[u8; 32], String> {
28    bytes
29        .try_into()
30        .map_err(|_| format!("expected 32-byte key, got {} bytes", bytes.len()))
31}
32
33// At-rest wrappers (see module doc). `enc_*` for write binds, `dec_*` for read.
34fn enc_key(k: &[u8; 32]) -> Result<Vec<u8>, String> { crate::crypto::maybe_encrypt_blob(k) }
35fn dec_key(stored: &[u8]) -> Result<[u8; 32], String> { to_32(&crate::crypto::maybe_decrypt_blob(stored)) }
36fn enc_txt(s: &str) -> Result<String, String> { crate::crypto::maybe_encrypt_text(s) }
37fn dec_txt(s: &str) -> String { crate::crypto::maybe_decrypt_text(s) }
38/// Encrypt an optional text field, preserving NULL.
39fn enc_txt_opt(s: &Option<String>) -> Result<Option<String>, String> {
40    s.as_deref().map(enc_txt).transpose()
41}
42
43/// Decode a 64-char hex id to 32 bytes, REJECTING malformed input. Unlike
44/// `simd::hex::hex_to_bytes_32`, this never silently zero-fills or truncates — a
45/// corrupted id row must error, not reconstruct a wrong-but-self-consistent id.
46pub(crate) fn hex_id_to_32(hex: &str) -> Result<[u8; 32], String> {
47    crate::simd::hex::hex_to_bytes_32_checked(hex)
48        .ok_or_else(|| format!("corrupt or wrong-length 64-char hex id ({} chars)", hex.len()))
49}
50
51/// Persist a Community and all its channels (upsert). Secrets are stored as raw
52/// blobs in the account-scoped DB.
53pub fn save_community(community: &Community) -> Result<(), String> {
54    let conn = super::get_write_connection_guard_static()?;
55    let relays_json = serde_json::to_string(&community.relays).map_err(|e| e.to_string())?;
56    let community_id = community.id.to_hex();
57
58    // icon/banner persisted as the CommunityImage JSON ref (None → NULL).
59    let icon_json = community
60        .icon
61        .as_ref()
62        .map(|i| serde_json::to_string(i))
63        .transpose()
64        .map_err(|e| e.to_string())?;
65    let banner_json = community
66        .banner
67        .as_ref()
68        .map(|b| serde_json::to_string(b))
69        .transpose()
70        .map_err(|e| e.to_string())?;
71    // Atomic: the community row + all its channel rows commit together, so a crash mid-save
72    // can't leave a Community with a partial channel set.
73    let tx = conn.unchecked_transaction().map_err(|e| format!("save community tx: {e}"))?;
74    // UPSERT (not INSERT OR REPLACE): a metadata re-save must NOT reset `banlist` (managed
75    // separately via set_community_banlist) or `created_at` to their defaults — REPLACE deletes
76    // the row first, so omitted columns would revert.
77    // Wrap secrets + identifying metadata before they touch disk (no-op when encryption is off).
78    let enc_root = enc_key(community.server_root_key.as_bytes())?;
79    let enc_name = enc_txt(&community.name)?;
80    let enc_relays = enc_txt(&relays_json)?;
81    let enc_desc = enc_txt_opt(&community.description)?;
82    let enc_icon = enc_txt_opt(&icon_json)?;
83    let enc_banner = enc_txt_opt(&banner_json)?;
84    let enc_owner = enc_txt_opt(&community.owner_attestation)?;
85    tx.execute(
86        "INSERT INTO communities
87            (community_id, server_root_key, name, relays, created_at,
88             description, icon, banner, owner_attestation, server_root_epoch)
89         VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)
90         ON CONFLICT(community_id) DO UPDATE SET
91            server_root_key=excluded.server_root_key, name=excluded.name, relays=excluded.relays,
92            description=excluded.description, icon=excluded.icon, banner=excluded.banner,
93            owner_attestation=excluded.owner_attestation, server_root_epoch=excluded.server_root_epoch",
94        params![
95            community_id,
96            &enc_root[..],
97            enc_name,
98            enc_relays,
99            now_secs(),
100            enc_desc,
101            enc_icon,
102            enc_banner,
103            enc_owner,
104            community.server_root_epoch.0 as i64,
105        ],
106    )
107    .map_err(|e| format!("save community: {e}"))?;
108
109    // Archive the base key at its epoch so a future rotation can't clobber it (multi-held keys).
110    store_epoch_key_tx(&tx, &community_id, crate::community::SERVER_ROOT_SCOPE_HEX,
111        community.server_root_epoch.0, community.server_root_key.as_bytes())?;
112
113    for channel in &community.channels {
114        let enc_chan_key = enc_key(channel.key.as_bytes())?;
115        let enc_chan_name = enc_txt(&channel.name)?;
116        // UPSERT (not INSERT OR REPLACE): a re-save must preserve `created_at` (channel ordering) and
117        // `rekeyed_at_server_epoch` (read-cut resume progress) — REPLACE would reset both to defaults.
118        tx.execute(
119            "INSERT INTO community_channels
120                (channel_id, community_id, channel_key, epoch, name, created_at, rekeyed_at_server_epoch)
121             VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)
122             ON CONFLICT(channel_id) DO UPDATE SET
123                community_id=excluded.community_id, channel_key=excluded.channel_key,
124                epoch=excluded.epoch, name=excluded.name",
125            params![
126                channel.id.to_hex(),
127                community_id,
128                &enc_chan_key[..],
129                // SQLite INTEGER is i64; reinterpret the u64 epoch bit-for-bit
130                // (lossless two's-complement). NOTE: a signed SQL comparison would
131                // mis-order epochs >= 2^63 — don't ORDER BY / range-filter `epoch`.
132                channel.epoch.0 as i64,
133                enc_chan_name,
134                now_secs(),
135                // A newly-inserted channel is current as of the community's base epoch (no cut owed for it).
136                community.server_root_epoch.0 as i64,
137            ],
138        )
139        .map_err(|e| format!("save channel: {e}"))?;
140        // Mirror the channel's current-epoch key into the multi-held archive. The
141        // `community_channels` row above is just the head pointer (REPLACE clobbers it); the archive
142        // (PK includes epoch) retains EVERY epoch key so cross-epoch history stays readable post-rekey.
143        store_epoch_key_tx(&tx, &community_id, &channel.id.to_hex(), channel.epoch.0, channel.key.as_bytes())?;
144    }
145    tx.commit().map_err(|e| format!("save community commit: {e}"))?;
146    Ok(())
147}
148
149// ── Parked Private-Channel key vends (CORD-03 "delivered on grant") ──────────
150
151/// One parked key vend awaiting the control fold that proves its grant.
152#[derive(Debug, Clone, PartialEq, Eq)]
153pub struct PendingChannelKey {
154    /// Row id — the discharge handle, since several candidates may name one channel.
155    pub id: i64,
156    pub channel_id: String,
157    pub epoch: u64,
158    pub key: [u8; 32],
159    /// Seal author — judged as an entitled vendor before the key is accepted.
160    pub sender: String,
161    pub received_at: i64,
162}
163
164/// Most candidate vends retained per channel, and per community overall.
165///
166/// Parking is reachable by ANY npub that can gift-wrap us, so these bound what a
167/// stranger can make us store — and decrypt on every follow pass. Small, because
168/// more than a couple of live candidates for one channel is already pathological.
169const MAX_PARKED_PER_CHANNEL: usize = 4;
170const MAX_PARKED_PER_COMMUNITY: usize = 64;
171
172/// Park a vended channel key as a CANDIDATE.
173///
174/// Deliberately NOT a single slot keyed on (community, channel): parking is open
175/// to any sender, so a slot lets a stranger pre-empt the entitled vendor's key
176/// and silently suppress delivery. Every vend is its own row, the judge tries
177/// them all, and the caps evict oldest-first so an unprovable flood cannot push
178/// out a provable row indefinitely.
179///
180/// `sender` is encrypted at rest with a per-write nonce, so it can be neither a
181/// key nor a dedupe column — hence oldest-first eviction rather than per-sender
182/// replacement.
183pub fn park_channel_key(
184    community_id: &str,
185    channel_id: &str,
186    epoch: u64,
187    key: &[u8; 32],
188    sender: &str,
189) -> Result<(), String> {
190    let conn = super::get_write_connection_guard_static()?;
191    let tx = conn.unchecked_transaction().map_err(|e| format!("park channel key tx: {e}"))?;
192    let enc = enc_key(key)?;
193    let enc_sender = enc_txt(sender)?;
194    tx.execute(
195        "INSERT INTO pending_channel_keys
196            (community_id, channel_id, epoch, channel_key, sender, received_at)
197         VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
198        params![community_id, channel_id, epoch as i64, &enc[..], enc_sender, now_secs()],
199    )
200    .map_err(|e| format!("park channel key: {e}"))?;
201    // Trim this channel's candidates, then the community's total. `id` is the
202    // arrival order (autoincrement), so this is oldest-first without trusting
203    // any attacker-supplied field.
204    tx.execute(
205        "DELETE FROM pending_channel_keys WHERE id IN (
206            SELECT id FROM pending_channel_keys
207             WHERE community_id = ?1 AND channel_id = ?2
208             ORDER BY id DESC LIMIT -1 OFFSET ?3)",
209        params![community_id, channel_id, MAX_PARKED_PER_CHANNEL as i64],
210    )
211    .map_err(|e| format!("trim parked channel keys: {e}"))?;
212    tx.execute(
213        "DELETE FROM pending_channel_keys WHERE id IN (
214            SELECT id FROM pending_channel_keys
215             WHERE community_id = ?1
216             ORDER BY id DESC LIMIT -1 OFFSET ?2)",
217        params![community_id, MAX_PARKED_PER_COMMUNITY as i64],
218    )
219    .map_err(|e| format!("trim parked community keys: {e}"))?;
220    tx.commit().map_err(|e| format!("park channel key commit: {e}"))?;
221    Ok(())
222}
223
224/// Every parked vend for a community, for the post-fold re-judge.
225pub fn get_pending_channel_keys(community_id: &str) -> Result<Vec<PendingChannelKey>, String> {
226    let conn = super::get_db_connection_guard_static()?;
227    // Newest first: the freshest candidate is the likeliest genuine vend, so a
228    // pile of stale squatters costs at most a few failed judgements.
229    let mut stmt = conn
230        .prepare("SELECT id, channel_id, epoch, channel_key, sender, received_at FROM pending_channel_keys WHERE community_id = ?1 ORDER BY id DESC")
231        .map_err(|e| e.to_string())?;
232    let rows = stmt
233        .query_map(params![community_id], |row| {
234            Ok((
235                row.get::<_, i64>(0)?,
236                row.get::<_, String>(1)?,
237                row.get::<_, i64>(2)?,
238                row.get::<_, Vec<u8>>(3)?,
239                row.get::<_, String>(4)?,
240                row.get::<_, i64>(5)?,
241            ))
242        })
243        .map_err(|e| e.to_string())?;
244    let mut out = Vec::new();
245    for row in rows {
246        let (id, channel_id, epoch, blob, sender, received_at) = row.map_err(|e| e.to_string())?;
247        // A key that won't decrypt is unusable — drop it rather than failing the
248        // whole re-judge for its sake.
249        let Ok(key) = dec_key(&blob) else { continue };
250        out.push(PendingChannelKey { id, channel_id, epoch: epoch as u64, key, sender: dec_txt(&sender), received_at });
251    }
252    Ok(out)
253}
254
255/// Seat a channel key on a channel that currently holds NONE, without the
256/// monotonic epoch guard.
257///
258/// [`advance_channel_epoch`] requires `new_epoch > current`, which is right for a
259/// rotation but wrong for first delivery: a keyless record parks at epoch 0 (the
260/// rekey-scan cursor) and a client that mints born-private channels at epoch 0
261/// vends exactly that epoch, so the guard would silently refuse the only key we
262/// will ever be offered. A keyless channel has no key to lose, so any authorized
263/// delivery is strictly an improvement.
264pub fn seat_channel_key(
265    community_id: &str,
266    channel_id: &str,
267    epoch: u64,
268    key: &[u8; 32],
269) -> Result<(), String> {
270    let conn = super::get_write_connection_guard_static()?;
271    let tx = conn.unchecked_transaction().map_err(|e| format!("seat channel key tx: {e}"))?;
272    store_epoch_key_tx(&tx, community_id, channel_id, epoch, key)?;
273    let enc = enc_key(key)?;
274    tx.execute(
275        "UPDATE community_channels SET epoch = ?1, channel_key = ?2
276           WHERE community_id = ?3 AND channel_id = ?4",
277        params![epoch as i64, &enc[..], community_id, channel_id],
278    )
279    .map_err(|e| format!("seat channel key: {e}"))?;
280    tx.commit().map_err(|e| format!("seat channel key commit: {e}"))?;
281    Ok(())
282}
283
284/// Discharge ONE candidate (refused, superseded, expired, or undecodable).
285pub fn drop_pending_channel_key(id: i64) -> Result<(), String> {
286    let conn = super::get_write_connection_guard_static()?;
287    conn.execute("DELETE FROM pending_channel_keys WHERE id = ?1", params![id])
288        .map_err(|e| format!("drop pending channel key: {e}"))?;
289    Ok(())
290}
291
292/// Discharge every candidate for a channel — a key landed, so the rest are moot.
293pub fn drop_pending_channel_keys_for(community_id: &str, channel_id: &str) -> Result<(), String> {
294    let conn = super::get_write_connection_guard_static()?;
295    conn.execute(
296        "DELETE FROM pending_channel_keys WHERE community_id = ?1 AND channel_id = ?2",
297        params![community_id, channel_id],
298    )
299    .map_err(|e| format!("drop pending channel keys: {e}"))?;
300    Ok(())
301}
302
303/// Store one held epoch key in the multi-held archive. `scope_id` is a channel_id hex or
304/// [`crate::community::SERVER_ROOT_SCOPE_HEX`]. The `(community, scope, epoch)` PK makes a write for
305/// one epoch unable to disturb another epoch's key — so retained history survives a rekey. Uses
306/// REPLACE on the exact coordinate so the fork-resolution apply path can commit the *winning*
307/// key for a contested epoch over a previously-stored loser (the only legitimate same-coordinate
308/// overwrite; an epoch key is otherwise immutable).
309pub fn store_epoch_key(community_id: &str, scope_id: &str, epoch: u64, key: &[u8; 32]) -> Result<(), String> {
310    let conn = super::get_write_connection_guard_static()?;
311    store_epoch_key_tx(&conn, community_id, scope_id, epoch, key)
312}
313
314/// Shared INSERT body so `save_community` can archive keys inside its own transaction and the
315/// standalone [`store_epoch_key`] can run on a borrowed connection. `C: Deref<Target=Connection>`
316/// covers both a `Connection` and a `Transaction`.
317fn store_epoch_key_tx<C: std::ops::Deref<Target = rusqlite::Connection>>(
318    conn: &C,
319    community_id: &str,
320    scope_id: &str,
321    epoch: u64,
322    key: &[u8; 32],
323) -> Result<(), String> {
324    let enc = enc_key(key)?;
325    conn.execute(
326        "INSERT OR REPLACE INTO community_epoch_keys
327            (community_id, scope_id, epoch, key, created_at)
328         VALUES (?1, ?2, ?3, ?4, ?5)",
329        // epoch reinterpreted u64->i64 (lossless); never ORDER BY / range-filter it in SQL (see save).
330        params![community_id, scope_id, epoch as i64, &enc[..], now_secs()],
331    )
332    .map_err(|e| format!("store epoch key: {e}"))?;
333    Ok(())
334}
335
336/// Apply a received channel rekey's new key — the atomic archive+head dual-write: in ONE transaction,
337/// ARCHIVE `(channel, new_epoch) -> new_key` in `community_epoch_keys` AND advance the channel's
338/// read-head (`community_channels.epoch` + `channel_key`) iff `new_epoch` exceeds the current head.
339/// A caught-up OLDER epoch is archived (its history stays decryptable) but never regresses the head.
340/// Atomic so a crash can't leave the archive ahead of the head or the reverse. Returns whether the
341/// head advanced. Epoch comparison is done in RUST (the u64-as-i64 ≥2^63 SQL mis-order trap).
342pub fn advance_channel_epoch(
343    community_id: &str,
344    channel_id: &str,
345    new_epoch: u64,
346    new_key: &[u8; 32],
347) -> Result<bool, String> {
348    let conn = super::get_write_connection_guard_static()?;
349    let tx = conn.unchecked_transaction().map_err(|e| format!("advance channel epoch tx: {e}"))?;
350    // Archive always (PK includes epoch → never clobbers another epoch's key).
351    store_epoch_key_tx(&tx, community_id, channel_id, new_epoch, new_key)?;
352    // Monotonic head advance, compared in Rust.
353    let cur: Option<i64> = tx
354        .query_row(
355            "SELECT epoch FROM community_channels WHERE community_id = ?1 AND channel_id = ?2",
356            params![community_id, channel_id],
357            |r| r.get(0),
358        )
359        .optional()
360        .map_err(|e| format!("read channel head: {e}"))?;
361    let advanced = matches!(cur, Some(c) if new_epoch > c as u64);
362    if advanced {
363        let enc = enc_key(new_key)?;
364        tx.execute(
365            "UPDATE community_channels SET epoch = ?1, channel_key = ?2
366               WHERE community_id = ?3 AND channel_id = ?4",
367            params![new_epoch as i64, &enc[..], community_id, channel_id],
368        )
369        .map_err(|e| format!("advance channel head: {e}"))?;
370    }
371    tx.commit().map_err(|e| format!("advance channel epoch commit: {e}"))?;
372    Ok(advanced)
373}
374
375/// Apply a received SERVER-ROOT (base) rekey's new root — the base counterpart to
376/// [`advance_channel_epoch`], atomic: in ONE transaction, ARCHIVE `(server-root scope, new_epoch) ->
377/// new_root` in `community_epoch_keys` AND advance the base head (`communities.server_root_epoch` +
378/// `server_root_key`) iff `new_epoch` exceeds the current base epoch (monotonic, compared in RUST). A
379/// caught-up OLDER base epoch is archived (its control/base history stays decryptable) but never
380/// regresses the head. Returns whether the head advanced.
381/// The community row's CURRENT base epoch — the cheap freshness probe a
382/// root-derived write compares its in-hand struct against.
383pub fn get_server_root_epoch(community_id: &str) -> Result<Option<u64>, String> {
384    let conn = super::get_db_connection_guard_static()?;
385    conn.query_row(
386        "SELECT server_root_epoch FROM communities WHERE community_id = ?1",
387        params![community_id],
388        |r| r.get::<_, i64>(0),
389    )
390    .optional()
391    .map(|v| v.map(|e| e as u64))
392    .map_err(|e| format!("get server root epoch: {e}"))
393}
394
395pub fn advance_server_root_epoch(community_id: &str, new_epoch: u64, new_root: &[u8; 32]) -> Result<bool, String> {
396    let conn = super::get_write_connection_guard_static()?;
397    let tx = conn.unchecked_transaction().map_err(|e| format!("advance server root tx: {e}"))?;
398    // Archive always, under the all-zero server-root scope sentinel (PK includes epoch → never clobbers
399    // another epoch's root).
400    store_epoch_key_tx(&tx, community_id, crate::community::SERVER_ROOT_SCOPE_HEX, new_epoch, new_root)?;
401    let cur: Option<i64> = tx
402        .query_row(
403            "SELECT server_root_epoch FROM communities WHERE community_id = ?1",
404            params![community_id],
405            |r| r.get(0),
406        )
407        .optional()
408        .map_err(|e| format!("read server-root epoch: {e}"))?;
409    let advanced = matches!(cur, Some(c) if new_epoch > c as u64);
410    if advanced {
411        let enc = enc_key(new_root)?;
412        tx.execute(
413            "UPDATE communities SET server_root_epoch = ?1, server_root_key = ?2 WHERE community_id = ?3",
414            params![new_epoch as i64, &enc[..], community_id],
415        )
416        .map_err(|e| format!("advance server-root head: {e}"))?;
417    }
418    tx.commit().map_err(|e| format!("advance server root commit: {e}"))?;
419    Ok(advanced)
420}
421
422/// SAME-EPOCH convergence for the server root (concurrent re-founding heal): two BAN-holders who
423/// re-founded at the same time each sit on their OWN root at the SAME epoch. `advance_server_root_epoch`
424/// refuses to switch (its guard is strictly monotonic), so this is the sibling that REPLACES the head root
425/// at `epoch` with the deterministic winner (lowest root bytes — the caller decides). Archives the new root
426/// + swaps the head, but ONLY while we're still AT `epoch` (a later real rotation must win over a stale
427/// converge). Returns whether it switched.
428pub fn converge_server_root_epoch(community_id: &str, epoch: u64, new_root: &[u8; 32]) -> Result<bool, String> {
429    let conn = super::get_write_connection_guard_static()?;
430    let tx = conn.unchecked_transaction().map_err(|e| format!("converge server root tx: {e}"))?;
431    store_epoch_key_tx(&tx, community_id, crate::community::SERVER_ROOT_SCOPE_HEX, epoch, new_root)?;
432    let enc = enc_key(new_root)?;
433    let switched = tx
434        .execute(
435            "UPDATE communities SET server_root_key = ?1 WHERE community_id = ?2 AND server_root_epoch = ?3",
436            params![&enc[..], community_id, epoch as i64],
437        )
438        .map_err(|e| format!("converge server-root head: {e}"))?
439        > 0;
440    tx.commit().map_err(|e| format!("converge server root commit: {e}"))?;
441    Ok(switched)
442}
443
444/// SAME-EPOCH convergence for a channel key (concurrent re-founding heal) — the channel counterpart to
445/// [`converge_server_root_epoch`]. Adopts the winning re-founding's channel key at `epoch` (the rekey
446/// addressed under the converged server root), replacing the one we minted in our own losing fork. Switches
447/// only while the channel is still AT `epoch`. Returns whether it switched.
448pub fn converge_channel_epoch(community_id: &str, channel_id: &str, epoch: u64, new_key: &[u8; 32]) -> Result<bool, String> {
449    let conn = super::get_write_connection_guard_static()?;
450    let tx = conn.unchecked_transaction().map_err(|e| format!("converge channel tx: {e}"))?;
451    store_epoch_key_tx(&tx, community_id, channel_id, epoch, new_key)?;
452    let enc = enc_key(new_key)?;
453    let switched = tx
454        .execute(
455            "UPDATE community_channels SET channel_key = ?1 WHERE community_id = ?2 AND channel_id = ?3 AND epoch = ?4",
456            params![&enc[..], community_id, channel_id, epoch as i64],
457        )
458        .map_err(|e| format!("converge channel head: {e}"))?
459        > 0;
460    tx.commit().map_err(|e| format!("converge channel commit: {e}"))?;
461    Ok(switched)
462}
463
464/// Every held `(epoch, key)` for a scope, ascending by epoch. The read paths derive a pseudonym per
465/// returned epoch (`#z` OR-set) so cross-epoch history isn't stranded. Sorted in Rust (not SQL):
466/// epoch is a u64 stored as i64, so a SQL `ORDER BY` would mis-order epochs >= 2^63.
467pub fn held_epoch_keys(community_id: &str, scope_id: &str) -> Result<Vec<(Epoch, [u8; 32])>, String> {
468    let conn = super::get_db_connection_guard_static()?;
469    let mut stmt = conn
470        .prepare("SELECT epoch, key FROM community_epoch_keys WHERE community_id = ?1 AND scope_id = ?2")
471        .map_err(|e| e.to_string())?;
472    let rows = stmt
473        .query_map(params![community_id, scope_id], |r| {
474            Ok((r.get::<_, i64>(0)?, r.get::<_, Vec<u8>>(1)?))
475        })
476        .map_err(|e| e.to_string())?;
477    let mut out: Vec<(Epoch, [u8; 32])> = Vec::new();
478    for row in rows {
479        let (epoch, key_blob) = row.map_err(|e| e.to_string())?;
480        out.push((Epoch(epoch as u64), dec_key(&key_blob)?));
481    }
482    out.sort_by_key(|(e, _)| e.0);
483    Ok(out)
484}
485
486/// The held key for one specific `(scope, epoch)`, or `None` if not held. The open path uses this to
487/// select the decryption key by the inbound event's `epoch` tag.
488pub fn held_epoch_key(community_id: &str, scope_id: &str, epoch: u64) -> Result<Option<[u8; 32]>, String> {
489    let conn = super::get_db_connection_guard_static()?;
490    let blob: Option<Vec<u8>> = conn
491        .query_row(
492            "SELECT key FROM community_epoch_keys WHERE community_id = ?1 AND scope_id = ?2 AND epoch = ?3",
493            params![community_id, scope_id, epoch as i64],
494            |r| r.get(0),
495        )
496        .optional()
497        .map_err(|e| format!("held epoch key: {e}"))?;
498    blob.map(|b| dec_key(&b)).transpose()
499}
500
501/// Local first-save time of a community (≈ when this account joined or created it), in ms.
502/// `created_at` is set on the first save and preserved across metadata re-saves, so it tracks
503/// the join moment. Used to sort a not-yet-active community by join time. `None` if unknown.
504pub fn community_created_at_ms(id: &CommunityId) -> Option<u64> {
505    let conn = super::get_db_connection_guard_static().ok()?;
506    conn.query_row(
507        "SELECT created_at FROM communities WHERE community_id = ?1",
508        params![id.to_hex()],
509        |r| r.get::<_, i64>(0),
510    )
511    .optional()
512    .ok()
513    .flatten()
514    .map(|secs| (secs.max(0) as u64) * 1000)
515}
516
517/// Load a Community and its channels by id. Returns `None` if not stored locally.
518pub fn load_community(id: &CommunityId) -> Result<Option<Community>, String> {
519    let conn = super::get_db_connection_guard_static()?;
520    let id_hex = id.to_hex();
521
522    let row = conn
523        .query_row(
524            "SELECT server_root_key, name, relays,
525                    description, icon, banner, banlist, owner_attestation, server_root_epoch, dissolved
526               FROM communities WHERE community_id = ?1",
527            params![id_hex],
528            |r| {
529                Ok((
530                    r.get::<_, Vec<u8>>(0)?,
531                    r.get::<_, String>(1)?,
532                    r.get::<_, String>(2)?,
533                    r.get::<_, Option<String>>(3)?,
534                    r.get::<_, Option<String>>(4)?,
535                    r.get::<_, Option<String>>(5)?,
536                    r.get::<_, String>(6)?,
537                    r.get::<_, Option<String>>(7)?,
538                    r.get::<_, i64>(8)?,
539                    r.get::<_, i64>(9)?,
540                ))
541            },
542        )
543        .optional()
544        .map_err(|e| format!("load community: {e}"))?;
545
546    let (root_blob, name, relays_json, description, icon_json, banner_json, banlist_json, owner_attestation, server_root_epoch, dissolved_int) =
547        match row {
548            Some(t) => t,
549            None => return Ok(None),
550        };
551    let dissolved = dissolved_int != 0;
552
553    // Unwrap at-rest encryption before parsing (no-op when off, or for not-yet-wrapped rows).
554    let name = dec_txt(&name);
555    let relays_json = dec_txt(&relays_json);
556    let description = description.map(|s| dec_txt(&s));
557    let icon_json = icon_json.map(|s| dec_txt(&s));
558    let banner_json = banner_json.map(|s| dec_txt(&s));
559    let banlist_json = dec_txt(&banlist_json);
560    let owner_attestation = owner_attestation.map(|s| dec_txt(&s));
561
562    // Banlist: stored as a JSON array of hex pubkeys; parse to PublicKeys (skipping any
563    // malformed entry) and denormalize onto every channel so the inbound path can drop
564    // banned authors. A bad/empty column degrades to "no bans", never an error.
565    let banned: Vec<PublicKey> = serde_json::from_str::<Vec<String>>(&banlist_json)
566        .unwrap_or_default()
567        .iter()
568        .filter_map(|h| PublicKey::from_hex(h).ok())
569        .collect();
570
571    let icon = icon_json
572        .map(|j| serde_json::from_str(&j))
573        .transpose()
574        .map_err(|e| format!("icon json: {e}"))?;
575    let banner = banner_json
576        .map(|j| serde_json::from_str(&j))
577        .transpose()
578        .map_err(|e| format!("banner json: {e}"))?;
579
580    let server_root_key = ServerRootKey(dec_key(&root_blob)?);
581    let relays: Vec<String> = serde_json::from_str(&relays_json).map_err(|e| e.to_string())?;
582
583    // hierarchy invariant (apply-time): the OWNER is the uppermost role and can never be
584    // effectively banned or hidden — by anyone. Everyone knows the owner from the attestation, so
585    // ALL members enforce this (the owner is filtered out of `banned` and protected from hides).
586    // Admins are NOT absolutely protected: the owner outranks them and CAN ban/hide an admin.
587    // (Admin-vs-admin peer protection — a lower rank can't act on an equal — is a later
588    // position-relative refinement gated on the author proof; the owner protection is the invariant.)
589    let mut protected: Vec<PublicKey> = Vec::new();
590    if let Some(owner) = owner_attestation
591        .as_ref()
592        .and_then(|att| crate::community::owner::verify_owner_attestation(att, &id_hex))
593    {
594        protected.push(owner);
595    }
596    let banned: Vec<PublicKey> = banned.into_iter().filter(|pk| !protected.contains(pk)).collect();
597
598    // Collect the channel head rows FIRST (drops the borrow on `conn`) so we can then query each
599    // channel's full epoch-key archive on the same connection without a borrow conflict.
600    let raw_channels: Vec<(String, Vec<u8>, i64, String)> = {
601        let mut stmt = conn
602            .prepare(
603                "SELECT channel_id, channel_key, epoch, name
604                   FROM community_channels WHERE community_id = ?1 ORDER BY created_at",
605            )
606            .map_err(|e| e.to_string())?;
607        let rows = stmt
608            .query_map(params![id_hex], |r| {
609                Ok((
610                    r.get::<_, String>(0)?,
611                    r.get::<_, Vec<u8>>(1)?,
612                    r.get::<_, i64>(2)?,
613                    r.get::<_, String>(3)?,
614                ))
615            })
616            .map_err(|e| e.to_string())?;
617        rows.collect::<Result<Vec<_>, _>>().map_err(|e| e.to_string())?
618    };
619
620    // The AUTHORIZED roster (cached by fetch_and_apply_roles, post delegation check), denormalized
621    // onto each channel so the inbound delete path can verify a keyless moderation-hide.
622    let roster = get_community_roles(&id_hex).unwrap_or_default();
623
624    let mut channels = Vec::new();
625    for (cid_hex, key_blob, epoch, cname) in raw_channels {
626        // Every retained epoch key for this channel (multi-held archive), so the read path can fetch +
627        // decrypt across rekeys. Best-effort: a read hiccup degrades to the head epoch (read_epoch_keys
628        // falls back), never an error.
629        let epoch_keys: Vec<(Epoch, crate::community::ChannelKey)> = {
630            let mut ek_stmt = conn
631                .prepare("SELECT epoch, key FROM community_epoch_keys WHERE community_id = ?1 AND scope_id = ?2")
632                .map_err(|e| e.to_string())?;
633            let rows = ek_stmt
634                .query_map(params![id_hex, cid_hex], |r| Ok((r.get::<_, i64>(0)?, r.get::<_, Vec<u8>>(1)?)))
635                .map_err(|e| e.to_string())?;
636            let mut out = Vec::new();
637            for row in rows {
638                let (e, blob) = row.map_err(|e| e.to_string())?;
639                if let Ok(k) = dec_key(&blob) {
640                    out.push((Epoch(e as u64), crate::community::ChannelKey(k)));
641                }
642            }
643            out
644        };
645        channels.push(Channel {
646            id: ChannelId(hex_id_to_32(&cid_hex)?),
647            key: ChannelKey(dec_key(&key_blob)?),
648            // Stored as i64; reinterpreted back to u64 (two's-complement is exact,
649            // so the bit pattern round-trips losslessly even for epoch >= 2^63).
650            epoch: Epoch(epoch as u64),
651            name: dec_txt(&cname),
652            banned: banned.clone(),
653            protected: protected.clone(),
654            roster: roster.clone(),
655            epoch_keys,
656            dissolved,
657        });
658    }
659
660    Ok(Some(Community {
661        id: *id,
662        server_root_key,
663        // Stored as i64; reinterpreted to u64 (two's-complement is exact), same as channel epochs.
664        server_root_epoch: Epoch(server_root_epoch as u64),
665        name,
666        description,
667        icon,
668        banner,
669        relays,
670        channels,
671        owner_attestation,
672        dissolved,
673    }))
674}
675
676/// Retain the ephemeral signing key of a message I published, so I can later
677/// NIP-09-delete it. `relays` is where the deletion must be sent.
678pub fn store_message_key(
679    message_id: &str,
680    outer_event_id: &str,
681    ephemeral: &Keys,
682    relays: &[String],
683) -> Result<(), String> {
684    let conn = super::get_write_connection_guard_static()?;
685    let relays_json = serde_json::to_string(relays).map_err(|e| e.to_string())?;
686    let sk_bytes = to_32(ephemeral.secret_key().as_secret_bytes())?;
687    let enc_secret = enc_key(&sk_bytes)?;
688    let enc_relays = enc_txt(&relays_json)?;
689    conn.execute(
690        "INSERT OR REPLACE INTO community_message_keys
691            (outer_event_id, message_id, ephemeral_secret, relays, created_at)
692         VALUES (?1, ?2, ?3, ?4, ?5)",
693        params![
694            outer_event_id,
695            message_id,
696            &enc_secret[..],
697            enc_relays,
698            now_secs(),
699        ],
700    )
701    .map_err(|e| format!("store message key: {e}"))?;
702    Ok(())
703}
704
705/// Read (WITHOUT removing) the retained key for a message by its INNER message id (what
706/// the UI holds). Returns the ephemeral signing `Keys`, the OUTER event id to
707/// NIP-09-delete, and the relay set — or `None` if not retained (someone else's message,
708/// or already deleted). Peek-only so the key survives a failed deletion publish; the
709/// caller removes it with [`delete_message_key`] only after the publish succeeds.
710pub fn get_message_key(message_id: &str) -> Result<Option<(Keys, String, Vec<String>)>, String> {
711    let conn = super::get_db_connection_guard_static()?;
712    let row = conn
713        .query_row(
714            "SELECT ephemeral_secret, outer_event_id, relays
715               FROM community_message_keys WHERE message_id = ?1",
716            params![message_id],
717            |r| Ok((r.get::<_, Vec<u8>>(0)?, r.get::<_, String>(1)?, r.get::<_, String>(2)?)),
718        )
719        .optional()
720        .map_err(|e| format!("get message key: {e}"))?;
721    let (secret_blob, outer_event_id, relays_json) = match row {
722        Some(t) => t,
723        None => return Ok(None),
724    };
725    let secret = SecretKey::from_slice(&dec_key(&secret_blob)?).map_err(|e| format!("ephemeral secret: {e}"))?;
726    let relays: Vec<String> = serde_json::from_str(&dec_txt(&relays_json)).map_err(|e| e.to_string())?;
727    Ok(Some((Keys::new(secret), outer_event_id, relays)))
728}
729
730/// Remove a retained message key (after a successful deletion publish).
731pub fn delete_message_key(message_id: &str) -> Result<(), String> {
732    let conn = super::get_write_connection_guard_static()?;
733    conn.execute(
734        "DELETE FROM community_message_keys WHERE message_id = ?1",
735        params![message_id],
736    )
737    .map_err(|e| format!("remove message key: {e}"))?;
738    Ok(())
739}
740
741/// Peek + remove in one call. Prefer [`get_message_key`] + [`delete_message_key`] when a
742/// fallible step sits between, so a failure doesn't strand the key.
743pub fn take_message_key(message_id: &str) -> Result<Option<(Keys, String, Vec<String>)>, String> {
744    let r = get_message_key(message_id)?;
745    if r.is_some() {
746        delete_message_key(message_id)?;
747    }
748    Ok(r)
749}
750
751/// The hex id of the Community that owns `channel_id`, if any is stored locally. Used to
752/// resolve a channel-addressed chat back to its Community for sending.
753// ── Channel → community cache ────────────────────────────────────────────────
754// The owning community of a channel is IMMUTABLE (a channel belongs to one community
755// for life; channel ids are random-32 and never reused — the save path refuses a
756// cross-community id claim), so a live entry is never wrong. It only needs eviction
757// when the channel row itself goes away: `delete_community_inner` (all rows) or a
758// `save_community_v2` prune (some rows); `save_community` is UPSERT-only and never
759// removes a channel, so it needs none. POSITIVES ONLY — a missing channel is never
760// cached, so a not-yet-synced channel resolves the moment its row lands and `None`
761// keeps meaning "gone" for delete-then-check callers. Swap-cleared via `clear_id_caches`.
762static CHANNEL_COMMUNITY_CACHE: std::sync::LazyLock<
763    std::sync::RwLock<std::collections::HashMap<String, String>>,
764> = std::sync::LazyLock::new(|| std::sync::RwLock::new(std::collections::HashMap::new()));
765
766/// Drop every cached channel→community mapping (account swap).
767pub fn clear_channel_community_cache() {
768    CHANNEL_COMMUNITY_CACHE.write().unwrap().clear();
769}
770
771/// Forget a community's channel mappings — its rows were dropped or rewritten, so any
772/// pruned entry must stop resolving. Retained channels refill lazily on next lookup.
773fn forget_community_channels(community_id: &str) {
774    CHANNEL_COMMUNITY_CACHE.write().unwrap().retain(|_, cid| cid != community_id);
775}
776
777pub fn community_id_for_channel(channel_id: &str) -> Result<Option<String>, String> {
778    if let Some(cid) = CHANNEL_COMMUNITY_CACHE.read().unwrap().get(channel_id) {
779        return Ok(Some(cid.clone()));
780    }
781    let conn = super::get_db_connection_guard_static()?;
782    let cid: Option<String> = conn
783        .query_row(
784            "SELECT community_id FROM community_channels WHERE channel_id = ?1",
785            params![channel_id],
786            |r| r.get::<_, String>(0),
787        )
788        .optional()
789        .map_err(|e| format!("community_id_for_channel: {e}"))?;
790    if let Some(ref c) = cid {
791        CHANNEL_COMMUNITY_CACHE.write().unwrap().insert(channel_id.to_string(), c.clone());
792    }
793    Ok(cid)
794}
795
796/// Whether a Community with this id is already stored locally (joined). Cheaper than
797/// `load_community` when only existence matters (e.g. inbound-invite dedup).
798pub fn community_exists(id: &CommunityId) -> Result<bool, String> {
799    let conn = super::get_db_connection_guard_static()?;
800    let found: Option<i64> = conn
801        .query_row(
802            "SELECT 1 FROM communities WHERE community_id = ?1",
803            params![id.to_hex()],
804            |r| r.get(0),
805        )
806        .optional()
807        .map_err(|e| format!("community_exists: {e}"))?;
808    Ok(found.is_some())
809}
810
811/// A parked invite awaiting the user's accept/decline decision.
812#[derive(Debug, Clone, serde::Serialize)]
813pub struct PendingCommunityInvite {
814    pub community_id: String,
815    pub bundle_json: String,
816    pub inviter_npub: String,
817    pub received_at: i64,
818    /// Sender-declared NIP-40 expiry (unix secs); 0 = none declared, so permanent.
819    pub expires_at: i64,
820}
821
822/// Park an inbound invite bundle for explicit user consent (the carrier never
823/// auto-joins). First-invite-wins: `INSERT OR IGNORE` means a later invite for the
824/// same `community_id` can't silently rewrite a parked bundle. Returns whether a new
825/// row was inserted (`false` = already pending, caller should not re-notify).
826pub fn save_pending_invite(
827    community_id: &str,
828    bundle_json: &str,
829    inviter_npub: &str,
830    expires_at: i64,
831) -> Result<bool, String> {
832    /// Cap on parked invites. Each row is one gift-wrapped invite from an arbitrary sender, so an
833    /// attacker fabricating unbounded community_ids could otherwise grow this table without limit
834    /// (#298). Newest-wins: a stale months-old park is the safe thing to shed.
835    const MAX_PENDING_INVITES: usize = 100;
836
837    let conn = super::get_write_connection_guard_static()?;
838    let enc_bundle = enc_txt(bundle_json)?;
839    let enc_inviter = enc_txt(inviter_npub)?;
840    // First-wins: a parked invite is never silently overwritten by a later different
841    // bundle (that would let an attacker replace a genuine parked invite). For v2, a
842    // pre-planted forged-root bundle sharing a real community_id is instead cleared on
843    // a failed accept (see `accept_pending_invite`), so a genuine re-invite can re-park.
844    let changed = conn
845        .execute(
846            "INSERT OR IGNORE INTO pending_community_invites
847                (community_id, bundle_json, inviter_npub, received_at, expires_at)
848             VALUES (?1, ?2, ?3, ?4, ?5)",
849            params![community_id, enc_bundle, enc_inviter, now_secs(), expires_at],
850        )
851        .map_err(|e| format!("save pending invite: {e}"))?;
852    // Only growth can breach the cap. Evict everything past the newest MAX rows
853    // (LIMIT -1 OFFSET cap = "all rows after the first cap"); community_id tie-breaks equal times.
854    if changed > 0 {
855        let _ = conn.execute(
856            "DELETE FROM pending_community_invites
857               WHERE community_id IN (
858                 SELECT community_id FROM pending_community_invites
859                 ORDER BY received_at DESC, community_id DESC
860                 LIMIT -1 OFFSET ?1
861               )",
862            params![MAX_PENDING_INVITES],
863        );
864    }
865    Ok(changed > 0)
866}
867
868/// Drop every parked invite for a community we ALREADY hold — once joined on any device, the
869/// invite must never resurface. Ordering-independent: covers the cross-device case where the
870/// historical gift-wrapped invites are ingested BEFORE the synced membership list rehydrates
871/// those communities (so the ingest-time `community_exists` guard saw nothing yet). Returns the
872/// count purged.
873pub fn purge_pending_invites_for_held_communities() -> Result<usize, String> {
874    let conn = super::get_write_connection_guard_static()?;
875    let n = conn
876        .execute(
877            "DELETE FROM pending_community_invites
878               WHERE community_id IN (SELECT community_id FROM communities)",
879            [],
880        )
881        .map_err(|e| format!("purge held pending invites: {e}"))?;
882    Ok(n)
883}
884
885/// Drop parked invites past their sender-declared NIP-40 expiry OR past the
886/// recipient-enforced 24h lifetime (measured from park time — conservative,
887/// since parking postdates sending). The lifetime leg is what clears rows
888/// parked by builds that predate the ingest-time rule: a machine dormant for
889/// a month otherwise boots into a page of fossil invites no other culler can
890/// touch (no declared expiry, never held, never tombstoned). Returns the
891/// count purged.
892pub fn purge_expired_pending_invites() -> Result<usize, String> {
893    let conn = super::get_write_connection_guard_static()?;
894    let now = now_secs();
895    let n = conn
896        .execute(
897            "DELETE FROM pending_community_invites
898              WHERE (expires_at != 0 AND expires_at <= ?1)
899                 OR received_at <= ?2",
900            params![now, now - crate::event_handler::DIRECT_INVITE_LIFETIME_SECS as i64],
901        )
902        .map_err(|e| format!("purge expired pending invites: {e}"))?;
903    Ok(n)
904}
905
906/// All parked invites, newest first.
907pub fn list_pending_invites() -> Result<Vec<PendingCommunityInvite>, String> {
908    let conn = super::get_db_connection_guard_static()?;
909    let mut stmt = conn
910        .prepare(
911            "SELECT community_id, bundle_json, inviter_npub, received_at, expires_at
912               FROM pending_community_invites
913              WHERE (expires_at = 0 OR expires_at > ?1)
914                AND received_at > ?2
915              ORDER BY received_at DESC",
916        )
917        .map_err(|e| e.to_string())?;
918    let now = now_secs();
919    let rows = stmt
920        .query_map(params![now, now - crate::event_handler::DIRECT_INVITE_LIFETIME_SECS as i64], |r| {
921            Ok(PendingCommunityInvite {
922                community_id: r.get(0)?,
923                bundle_json: dec_txt(&r.get::<_, String>(1)?),
924                inviter_npub: dec_txt(&r.get::<_, String>(2)?),
925                received_at: r.get(3)?,
926                expires_at: r.get(4)?,
927            })
928        })
929        .map_err(|e| e.to_string())?;
930    let mut out = Vec::new();
931    for row in rows {
932        out.push(row.map_err(|e| e.to_string())?);
933    }
934    Ok(out)
935}
936
937/// Read a parked invite's bundle WITHOUT removing it. Accept is fallible (caps,
938/// owner/authority collision), so the row must survive a rejected accept — peek here,
939/// then [`delete_pending_invite`] only after the join succeeds.
940pub fn get_pending_invite(community_id: &str) -> Result<Option<String>, String> {
941    let conn = super::get_db_connection_guard_static()?;
942    let raw: Option<String> = conn
943        .query_row(
944            "SELECT bundle_json FROM pending_community_invites
945              WHERE community_id = ?1 AND (expires_at = 0 OR expires_at > ?2)",
946            params![community_id, now_secs()],
947            |r| r.get::<_, String>(0),
948        )
949        .optional()
950        .map_err(|e| format!("get pending invite: {e}"))?;
951    Ok(raw.map(|s| dec_txt(&s)))
952}
953
954/// Drop a parked invite without joining (the user declined).
955pub fn delete_pending_invite(community_id: &str) -> Result<(), String> {
956    let conn = super::get_write_connection_guard_static()?;
957    conn.execute(
958        "DELETE FROM pending_community_invites WHERE community_id = ?1",
959        params![community_id],
960    )
961    .map_err(|e| format!("delete pending invite: {e}"))?;
962    Ok(())
963}
964
965/// Whether an invite for this id is already parked (inbound dedup).
966/// When a parked invite ARRIVED (unix secs), or `None` if none is parked. The
967/// supersession key for the purge: an invite that arrived AFTER a removal is a genuine
968/// re-invite, not residue of the leave.
969pub fn pending_invite_received_at(community_id: &str) -> Result<Option<i64>, String> {
970    let conn = super::get_db_connection_guard_static()?;
971    conn.query_row(
972        "SELECT received_at FROM pending_community_invites WHERE community_id = ?1",
973        params![community_id],
974        |r| r.get(0),
975    )
976    .optional()
977    .map_err(|e| format!("pending_invite_received_at: {e}"))
978}
979
980pub fn pending_invite_exists(community_id: &str) -> Result<bool, String> {
981    let conn = super::get_db_connection_guard_static()?;
982    let found: Option<i64> = conn
983        .query_row(
984            "SELECT 1 FROM pending_community_invites WHERE community_id = ?1",
985            params![community_id],
986            |r| r.get(0),
987        )
988        .optional()
989        .map_err(|e| format!("pending_invite_exists: {e}"))?;
990    Ok(found.is_some())
991}
992
993/// A minted public-invite link the owner retains (to list + revoke).
994#[derive(Debug, Clone, serde::Serialize)]
995pub struct PublicInviteRecord {
996    /// Hex token (the link's whole secret; lives only in the local account DB).
997    pub token: String,
998    pub community_id: String,
999    pub url: String,
1000    pub expires_at: Option<i64>,
1001    pub created_at: i64,
1002    /// Optional human label set at mint time (e.g. "Twitter", "Discord"). None if unset.
1003    pub label: Option<String>,
1004    /// Distinct members who joined via this link (by label attribution). 0 if none/unknown.
1005    #[serde(default)]
1006    pub join_count: u64,
1007}
1008
1009/// Retain a minted public-invite token so the owner can later list + revoke it.
1010pub fn save_public_invite(
1011    token: &str,
1012    community_id: &str,
1013    url: &str,
1014    expires_at: Option<i64>,
1015    label: Option<&str>,
1016) -> Result<(), String> {
1017    let conn = super::get_write_connection_guard_static()?;
1018    // token + url are the link's secret; encrypted, the token PK becomes per-write-unique (random
1019    // nonce) so this is effectively an INSERT — fine, mints generate a fresh token each time.
1020    let enc_token = enc_txt(token)?;
1021    let enc_url = enc_txt(url)?;
1022    // Encrypt the label at rest like the url; NULL when no label was set.
1023    let enc_label = label.map(enc_txt).transpose()?;
1024    conn.execute(
1025        "INSERT OR REPLACE INTO community_public_invites
1026            (token, community_id, url, expires_at, created_at, label)
1027         VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
1028        params![enc_token, community_id, enc_url, expires_at, now_secs(), enc_label],
1029    )
1030    .map_err(|e| format!("save public invite: {e}"))?;
1031    Ok(())
1032}
1033
1034/// All minted public-invite links for a Community, newest first.
1035pub fn list_public_invites(community_id: &str) -> Result<Vec<PublicInviteRecord>, String> {
1036    let conn = super::get_db_connection_guard_static()?;
1037    let mut stmt = conn
1038        .prepare(
1039            "SELECT token, community_id, url, expires_at, created_at, label
1040               FROM community_public_invites WHERE community_id = ?1 ORDER BY created_at DESC",
1041        )
1042        .map_err(|e| e.to_string())?;
1043    let rows = stmt
1044        .query_map(params![community_id], |r| {
1045            Ok(PublicInviteRecord {
1046                token: dec_txt(&r.get::<_, String>(0)?),
1047                community_id: r.get(1)?,
1048                url: dec_txt(&r.get::<_, String>(2)?),
1049                expires_at: r.get(3)?,
1050                created_at: r.get(4)?,
1051                label: r.get::<_, Option<String>>(5)?.map(|s| dec_txt(&s)),
1052                join_count: 0,
1053            })
1054        })
1055        .map_err(|e| e.to_string())?;
1056    let mut out = Vec::new();
1057    for row in rows {
1058        out.push(row.map_err(|e| e.to_string())?);
1059    }
1060    // Fill per-link join counts (distinct joiners via each label, attributed to me).
1061    if let Some(me) = crate::state::my_public_key().and_then(|pk| pk.to_bech32().ok()) {
1062        if let Ok(counts) = community_invite_join_counts(community_id, &me) {
1063            for rec in &mut out {
1064                if let Some(l) = rec.label.as_deref() {
1065                    rec.join_count = counts.get(l).copied().unwrap_or(0);
1066                }
1067            }
1068        }
1069    }
1070    Ok(out)
1071}
1072
1073/// Forget a minted public-invite token (after revoking it on relays).
1074pub fn delete_public_invite(token: &str) -> Result<(), String> {
1075    let conn = super::get_write_connection_guard_static()?;
1076    // Stored tokens are encrypted (random nonce), so an equality DELETE can't match — scan,
1077    // decrypt, and delete the row whose plaintext token matches (by rowid). Few rows, owner-only.
1078    let rows: Vec<(i64, String)> = {
1079        let mut stmt = conn
1080            .prepare("SELECT rowid, token FROM community_public_invites")
1081            .map_err(|e| e.to_string())?;
1082        let mapped = stmt
1083            .query_map([], |r| Ok((r.get::<_, i64>(0)?, r.get::<_, String>(1)?)))
1084            .map_err(|e| e.to_string())?;
1085        mapped.filter_map(|r| r.ok()).collect()
1086    };
1087    for (rowid, stored) in rows {
1088        if dec_txt(&stored) == token {
1089            conn.execute("DELETE FROM community_public_invites WHERE rowid = ?1", params![rowid])
1090                .map_err(|e| format!("delete public invite: {e}"))?;
1091        }
1092    }
1093    Ok(())
1094}
1095
1096/// All minted public-invite links across ALL communities (backfill source for the synced Invite List).
1097pub fn list_all_public_invites() -> Result<Vec<PublicInviteRecord>, String> {
1098    let conn = super::get_db_connection_guard_static()?;
1099    let mut stmt = conn
1100        .prepare(
1101            "SELECT token, community_id, url, expires_at, created_at, label
1102               FROM community_public_invites ORDER BY created_at DESC",
1103        )
1104        .map_err(|e| e.to_string())?;
1105    let rows = stmt
1106        .query_map([], |r| {
1107            Ok(PublicInviteRecord {
1108                token: dec_txt(&r.get::<_, String>(0)?),
1109                community_id: r.get(1)?,
1110                url: dec_txt(&r.get::<_, String>(2)?),
1111                expires_at: r.get(3)?,
1112                created_at: r.get(4)?,
1113                label: r.get::<_, Option<String>>(5)?.map(|s| dec_txt(&s)),
1114                join_count: 0,
1115            })
1116        })
1117        .map_err(|e| e.to_string())?;
1118    let mut out = Vec::new();
1119    for row in rows {
1120        out.push(row.map_err(|e| e.to_string())?);
1121    }
1122    Ok(out)
1123}
1124
1125/// Insert a public-invite row only if its (decrypted) token isn't already present — idempotent hydration
1126/// from the synced Invite List, PRESERVING the original `created_at` (unlike `save_public_invite`, which
1127/// stamps now). Returns true if a row was inserted. Tokens are stored encrypted with a random nonce, so SQL
1128/// equality can't dedup; scan + decrypt (few rows per community, owner-only).
1129pub fn upsert_public_invite(
1130    token: &str,
1131    community_id: &str,
1132    url: &str,
1133    expires_at: Option<i64>,
1134    created_at: i64,
1135    label: Option<&str>,
1136) -> Result<bool, String> {
1137    let conn = super::get_write_connection_guard_static()?;
1138    let already = {
1139        let mut stmt = conn
1140            .prepare("SELECT token FROM community_public_invites WHERE community_id = ?1")
1141            .map_err(|e| e.to_string())?;
1142        let stored: Vec<String> = stmt
1143            .query_map(params![community_id], |r| r.get::<_, String>(0))
1144            .map_err(|e| e.to_string())?
1145            .filter_map(|r| r.ok())
1146            .collect();
1147        stored.iter().any(|s| dec_txt(s) == token)
1148    };
1149    if already {
1150        return Ok(false);
1151    }
1152    let enc_token = enc_txt(token)?;
1153    let enc_url = enc_txt(url)?;
1154    let enc_label = label.map(enc_txt).transpose()?;
1155    conn.execute(
1156        "INSERT INTO community_public_invites
1157            (token, community_id, url, expires_at, created_at, label)
1158         VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
1159        params![enc_token, community_id, enc_url, expires_at, created_at, enc_label],
1160    )
1161    .map_err(|e| format!("upsert public invite: {e}"))?;
1162    Ok(true)
1163}
1164
1165/// Remove a Community and all its local state (channels, retained message keys, parked
1166/// invites, minted public-invite tokens). Used when the user leaves a Community — there
1167/// is no protocol "leave" (membership is key possession), so leaving is purely local:
1168/// drop the keys + stop subscribing.
1169pub fn delete_community(community_id: &str) -> Result<(), String> {
1170    delete_community_inner(community_id, false)
1171}
1172
1173/// self-removal teardown: drop all local community state EXCEPT the held epoch keys
1174/// (`community_epoch_keys`). Read access to future epochs is already gone (the post-removal
1175/// keys are never delivered); retaining the OLD keys only preserves the ability to author a
1176/// `3305` self-delete of one's own past messages, each sealed under the epoch key it was sent
1177/// at. Used by every self-removal trigger (voluntary leave, kick of me, ban-rekey exclusion).
1178pub fn delete_community_retain_keys(community_id: &str) -> Result<(), String> {
1179    delete_community_inner(community_id, true)
1180}
1181
1182fn delete_community_inner(community_id: &str, retain_keys: bool) -> Result<(), String> {
1183    let conn = super::get_write_connection_guard_static()?;
1184    // Atomic: a crash/error mid-delete must not orphan channel/invite rows under a
1185    // now-missing parent (community_id_for_channel would still resolve them).
1186    let tx = conn.unchecked_transaction().map_err(|e| format!("delete community tx: {e}"))?;
1187    for sql in [
1188        Some("DELETE FROM communities WHERE community_id = ?1"),
1189        Some("DELETE FROM community_channels WHERE community_id = ?1"),
1190        // Multi-held epoch keys (base + per-channel, all epochs). RETAINED on a self-removal so a
1191        // later self-scrub of own past messages stays possible; dropped on an explicit delete/re-join reset
1192        // (else a re-join inherits stale rotated keys).
1193        (!retain_keys).then_some("DELETE FROM community_epoch_keys WHERE community_id = ?1"),
1194        Some("DELETE FROM community_public_invites WHERE community_id = ?1"),
1195        Some("DELETE FROM community_invite_link_sets WHERE community_id = ?1"),
1196        Some("DELETE FROM pending_community_invites WHERE community_id = ?1"),
1197        // Parked key vends. Dropped even under `retain_keys`: those are OUR held
1198        // epoch keys kept for a later self-scrub, whereas a parked vend is
1199        // undelivered key material for a community we no longer hold — nothing
1200        // re-judges it once the community is gone, and it would resurrect on
1201        // re-join to seat a stale epoch.
1202        Some("DELETE FROM pending_channel_keys WHERE community_id = ?1"),
1203        // Per-entity edition heads (keyless model) — else stale refuse-downgrade floors + self_hash
1204        // anchors survive a leave/re-join and reject a legitimately reset chain.
1205        Some("DELETE FROM community_edition_heads WHERE community_id = ?1"),
1206    ]
1207    .into_iter()
1208    .flatten()
1209    {
1210        tx.execute(sql, params![community_id])
1211            .map_err(|e| format!("delete community: {e}"))?;
1212    }
1213    tx.commit().map_err(|e| format!("delete community commit: {e}"))?;
1214    BANLIST_CACHE.write().unwrap().remove(community_id);
1215    forget_community_channels(community_id);
1216    // `community_message_keys` is INTENTIONALLY left intact: those are our OWN ephemeral signing keys for
1217    // NIP-09-deleting our own messages. The right to erase our own content from relays outlives membership
1218    // — even after a ban or leave we must keep the ability to purge what we sent — so they survive a
1219    // community delete. (Keyed by message_id, no community_id; there is nothing community-scoped to drop.)
1220    Ok(())
1221}
1222
1223/// Observed participants: the best-effort member list of a Community, newest-active first.
1224/// Membership is NOT authoritative (a lurker who never posts and never announced won't appear).
1225/// A member is included when they have real activity — a posted message/reaction/edit, OR a
1226/// join presence (kind 3306) — UNLESS that is superseded by a more-recent leave, OR they are
1227/// banned. So a "leave" actually removes a member, and a leave-then-rejoin/post re-adds them.
1228/// `created_at` is in seconds. Result is capped (anti-flood); see [`COMMUNITY_MEMBER_CAP`].
1229pub fn community_member_activity(community_id: &str) -> Result<Vec<(String, u64)>, String> {
1230    community_member_activity_capped(community_id, true)
1231}
1232
1233/// [`community_member_activity`] with the anti-flood display cap OPTIONAL. The migration
1234/// roster seed passes `capped = false` so a >500-member v1 community seeds EVERY member
1235/// — a silent truncation there would permanently strand the dropped members (absent from the
1236/// snapshot → absent from `memberlist()` → excluded from every future v2 rotation). Every
1237/// other caller keeps the cap.
1238pub fn community_member_activity_capped(community_id: &str, capped: bool) -> Result<Vec<(String, u64)>, String> {
1239    /// Cap on rendered members — bounds a presence-flood (fresh-identity 3306 spam) from
1240    /// growing the list / profile-fetch fan-out without limit.
1241    const COMMUNITY_MEMBER_CAP: usize = 500;
1242    use std::collections::HashMap;
1243
1244    let community = match load_community(&CommunityId(hex_id_to_32(community_id)?))? {
1245        Some(c) => c,
1246        None => return Ok(Vec::new()),
1247    };
1248    // The proven owner is ALWAYS a member of their own community. Seed them so a freshly-created
1249    // community (no message/presence events yet) still shows its creator instead of an empty roster.
1250    // `now_secs()` is just a presence baseline; real activity below overwrites it, and the UI re-sorts
1251    // by role tier regardless.
1252    let owner_b32: Option<String> = community
1253        .owner_attestation
1254        .as_deref()
1255        .and_then(|att| crate::community::owner::verify_owner_attestation(att, community_id))
1256        .and_then(|pk| pk.to_bech32().ok());
1257
1258    // Map each channel's hex id → its integer chat row id (skip channels with no events yet).
1259    let mut chat_ints: Vec<i64> = Vec::new();
1260    for ch in &community.channels {
1261        if let Ok(cid) = super::id_cache::get_chat_id_by_identifier(&ch.id.to_hex()) {
1262            chat_ints.push(cid);
1263        }
1264    }
1265
1266    // APPLICATION_SPECIFIC (30078) is the kind for presence/system events; everything else in a
1267    // community channel is real message activity. Inlined as a constant integer (no injection).
1268    let sys = crate::stored_event::event_kind::APPLICATION_SPECIFIC;
1269
1270    // active_at[npub] = newest real-activity time (any non-presence event), folded with joins below. The
1271    // proven owner + roster grant-holders are NOT seeded here — they're re-asserted AFTER the leave/ban
1272    // filter (else a stale message would overwrite the seed and a later `left` would wrongly cut a current
1273    // admin — the retain-set inversion). See the re-assert block below.
1274    let mut active: HashMap<String, u64> = HashMap::new();
1275    let mut left: HashMap<String, u64> = HashMap::new();
1276    // No channel has any events yet (e.g. fresh community) → skip the activity queries; the owner + roster
1277    // are still surfaced by the post-filter re-assert below.
1278    if !chat_ints.is_empty() {
1279    let conn = super::get_db_connection_guard_static()?;
1280    let placeholders = chat_ints.iter().map(|_| "?").collect::<Vec<_>>().join(",");
1281
1282    {
1283        let sql = format!(
1284            "SELECT npub, MAX(created_at) FROM events \
1285             WHERE chat_id IN ({placeholders}) AND kind != {sys} AND npub IS NOT NULL AND npub != '' \
1286             GROUP BY npub"
1287        );
1288        let mut stmt = conn.prepare(&sql).map_err(|e| e.to_string())?;
1289        let rows = stmt
1290            .query_map(rusqlite::params_from_iter(chat_ints.iter()), |r| {
1291                Ok((r.get::<_, String>(0)?, r.get::<_, i64>(1)?.max(0) as u64))
1292            })
1293            .map_err(|e| e.to_string())?;
1294        for row in rows {
1295            let (npub, at) = row.map_err(|e| e.to_string())?;
1296            active.insert(npub, at);
1297        }
1298    }
1299
1300    // Fold presence: a join (event-type "1") is activity; a leave (event-type "0") may remove.
1301    {
1302        let sql = format!(
1303            "SELECT npub, created_at, tags FROM events \
1304             WHERE chat_id IN ({placeholders}) AND kind = {sys} AND npub IS NOT NULL AND npub != ''"
1305        );
1306        let mut stmt = conn.prepare(&sql).map_err(|e| e.to_string())?;
1307        let rows = stmt
1308            .query_map(rusqlite::params_from_iter(chat_ints.iter()), |r| {
1309                Ok((r.get::<_, String>(0)?, r.get::<_, i64>(1)?.max(0) as u64, r.get::<_, String>(2)?))
1310            })
1311            .map_err(|e| e.to_string())?;
1312        for row in rows {
1313            let (npub, at, tags_json) = row.map_err(|e| e.to_string())?;
1314            // SystemEventType: 1 = MemberJoined, 0 = MemberLeft (carried in an ["event-type", n] tag).
1315            let etype = serde_json::from_str::<Vec<Vec<String>>>(&tags_json)
1316                .ok()
1317                .and_then(|tags| {
1318                    tags.into_iter()
1319                        .find(|t| t.first().map(|s| s == "event-type").unwrap_or(false))
1320                        .and_then(|t| t.into_iter().nth(1))
1321                });
1322            match etype.as_deref() {
1323                Some("1") => {
1324                    let e = active.entry(npub).or_insert(0);
1325                    if at > *e { *e = at; }
1326                }
1327                Some("0") => {
1328                    let e = left.entry(npub).or_insert(0);
1329                    if at > *e { *e = at; }
1330                }
1331                _ => {}
1332            }
1333        }
1334    }
1335    }
1336
1337    // Exclude banned (banlist is hex; events store bech32 — compare on bech32). Denormalized
1338    // identically onto every channel at load, so reading channels[0] is sufficient.
1339    let banned: std::collections::HashSet<String> = community
1340        .channels
1341        .first()
1342        .map(|c| c.banned.iter().filter_map(|pk| pk.to_bech32().ok()).collect())
1343        .unwrap_or_default();
1344
1345    // Member iff active, not banned, and last activity is at-or-after the last leave.
1346    let mut out: Vec<(String, u64)> = active
1347        .into_iter()
1348        .filter(|(npub, at)| !banned.contains(npub) && left.get(npub).map_or(true, |l| at >= l))
1349        .collect();
1350
1351    // RE-ASSERT authorized members AFTER the activity/leave filter: the proven owner + every
1352    // non-empty-grant roster holder is a member regardless of stale activity or a `left` — a privatize/ban
1353    // retain set must NEVER silently shed an authorized member (a leave or an old message must not drop a
1354    // current admin; that read-cut would lock a sitting admin out of their own community). Banned is the
1355    // only exclusion (a ban revokes the role anyway). Stamped `now_secs()` so they sort to the top and
1356    // survive the cap. Computed POST-filter so neither the leave filter nor a stale overwrite can cut them.
1357    {
1358        let mut present: std::collections::HashSet<String> = out.iter().map(|(n, _)| n.clone()).collect();
1359        let mut reassert = |npub: String| {
1360            if !banned.contains(&npub) && present.insert(npub.clone()) {
1361                out.push((npub, now_secs() as u64));
1362            }
1363        };
1364        if let Some(o) = owner_b32 {
1365            reassert(o);
1366        }
1367        if let Ok(roles) = get_community_roles(community_id) {
1368            for g in &roles.grants {
1369                if g.role_ids.is_empty() {
1370                    continue; // an empty grant is a revoked role, not a member
1371                }
1372                if let Some(b32) = PublicKey::from_hex(&g.member).ok().and_then(|pk| pk.to_bech32().ok()) {
1373                    reassert(b32);
1374                }
1375            }
1376        }
1377    }
1378    out.sort_by(|a, b| b.1.cmp(&a.1));
1379    if capped {
1380        out.truncate(COMMUNITY_MEMBER_CAP);
1381    }
1382    Ok(out)
1383}
1384
1385/// Per-link join counts for the owner's public invites: `label -> distinct joiners` who joined
1386/// via a link minted by `inviter_npub` (bech32). Reads the `invited-by` / `invited-label` tags on
1387/// MemberJoined system events; distinct by joiner npub so a rejoin isn't double-counted. Labels are
1388/// unique per creator (random fallback ensures it), so (inviter, label) keys a single link.
1389pub fn community_invite_join_counts(
1390    community_id: &str,
1391    inviter_npub: &str,
1392) -> Result<std::collections::HashMap<String, u64>, String> {
1393    use std::collections::{HashMap, HashSet};
1394    let community = match load_community(&CommunityId(hex_id_to_32(community_id)?))? {
1395        Some(c) => c,
1396        None => return Ok(HashMap::new()),
1397    };
1398    let mut chat_ints: Vec<i64> = Vec::new();
1399    for ch in &community.channels {
1400        if let Ok(cid) = super::id_cache::get_chat_id_by_identifier(&ch.id.to_hex()) {
1401            chat_ints.push(cid);
1402        }
1403    }
1404    if chat_ints.is_empty() {
1405        return Ok(HashMap::new());
1406    }
1407    let sys = crate::stored_event::event_kind::APPLICATION_SPECIFIC;
1408    let conn = super::get_db_connection_guard_static()?;
1409    let placeholders = chat_ints.iter().map(|_| "?").collect::<Vec<_>>().join(",");
1410    let sql = format!(
1411        "SELECT npub, tags FROM events \
1412         WHERE chat_id IN ({placeholders}) AND kind = {sys} AND npub IS NOT NULL AND npub != ''"
1413    );
1414    let mut stmt = conn.prepare(&sql).map_err(|e| e.to_string())?;
1415    let rows = stmt
1416        .query_map(rusqlite::params_from_iter(chat_ints.iter()), |r| {
1417            Ok((r.get::<_, String>(0)?, r.get::<_, String>(1)?))
1418        })
1419        .map_err(|e| e.to_string())?;
1420    // label -> set of distinct joiner npubs
1421    let mut per_label: HashMap<String, HashSet<String>> = HashMap::new();
1422    for row in rows {
1423        let (joiner, tags_json) = row.map_err(|e| e.to_string())?;
1424        let tags = match serde_json::from_str::<Vec<Vec<String>>>(&tags_json) {
1425            Ok(t) => t,
1426            Err(_) => continue,
1427        };
1428        let tag_val = |key: &str| -> Option<String> {
1429            tags.iter()
1430                .find(|t| t.first().map(|s| s == key).unwrap_or(false))
1431                .and_then(|t| t.get(1).cloned())
1432        };
1433        // MemberJoined (event-type "1") attributed to THIS owner's link, with a label.
1434        if tag_val("event-type").as_deref() != Some("1") {
1435            continue;
1436        }
1437        if tag_val("invited-by").as_deref() != Some(inviter_npub) {
1438            continue;
1439        }
1440        if let Some(label) = tag_val("invited-label") {
1441            per_label.entry(label).or_default().insert(joiner);
1442        }
1443    }
1444    Ok(per_label.into_iter().map(|(k, v)| (k, v.len() as u64)).collect())
1445}
1446
1447/// Replace a Community's stored banlist (JSON array of hex pubkeys) + the `created_at` (secs) of
1448/// the edition it came from. `at` is the version: the owner's own ban/unban writes its freshly
1449/// built event time, and `fetch_and_apply_banlist` only calls this with a strictly-newer edition,
1450/// so the stored banlist can never roll backwards.
1451// ── Banlist cache ────────────────────────────────────────────────────────────
1452// The inbound ban check runs per community event; uncached, each call costs a DB
1453// fetch + ChaCha20 vault decrypt + JSON parse, even though the banlist only changes
1454// on a fold. Cache the banned pubkeys as raw bytes, keyed by community_id — kept
1455// coherent by write-through in `set_community_banlist` and eviction in
1456// `delete_community_inner`, and cleared wholesale on account swap via
1457// `clear_id_caches`. Absent entry = not yet loaded (lazy-fills from DB on first read).
1458static BANLIST_CACHE: std::sync::LazyLock<
1459    std::sync::RwLock<std::collections::HashMap<String, std::sync::Arc<std::collections::HashSet<[u8; 32]>>>>,
1460> = std::sync::LazyLock::new(|| std::sync::RwLock::new(std::collections::HashMap::new()));
1461
1462fn banlist_set_from_hexes(hexes: &[String]) -> std::collections::HashSet<[u8; 32]> {
1463    hexes.iter().filter_map(|h| crate::simd::hex::hex_to_bytes_32_checked(h)).collect()
1464}
1465
1466/// Drop every cached banlist. The stored sets belong to the previous account's DB.
1467pub fn clear_banlist_cache() {
1468    BANLIST_CACHE.write().unwrap().clear();
1469}
1470
1471/// The banned-pubkey set for a community, lazily decrypted+parsed from DB on first
1472/// use and held (no further DB/decrypt) until a fold or delete invalidates it.
1473pub fn banned_set(community_id: &str) -> std::sync::Arc<std::collections::HashSet<[u8; 32]>> {
1474    if let Some(set) = BANLIST_CACHE.read().unwrap().get(community_id) {
1475        return std::sync::Arc::clone(set);
1476    }
1477    let set = std::sync::Arc::new(banlist_set_from_hexes(
1478        &get_community_banlist(community_id).unwrap_or_default(),
1479    ));
1480    BANLIST_CACHE
1481        .write()
1482        .unwrap()
1483        .insert(community_id.to_string(), std::sync::Arc::clone(&set));
1484    set
1485}
1486
1487/// Whether `author` is banned in this community. The per-event hot check: a HashSet
1488/// lookup once warm, no DB / decrypt / parse.
1489pub fn is_author_banned(community_id: &str, author: &PublicKey) -> bool {
1490    let set = banned_set(community_id);
1491    !set.is_empty() && set.contains(&author.to_bytes())
1492}
1493
1494pub fn set_community_banlist(community_id: &str, banned_hex: &[String], at: i64) -> Result<(), String> {
1495    let json = enc_txt(&serde_json::to_string(banned_hex).map_err(|e| e.to_string())?)?;
1496    let conn = super::get_write_connection_guard_static()?;
1497    conn.execute(
1498        "UPDATE communities SET banlist = ?1, banlist_at = ?2 WHERE community_id = ?3",
1499        params![json, at, community_id],
1500    )
1501    .map_err(|e| format!("set banlist: {e}"))?;
1502    // Write-through: the hot-path cache must not lag a fold — a stale ban would wrongly
1503    // vanish a now-unbanned author's messages (fail-closed).
1504    BANLIST_CACHE
1505        .write()
1506        .unwrap()
1507        .insert(community_id.to_string(), std::sync::Arc::new(banlist_set_from_hexes(banned_hex)));
1508    Ok(())
1509}
1510
1511/// Per-npub ban marks: lowercase-hex npub → `created_at` (secs) of the newest AUTHORIZED
1512/// banlist edition that named them. Retained past an un-ban on purpose — it is what stops a
1513/// pre-ban Join resurrecting a phantom member (CORD-02 §5 counts observation forward of the
1514/// latest Leave, Kick **or Ban**).
1515pub fn get_community_ban_marks(community_id: &str) -> Result<std::collections::BTreeMap<String, u64>, String> {
1516    let conn = super::get_db_connection_guard_static()?;
1517    let json: Option<String> = conn
1518        .query_row(
1519            "SELECT banlist_marks FROM communities WHERE community_id = ?1",
1520            params![community_id],
1521            |r| r.get(0),
1522        )
1523        .optional()
1524        .map_err(|e| format!("get ban marks: {e}"))?;
1525    Ok(json.and_then(|j| serde_json::from_str(&dec_txt(&j)).ok()).unwrap_or_default())
1526}
1527
1528/// MERGE fresh ban marks into the stored set, keeping the LATER time per npub and never
1529/// dropping an npub. A fold only sees the editions still in its window, so replacing
1530/// wholesale would forget every ban that has since aged out — precisely the history the
1531/// suppression depends on.
1532pub fn merge_community_ban_marks(community_id: &str, marks: &std::collections::BTreeMap<String, u64>) -> Result<bool, String> {
1533    if marks.is_empty() {
1534        return Ok(false);
1535    }
1536    let mut stored = get_community_ban_marks(community_id)?;
1537    let mut changed = false;
1538    for (npub, at) in marks {
1539        let slot = stored.entry(npub.clone()).or_insert(0);
1540        if *at > *slot {
1541            *slot = *at;
1542            changed = true;
1543        }
1544    }
1545    if !changed {
1546        return Ok(false);
1547    }
1548    let json = enc_txt(&serde_json::to_string(&stored).map_err(|e| e.to_string())?)?;
1549    let conn = super::get_write_connection_guard_static()?;
1550    conn.execute(
1551        "UPDATE communities SET banlist_marks = ?1 WHERE community_id = ?2",
1552        params![json, community_id],
1553    )
1554    .map_err(|e| format!("set ban marks: {e}"))?;
1555    Ok(true)
1556}
1557
1558/// The `created_at` (secs) of the banlist edition currently stored, or 0 if none. The version
1559/// floor the rollback guard compares against.
1560pub fn get_community_banlist_at(community_id: &str) -> Result<i64, String> {
1561    let conn = super::get_db_connection_guard_static()?;
1562    let at: Option<i64> = conn
1563        .query_row(
1564            "SELECT banlist_at FROM communities WHERE community_id = ?1",
1565            params![community_id],
1566            |r| r.get(0),
1567        )
1568        .optional()
1569        .map_err(|e| format!("get banlist_at: {e}"))?;
1570    Ok(at.unwrap_or(0))
1571}
1572
1573/// Replace a Community's cached role graph (the aggregated `CommunityRoles`) + the `created_at`
1574/// (secs) of the newest per-entity edition it was built from. `at` is the version floor: the
1575/// fetch path only calls this with a strictly-newer aggregate, so the role graph can't roll
1576/// backwards (same guard as the banlist).
1577pub fn set_community_roles(
1578    community_id: &str,
1579    roles: &crate::community::roles::CommunityRoles,
1580    at: i64,
1581) -> Result<(), String> {
1582    let json = enc_txt(&serde_json::to_string(roles).map_err(|e| e.to_string())?)?;
1583    let conn = super::get_write_connection_guard_static()?;
1584    conn.execute(
1585        "UPDATE communities SET roles = ?1, roles_at = ?2 WHERE community_id = ?3",
1586        params![json, at, community_id],
1587    )
1588    .map_err(|e| format!("set roles: {e}"))?;
1589    Ok(())
1590}
1591
1592/// A Community's cached role graph. Empty (default) for an unknown community or none stored.
1593pub fn get_community_roles(
1594    community_id: &str,
1595) -> Result<crate::community::roles::CommunityRoles, String> {
1596    let conn = super::get_db_connection_guard_static()?;
1597    let json: Option<String> = conn
1598        .query_row(
1599            "SELECT roles FROM communities WHERE community_id = ?1",
1600            params![community_id],
1601            |r| r.get(0),
1602        )
1603        .optional()
1604        .map_err(|e| format!("get roles: {e}"))?;
1605    Ok(json.and_then(|j| serde_json::from_str(&dec_txt(&j)).ok()).unwrap_or_default())
1606}
1607
1608/// The `created_at` (secs) of the role-graph edition currently stored, or 0 if none.
1609pub fn get_community_roles_at(community_id: &str) -> Result<i64, String> {
1610    let conn = super::get_db_connection_guard_static()?;
1611    let at: Option<i64> = conn
1612        .query_row(
1613            "SELECT roles_at FROM communities WHERE community_id = ?1",
1614            params![community_id],
1615            |r| r.get(0),
1616        )
1617        .optional()
1618        .map_err(|e| format!("get roles_at: {e}"))?;
1619    Ok(at.unwrap_or(0))
1620}
1621
1622/// Record the current head (version + self_hash) of a control entity's edition chain (keyless model).
1623/// The send side reads this to emit the next edition as `version+1` citing `self_hash` as `prev_hash`;
1624/// the fold uses it as the per-entity refuse-downgrade floor + anchor. Upserts per (community, entity).
1625/// `inner_id` is the head edition's deterministic tiebreak key (used only by [`converge_edition_head`]).
1626pub fn set_edition_head(community_id: &str, entity_id: &str, version: u64, self_hash: &[u8; 32]) -> Result<(), String> {
1627    set_edition_head_inner(community_id, entity_id, version, self_hash, None, None)
1628}
1629
1630/// As [`set_edition_head`], but also records the head edition's `inner_id` (the deterministic tiebreak
1631/// key), so a later same-version convergence can rank against it. A plain advance carries it through.
1632pub fn set_edition_head_with_id(community_id: &str, entity_id: &str, version: u64, self_hash: &[u8; 32], inner_id: &[u8; 32]) -> Result<(), String> {
1633    set_edition_head_inner(community_id, entity_id, version, self_hash, Some(inner_id), None)
1634}
1635
1636/// As [`set_edition_head_with_id`], stamping an EXPLICIT epoch — the epoch the caller's fold actually
1637/// ran under — instead of reading the community row at write time. Closes the TOCTOU where a
1638/// concurrent re-founding bumps `server_root_epoch` between a fold and its head persist, which would
1639/// stamp an old-plane version as the new epoch's floor and wedge the new epoch's genuine head.
1640pub fn set_edition_head_at_epoch(community_id: &str, entity_id: &str, version: u64, self_hash: &[u8; 32], inner_id: &[u8; 32], epoch: u64) -> Result<(), String> {
1641    set_edition_head_inner(community_id, entity_id, version, self_hash, Some(inner_id), Some(epoch))
1642}
1643
1644fn set_edition_head_inner(community_id: &str, entity_id: &str, version: u64, self_hash: &[u8; 32], inner_id: Option<&[u8; 32]>, epoch: Option<u64>) -> Result<(), String> {
1645    let conn = super::get_write_connection_guard_static()?;
1646    // MONOTONIC, EPOCH-PRIMARY: the head IS the refuse-downgrade floor. The recorded `epoch` is
1647    // the fold's epoch when given explicitly, else the community's current server-root epoch
1648    // (re-founding bumps it + resets versions to 1). A higher epoch ALWAYS supersedes (so a
1649    // re-founding's v1 lands over a held v21); within an epoch, version still only advances. So a
1650    // stale/hostile rollback can lower neither the epoch nor the in-epoch version.
1651    conn.execute(
1652        "INSERT INTO community_edition_heads (community_id, entity_id, version, self_hash, inner_id, epoch)
1653         VALUES (?1, ?2, ?3, ?4, ?5, COALESCE(?6, (SELECT server_root_epoch FROM communities WHERE community_id = ?1), 0))
1654         ON CONFLICT(community_id, entity_id) DO UPDATE SET
1655            version = excluded.version,
1656            self_hash = excluded.self_hash,
1657            inner_id = excluded.inner_id,
1658            epoch = excluded.epoch
1659         WHERE excluded.epoch > community_edition_heads.epoch
1660            OR (excluded.epoch = community_edition_heads.epoch AND excluded.version > community_edition_heads.version)",
1661        params![community_id, entity_id, version as i64, self_hash.as_slice(), inner_id.map(|i| i.as_slice()), epoch.map(|e| e as i64)],
1662    )
1663    .map_err(|e| format!("set edition head: {e}"))?;
1664    Ok(())
1665}
1666
1667/// Converge the head to a same-version fork winner (concurrent-edit resolution). Unlike
1668/// [`set_edition_head`] (which only ADVANCES the version), this resolves a fork AT the current version:
1669/// two authorized editors editing concurrently from the same base both produce `version`, and every
1670/// client must adopt the SAME one. The winner is the lower deterministic `inner_id`, so this update
1671/// fires only when the incoming edition ties the stored version AND carries a strictly lower `inner_id`
1672/// — monotonic toward the global minimum, so it can never flip-flop (a relay can't churn the head by
1673/// reordering, and a held row with a NULL `inner_id`, pre-migration, is treated as "always replaceable"
1674/// so it heals to a ranked id). The version-advance path is unchanged and still handled by
1675/// [`set_edition_head_with_id`]; callers run BOTH (advance covers v+1, converge covers a same-v fork).
1676pub fn converge_edition_head(community_id: &str, entity_id: &str, version: u64, self_hash: &[u8; 32], inner_id: &[u8; 32]) -> Result<(), String> {
1677    converge_edition_head_inner(community_id, entity_id, version, self_hash, inner_id, None)
1678}
1679
1680/// As [`converge_edition_head`], scoped to an EXPLICIT epoch (the epoch the caller's fold ran under)
1681/// rather than the community row's write-time value — same TOCTOU rationale as
1682/// [`set_edition_head_at_epoch`].
1683pub fn converge_edition_head_at_epoch(community_id: &str, entity_id: &str, version: u64, self_hash: &[u8; 32], inner_id: &[u8; 32], epoch: u64) -> Result<(), String> {
1684    converge_edition_head_inner(community_id, entity_id, version, self_hash, inner_id, Some(epoch))
1685}
1686
1687fn converge_edition_head_inner(community_id: &str, entity_id: &str, version: u64, self_hash: &[u8; 32], inner_id: &[u8; 32], epoch: Option<u64>) -> Result<(), String> {
1688    let conn = super::get_write_connection_guard_static()?;
1689    // Scoped to the CURRENT epoch's head: a fork is resolved within an epoch, never across one (an epoch
1690    // bump is a re-founding, handled by the advance path). `epoch` matches the community's current epoch.
1691    conn.execute(
1692        "UPDATE community_edition_heads
1693            SET self_hash = ?4, inner_id = ?5
1694          WHERE community_id = ?1 AND entity_id = ?2
1695            AND version = ?3
1696            AND epoch = COALESCE(?6, (SELECT server_root_epoch FROM communities WHERE community_id = ?1), 0)
1697            AND (inner_id IS NULL OR ?5 < inner_id)",
1698        params![community_id, entity_id, version as i64, self_hash.as_slice(), inner_id.as_slice(), epoch.map(|e| e as i64)],
1699    )
1700    .map_err(|e| format!("converge edition head: {e}"))?;
1701    Ok(())
1702}
1703
1704/// The held head's tiebreak key (`inner_id`), or `None` if unheld or pre-migration (NULL). The consumer
1705/// uses this to decide a same-version convergence exactly as [`converge_edition_head`]'s SQL does (a
1706/// NULL/None held id is "always replaceable") — so it never applies a display edit the head write would
1707/// then refuse.
1708pub fn get_edition_head_inner_id(community_id: &str, entity_id: &str) -> Result<Option<[u8; 32]>, String> {
1709    let conn = super::get_db_connection_guard_static()?;
1710    let row: Option<Option<Vec<u8>>> = conn
1711        .query_row(
1712            "SELECT inner_id FROM community_edition_heads WHERE community_id = ?1 AND entity_id = ?2",
1713            params![community_id, entity_id],
1714            |r| r.get(0),
1715        )
1716        .optional()
1717        .map_err(|e| format!("get edition head inner_id: {e}"))?;
1718    match row.flatten() {
1719        Some(blob) if blob.len() == 32 => {
1720            let mut h = [0u8; 32];
1721            h.copy_from_slice(&blob);
1722            Ok(Some(h))
1723        }
1724        _ => Ok(None),
1725    }
1726}
1727
1728/// The current head `(version, self_hash)` of a control entity's edition chain, or `None` if no
1729/// edition is held yet (so the next edition is the genesis, version 1, no prev_hash).
1730pub fn get_edition_head(community_id: &str, entity_id: &str) -> Result<Option<(u64, [u8; 32])>, String> {
1731    let conn = super::get_db_connection_guard_static()?;
1732    let row: Option<(i64, Vec<u8>)> = conn
1733        .query_row(
1734            "SELECT version, self_hash FROM community_edition_heads WHERE community_id = ?1 AND entity_id = ?2",
1735            params![community_id, entity_id],
1736            |r| Ok((r.get(0)?, r.get(1)?)),
1737        )
1738        .optional()
1739        .map_err(|e| format!("get edition head: {e}"))?;
1740    match row {
1741        Some((v, hash)) if hash.len() == 32 => {
1742            let mut h = [0u8; 32];
1743            h.copy_from_slice(&hash);
1744            Ok(Some((v as u64, h)))
1745        }
1746        _ => Ok(None),
1747    }
1748}
1749
1750/// The set of control-entity ids (hex) this account tracks a head for. A base rotation gates its
1751/// head-advance on re-anchoring covering EVERY one of these (not just a matching count), so a relay
1752/// that withholds one entity's editions while over-serving another's can't slip a thinned control
1753/// plane past the rotator.
1754pub fn edition_head_entity_ids(community_id: &str) -> Result<std::collections::HashSet<String>, String> {
1755    let conn = super::get_db_connection_guard_static()?;
1756    let mut stmt = conn
1757        .prepare("SELECT entity_id FROM community_edition_heads WHERE community_id = ?1")
1758        .map_err(|e| e.to_string())?;
1759    let rows = stmt
1760        .query_map(params![community_id], |r| r.get::<_, String>(0))
1761        .map_err(|e| e.to_string())?;
1762    let mut out = std::collections::HashSet::new();
1763    for row in rows {
1764        out.insert(row.map_err(|e| e.to_string())?);
1765    }
1766    Ok(out)
1767}
1768
1769
1770/// Every tracked control entity's persisted head `(entity_id hex → (version, self_hash))`. This is the
1771/// per-entity refuse-downgrade FLOOR: the fold seeds each entity's chain from its held head, so a
1772/// withholding relay serving editions BELOW what we already hold can't roll an authority chain back
1773/// (e.g. resurrecting a since-revoked admin's old grant). An empty map = a bootstrapping joiner (folds
1774/// from genesis, floor 0).
1775pub fn get_all_edition_heads(community_id: &str) -> Result<std::collections::HashMap<String, (u64, [u8; 32])>, String> {
1776    let conn = super::get_db_connection_guard_static()?;
1777    let mut stmt = conn
1778        .prepare("SELECT entity_id, version, self_hash FROM community_edition_heads WHERE community_id = ?1")
1779        .map_err(|e| e.to_string())?;
1780    let rows = stmt
1781        .query_map(params![community_id], |r| {
1782            Ok((r.get::<_, String>(0)?, r.get::<_, i64>(1)?, r.get::<_, Vec<u8>>(2)?))
1783        })
1784        .map_err(|e| e.to_string())?;
1785    let mut out = std::collections::HashMap::new();
1786    for row in rows {
1787        let (entity, version, hash) = row.map_err(|e| e.to_string())?;
1788        if hash.len() == 32 {
1789            let mut h = [0u8; 32];
1790            h.copy_from_slice(&hash);
1791            out.insert(entity, (version as u64, h));
1792        }
1793    }
1794    Ok(out)
1795}
1796
1797/// Every tracked head as `entity_hex → (epoch, version, self_hash)` — the epoch-primary floor.
1798/// The caller seeds the fold with ONLY the entities at the community's CURRENT epoch (a head recorded
1799/// at a PRIOR epoch belongs to a superseded founding, so its entity folds fresh from the new epoch's v1
1800/// genesis). This is what lets a re-founding's compacted v1 plane land without a version-only downgrade.
1801/// Every tracked head as `entity_hex → (epoch, version, self_hash, inner_id)` — the epoch-primary
1802/// floor INCLUDING the deterministic tiebreak key, so a fold can resolve a same-version fork at the
1803/// floor (converge to the lower inner id) instead of wedging on it.
1804pub fn get_all_edition_heads_full(community_id: &str) -> Result<std::collections::HashMap<String, (u64, u64, [u8; 32], Option<[u8; 32]>)>, String> {
1805    let conn = super::get_db_connection_guard_static()?;
1806    let mut stmt = conn
1807        .prepare("SELECT entity_id, epoch, version, self_hash, inner_id FROM community_edition_heads WHERE community_id = ?1")
1808        .map_err(|e| e.to_string())?;
1809    let rows = stmt
1810        .query_map(params![community_id], |r| {
1811            Ok((r.get::<_, String>(0)?, r.get::<_, i64>(1)?, r.get::<_, i64>(2)?, r.get::<_, Vec<u8>>(3)?, r.get::<_, Option<Vec<u8>>>(4)?))
1812        })
1813        .map_err(|e| e.to_string())?;
1814    let mut out = std::collections::HashMap::new();
1815    for row in rows {
1816        let (entity, epoch, version, hash, inner) = row.map_err(|e| e.to_string())?;
1817        if hash.len() == 32 {
1818            let mut h = [0u8; 32];
1819            h.copy_from_slice(&hash);
1820            let inner_id = inner.and_then(|b| {
1821                (b.len() == 32).then(|| {
1822                    let mut i = [0u8; 32];
1823                    i.copy_from_slice(&b);
1824                    i
1825                })
1826            });
1827            out.insert(entity, (epoch as u64, version as u64, h, inner_id));
1828        }
1829    }
1830    Ok(out)
1831}
1832
1833pub fn get_all_edition_heads_epoched(community_id: &str) -> Result<std::collections::HashMap<String, (u64, u64, [u8; 32])>, String> {
1834    let conn = super::get_db_connection_guard_static()?;
1835    let mut stmt = conn
1836        .prepare("SELECT entity_id, epoch, version, self_hash FROM community_edition_heads WHERE community_id = ?1")
1837        .map_err(|e| e.to_string())?;
1838    let rows = stmt
1839        .query_map(params![community_id], |r| {
1840            Ok((r.get::<_, String>(0)?, r.get::<_, i64>(1)?, r.get::<_, i64>(2)?, r.get::<_, Vec<u8>>(3)?))
1841        })
1842        .map_err(|e| e.to_string())?;
1843    let mut out = std::collections::HashMap::new();
1844    for row in rows {
1845        let (entity, epoch, version, hash) = row.map_err(|e| e.to_string())?;
1846        if hash.len() == 32 {
1847            let mut h = [0u8; 32];
1848            h.copy_from_slice(&hash);
1849            out.insert(entity, (epoch as u64, version as u64, h));
1850        }
1851    }
1852    Ok(out)
1853}
1854
1855/// A Community's current banlist (hex pubkeys). Empty for an unknown community or empty list.
1856pub fn get_community_banlist(community_id: &str) -> Result<Vec<String>, String> {
1857    let conn = super::get_db_connection_guard_static()?;
1858    let json: Option<String> = conn
1859        .query_row(
1860            "SELECT banlist FROM communities WHERE community_id = ?1",
1861            params![community_id],
1862            |r| r.get(0),
1863        )
1864        .optional()
1865        .map_err(|e| format!("get banlist: {e}"))?;
1866    Ok(json.and_then(|j| serde_json::from_str(&dec_txt(&j)).ok()).unwrap_or_default())
1867}
1868
1869/// Replace a Community's cached invite-link registry (active link locators, hex), folded from the
1870/// owner-signed vsk=5 edition. Empty = Private. The version floor lives in `community_edition_heads`
1871/// (the registry's own entity), so this is just the content cache (mirrors `set_community_banlist`).
1872pub fn set_community_invite_registry(community_id: &str, link_locators: &[String]) -> Result<(), String> {
1873    let json = enc_txt(&serde_json::to_string(link_locators).map_err(|e| e.to_string())?)?;
1874    let conn = super::get_write_connection_guard_static()?;
1875    conn.execute(
1876        "UPDATE communities SET invite_registry = ?1 WHERE community_id = ?2",
1877        params![json, community_id],
1878    )
1879    .map_err(|e| format!("set invite registry: {e}"))?;
1880    Ok(())
1881}
1882
1883/// A Community's current invite-link registry (active link locators, hex). Empty for an unknown
1884/// community or a Private one. `is_public` = this is non-empty (computed mode).
1885pub fn get_community_invite_registry(community_id: &str) -> Result<Vec<String>, String> {
1886    let conn = super::get_db_connection_guard_static()?;
1887    let json: Option<String> = conn
1888        .query_row(
1889            "SELECT invite_registry FROM communities WHERE community_id = ?1",
1890            params![community_id],
1891            |r| r.get(0),
1892        )
1893        .optional()
1894        .map_err(|e| format!("get invite registry: {e}"))?;
1895    Ok(json.and_then(|j| serde_json::from_str(&dec_txt(&j)).ok()).unwrap_or_default())
1896}
1897
1898/// A folded per-creator public-invite-link set: the creator's pubkey (hex) and their active link
1899/// locators. Used to surface "X has N active invite links" in the UI.
1900pub struct InviteLinkSetRow {
1901    pub creator_hex: String,
1902    pub locators: Vec<String>,
1903}
1904
1905/// Replace ALL of a Community's per-creator invite-link sets with the freshly-folded set (latest-wins).
1906/// Replacing wholesale (not upserting) drops a creator who has revoked every link, so the per-creator
1907/// view stays in lockstep with the flat registry computed in the same fold.
1908pub fn replace_invite_link_sets(community_id: &str, sets: &[InviteLinkSetRow]) -> Result<(), String> {
1909    let mut conn = super::get_write_connection_guard_static()?;
1910    let tx = conn.transaction().map_err(|e| format!("invite-link-sets tx: {e}"))?;
1911    tx.execute("DELETE FROM community_invite_link_sets WHERE community_id = ?1", params![community_id])
1912        .map_err(|e| format!("clear invite-link-sets: {e}"))?;
1913    for s in sets {
1914        if s.locators.is_empty() {
1915            continue; // a creator with no active links is just absent (count 0)
1916        }
1917        let enc_creator = enc_txt(&s.creator_hex)?;
1918        let enc_locators = enc_txt(&serde_json::to_string(&s.locators).map_err(|e| e.to_string())?)?;
1919        // Plain INSERT: the DELETE above cleared the community's rows and `sets` has distinct creators
1920        // (an encrypted creator can't act as a dedup key anyway — random nonce per write).
1921        tx.execute(
1922            "INSERT INTO community_invite_link_sets (community_id, creator, locators) VALUES (?1, ?2, ?3)",
1923            params![community_id, enc_creator, enc_locators],
1924        )
1925        .map_err(|e| format!("insert invite-link-set: {e}"))?;
1926    }
1927    tx.commit().map_err(|e| format!("commit invite-link-sets: {e}"))?;
1928    Ok(())
1929}
1930
1931/// Upsert ONE creator's invite-link set (optimistic local update after the local user mints/revokes their
1932/// own links, mirroring the flat-registry merge). An empty set removes the row.
1933pub fn upsert_invite_link_set(community_id: &str, creator_hex: &str, locators: &[String]) -> Result<(), String> {
1934    let conn = super::get_write_connection_guard_static()?;
1935    // `creator` is encrypted (random nonce), so locate any existing row by decrypting + matching.
1936    let existing_rowid: Option<i64> = {
1937        let mut stmt = conn
1938            .prepare("SELECT rowid, creator FROM community_invite_link_sets WHERE community_id = ?1")
1939            .map_err(|e| e.to_string())?;
1940        let rows = stmt
1941            .query_map(params![community_id], |r| Ok((r.get::<_, i64>(0)?, r.get::<_, String>(1)?)))
1942            .map_err(|e| e.to_string())?;
1943        let mut found = None;
1944        for row in rows {
1945            let (rowid, stored) = row.map_err(|e| e.to_string())?;
1946            if dec_txt(&stored) == creator_hex {
1947                found = Some(rowid);
1948                break;
1949            }
1950        }
1951        found
1952    };
1953    if locators.is_empty() {
1954        if let Some(rowid) = existing_rowid {
1955            conn.execute("DELETE FROM community_invite_link_sets WHERE rowid = ?1", params![rowid])
1956                .map_err(|e| format!("delete invite-link-set: {e}"))?;
1957        }
1958        return Ok(());
1959    }
1960    let enc_locators = enc_txt(&serde_json::to_string(locators).map_err(|e| e.to_string())?)?;
1961    match existing_rowid {
1962        Some(rowid) => {
1963            conn.execute(
1964                "UPDATE community_invite_link_sets SET locators = ?1 WHERE rowid = ?2",
1965                params![enc_locators, rowid],
1966            )
1967            .map_err(|e| format!("upsert invite-link-set: {e}"))?;
1968        }
1969        None => {
1970            let enc_creator = enc_txt(creator_hex)?;
1971            conn.execute(
1972                "INSERT INTO community_invite_link_sets (community_id, creator, locators) VALUES (?1, ?2, ?3)",
1973                params![community_id, enc_creator, enc_locators],
1974            )
1975            .map_err(|e| format!("upsert invite-link-set: {e}"))?;
1976        }
1977    }
1978    Ok(())
1979}
1980
1981/// Every creator's active invite-link set for a Community (creator hex + locators). Empty for a Private
1982/// community (or one not yet re-folded since this table was added).
1983pub fn get_invite_link_sets(community_id: &str) -> Result<Vec<InviteLinkSetRow>, String> {
1984    let conn = super::get_db_connection_guard_static()?;
1985    let mut stmt = conn
1986        .prepare("SELECT creator, locators FROM community_invite_link_sets WHERE community_id = ?1")
1987        .map_err(|e| format!("prepare invite-link-sets: {e}"))?;
1988    let rows = stmt
1989        .query_map(params![community_id], |r| {
1990            let creator_hex: String = r.get(0)?;
1991            let json: String = r.get(1)?;
1992            Ok((creator_hex, json))
1993        })
1994        .map_err(|e| format!("query invite-link-sets: {e}"))?;
1995    let mut out = Vec::new();
1996    for row in rows {
1997        let (creator_hex, json) = row.map_err(|e| format!("row invite-link-sets: {e}"))?;
1998        let locators: Vec<String> = serde_json::from_str(&dec_txt(&json)).unwrap_or_default();
1999        out.push(InviteLinkSetRow { creator_hex: dec_txt(&creator_hex), locators });
2000    }
2001    Ok(out)
2002}
2003
2004/// Mark (or clear) that a PRIVATE-community ban's base re-seal (read-cut) is OUTSTANDING — set when
2005/// the re-seal is attempted and cleared only when it succeeds, so a transient failure is retried later
2006/// instead of silently leaving a banned member with read access.
2007pub fn set_read_cut_pending(community_id: &str, pending: bool) -> Result<(), String> {
2008    let conn = super::get_write_connection_guard_static()?;
2009    conn.execute(
2010        "UPDATE communities SET read_cut_pending = ?1 WHERE community_id = ?2",
2011        params![pending as i64, community_id],
2012    )
2013    .map_err(|e| format!("set read_cut_pending: {e}"))?;
2014    Ok(())
2015}
2016
2017/// Set the owner-dissolution SEAL on a community — PERMANENT + irreversible (no clear path; there
2018/// is no un-dissolve). Idempotent: re-setting an already-dissolved community is a harmless no-op. Once
2019/// set, the control fold stops advancing and the inbound path drops every subsequent event.
2020/// Seal a community as dissolved. Returns whether this call TRANSITIONED it (a
2021/// live→dissolved flip) so the caller can fire the one-time death notification
2022/// exactly once — a re-wrapped tombstone (fresh outer id, same owner seal) then
2023/// can't spam the handler.
2024pub fn set_community_dissolved(community_id: &str) -> Result<bool, String> {
2025    let conn = super::get_write_connection_guard_static()?;
2026    let changed = conn
2027        .execute(
2028            "UPDATE communities SET dissolved = 1 WHERE community_id = ?1 AND dissolved = 0",
2029            params![community_id],
2030        )
2031        .map_err(|e| format!("set dissolved: {e}"))?;
2032    Ok(changed > 0)
2033}
2034
2035// ---- v1→v2 migration state (migration 77) ------------------------------------------------
2036// `migration_pointer` is the extracted dissolution payload JSON (signpost + sealed `m`) —
2037// wrapped by Local Encryption like every identifying community field. `migrated_to` is the
2038// terminal flip fence. `migration_checked` converges the boot sweep on plain dissolutions.
2039
2040/// Persist the extracted migration payload. Overwrite-idempotent (pointer selection is
2041/// total: the newest payload-carrying owner tombstone wins, so re-persisting is harmless).
2042pub fn set_migration_pointer(community_id: &str, payload_json: &str) -> Result<(), String> {
2043    let conn = super::get_write_connection_guard_static()?;
2044    let wrapped = enc_txt(payload_json)?;
2045    conn.execute(
2046        "UPDATE communities SET migration_pointer = ?2, migration_checked = 1 WHERE community_id = ?1",
2047        params![community_id, wrapped],
2048    )
2049    .map_err(|e| format!("set migration pointer: {e}"))?;
2050    Ok(())
2051}
2052
2053/// The persisted migration payload JSON, if any. `None` for unknown/pointer-less communities.
2054pub fn get_migration_pointer(community_id: &str) -> Result<Option<String>, String> {
2055    let conn = super::get_db_connection_guard_static()?;
2056    let v: Option<Option<String>> = conn
2057        .query_row(
2058            "SELECT migration_pointer FROM communities WHERE community_id = ?1",
2059            params![community_id],
2060            |r| r.get(0),
2061        )
2062        .optional()
2063        .map_err(|e| format!("get migration pointer: {e}"))?;
2064    Ok(v.flatten().map(|s| dec_txt(&s)))
2065}
2066
2067/// Terminal flip fence: the v2 community id this v1 community migrated to. Set ONLY inside
2068/// the flip transaction. One-way (no clear path) — mirrors the dissolved seal's discipline.
2069pub fn set_migrated_to(community_id: &str, v2_community_id: &str) -> Result<(), String> {
2070    let conn = super::get_write_connection_guard_static()?;
2071    conn.execute(
2072        "UPDATE communities SET migrated_to = ?2 WHERE community_id = ?1 AND migrated_to IS NULL",
2073        params![community_id, v2_community_id],
2074    )
2075    .map_err(|e| format!("set migrated_to: {e}"))?;
2076    Ok(())
2077}
2078
2079/// The flip fence readout — every v1 write path checks this first. `None` = not migrated.
2080pub fn get_migrated_to(community_id: &str) -> Result<Option<String>, String> {
2081    let conn = super::get_db_connection_guard_static()?;
2082    let v: Option<Option<String>> = conn
2083        .query_row(
2084            "SELECT migrated_to FROM communities WHERE community_id = ?1",
2085            params![community_id],
2086            |r| r.get(0),
2087        )
2088        .optional()
2089        .map_err(|e| format!("get migrated_to: {e}"))?;
2090    Ok(v.flatten())
2091}
2092
2093/// Mark a dissolved community's tombstone as migration-checked (found to be a plain `{}`
2094/// dissolution) so the boot sweep stops re-probing it. Set implicitly by
2095/// [`set_migration_pointer`] too — either outcome converges the sweep.
2096pub fn set_migration_checked(community_id: &str) -> Result<(), String> {
2097    let conn = super::get_write_connection_guard_static()?;
2098    conn.execute(
2099        "UPDATE communities SET migration_checked = 1 WHERE community_id = ?1",
2100        params![community_id],
2101    )
2102    .map_err(|e| format!("set migration checked: {e}"))?;
2103    Ok(())
2104}
2105
2106// ---- Owner migration wizard ledger (migration 77 `community_migrations`) ------------------
2107// Resumable phase tracking. `twin` carries enough to rebuild the twin on resume (the pre-flip
2108// v2 twin has ZERO channel rows locally — the hijack guard skips v1-owned rows — so a reloaded
2109// twin would be channel-less; the ledger holds the twin's v2 id + created channel set).
2110
2111/// Upsert the wizard's ledger row (phase reached + serialized twin state).
2112pub fn set_migration_ledger(v1_community_id: &str, v2_community_id: &str, phase: i64, twin_json: &str) -> Result<(), String> {
2113    let conn = super::get_write_connection_guard_static()?;
2114    let wrapped = enc_txt(twin_json)?;
2115    // Stamp every write: a row parked mid-ladder is the crash-resume signal, and
2116    // without a time it can't say whether that crash was seconds or weeks ago.
2117    let now = now_secs();
2118    conn.execute(
2119        "INSERT INTO community_migrations (community_id, v2_community_id, phase, twin, updated_at)
2120         VALUES (?1, ?2, ?3, ?4, ?5)
2121         ON CONFLICT(community_id) DO UPDATE SET v2_community_id=?2, phase=?3, twin=?4, updated_at=?5",
2122        params![v1_community_id, v2_community_id, phase, wrapped, now],
2123    )
2124    .map_err(|e| format!("set migration ledger: {e}"))?;
2125    Ok(())
2126}
2127
2128/// Read the wizard ledger row: `(v2_community_id, phase, twin_json)`.
2129pub fn get_migration_ledger(v1_community_id: &str) -> Result<Option<(String, i64, String)>, String> {
2130    let conn = super::get_db_connection_guard_static()?;
2131    let row = conn
2132        .query_row(
2133            "SELECT v2_community_id, phase, twin FROM community_migrations WHERE community_id = ?1",
2134            params![v1_community_id],
2135            |r| Ok((r.get::<_, String>(0)?, r.get::<_, i64>(1)?, r.get::<_, String>(2)?)),
2136        )
2137        .optional()
2138        .map_err(|e| format!("get migration ledger: {e}"))?;
2139    Ok(row.map(|(v2, phase, twin)| (v2, phase, dec_txt(&twin))))
2140}
2141
2142/// Re-parent every stitched channel row from the v1 community to the v2 twin, in ONE
2143/// transaction, AND stamp the terminal fence (`migrated_to` + `dissolved`) on the v1 row.
2144/// The dedicated migration transaction the v2 hijack guard (`save_community_v2`) forces: the
2145/// generic v2 save SKIPS foreign-owned channel rows, so nothing but this may adopt them.
2146/// Callers must NOT `save_community_v2` after this: every in-memory v2 view at flip time is
2147/// CHANNEL-LESS (its channel ids were v1-owned, so the pre-flip saves skipped them), and the
2148/// v2 save PRUNES channel rows absent from the passed struct — a post-flip re-save DELETES
2149/// the just-re-parented rows. Public channels fold from the control plane; nothing needs a
2150/// re-save. Idempotent: a crash re-run re-parents zero rows and the fence writes are no-ops.
2151pub fn reparent_channels_and_fence(v1_community_id: &str, v2_community_id: &str) -> Result<(), String> {
2152    let conn = super::get_write_connection_guard_static()?;
2153    let tx = conn.unchecked_transaction().map_err(|e| format!("flip txn: {e}"))?;
2154    tx.execute(
2155        "UPDATE community_channels SET community_id = ?2 WHERE community_id = ?1",
2156        params![v1_community_id, v2_community_id],
2157    )
2158    .map_err(|e| format!("reparent channels: {e}"))?;
2159    // Terminal fence: one-way, mirrors the dissolved seal's discipline. `dissolved` too,
2160    // so a fallback-door/on-ramp flip (member never folded the tombstone) still activates
2161    // fence layer 0 (the control fold short-circuit).
2162    tx.execute(
2163        "UPDATE communities SET migrated_to = ?2, dissolved = 1 WHERE community_id = ?1 AND migrated_to IS NULL",
2164        params![v1_community_id, v2_community_id],
2165    )
2166    .map_err(|e| format!("set fence: {e}"))?;
2167    tx.commit().map_err(|e| format!("flip commit: {e}"))?;
2168    // The channel→community cache assumes an IMMUTABLE mapping (positives-only); the
2169    // re-parent is the one place that mapping changes, so drop the stale v1 entries. They
2170    // refill lazily as v2 on next lookup.
2171    forget_community_channels(v1_community_id);
2172    Ok(())
2173}
2174
2175/// Communities the boot sweep must probe: sealed (`dissolved = 1`), never flipped, and not
2176/// yet migration-checked. Returns their ids.
2177pub fn migration_sweep_candidates() -> Result<Vec<String>, String> {
2178    let conn = super::get_db_connection_guard_static()?;
2179    let mut stmt = conn
2180        .prepare(
2181            "SELECT community_id FROM communities
2182             WHERE dissolved = 1 AND migrated_to IS NULL AND migration_checked = 0",
2183        )
2184        .map_err(|e| e.to_string())?;
2185    let rows = stmt
2186        .query_map([], |r| r.get::<_, String>(0))
2187        .map_err(|e| e.to_string())?;
2188    Ok(rows.flatten().collect())
2189}
2190
2191/// Communities whose flip is UNFINISHED: a pointer is held but `migrated_to` never landed
2192/// (crash between the v2 join and the flip txn, an unopenable-`m` retry, or a stale-root
2193/// walk that can now advance). The boot maintenance re-drives each.
2194pub fn migration_flip_candidates() -> Result<Vec<String>, String> {
2195    let conn = super::get_db_connection_guard_static()?;
2196    let mut stmt = conn
2197        .prepare(
2198            "SELECT community_id FROM communities
2199             WHERE migration_pointer IS NOT NULL AND migrated_to IS NULL",
2200        )
2201        .map_err(|e| e.to_string())?;
2202    let rows = stmt
2203        .query_map([], |r| r.get::<_, String>(0))
2204        .map_err(|e| e.to_string())?;
2205    Ok(rows.flatten().collect())
2206}
2207
2208/// Whether a community has been sealed by a folded + owner-verified GroupDissolved tombstone.
2209/// `false` for an unknown community.
2210pub fn get_community_dissolved(community_id: &str) -> Result<bool, String> {
2211    let conn = super::get_db_connection_guard_static()?;
2212    let v: Option<i64> = conn
2213        .query_row(
2214            "SELECT dissolved FROM communities WHERE community_id = ?1",
2215            params![community_id],
2216            |r| r.get(0),
2217        )
2218        .optional()
2219        .map_err(|e| format!("get dissolved: {e}"))?;
2220    Ok(v.unwrap_or(0) != 0)
2221}
2222
2223/// Whether a PRIVATE-community read-cut re-seal is still outstanding (a prior attempt failed). The ban
2224/// flow retries the re-seal whenever this is set. `false` for an unknown community.
2225pub fn get_read_cut_pending(community_id: &str) -> Result<bool, String> {
2226    let conn = super::get_db_connection_guard_static()?;
2227    let v: Option<i64> = conn
2228        .query_row(
2229            "SELECT read_cut_pending FROM communities WHERE community_id = ?1",
2230            params![community_id],
2231            |r| r.get(0),
2232        )
2233        .optional()
2234        .map_err(|e| format!("get read_cut_pending: {e}"))?;
2235    Ok(v.unwrap_or(0) != 0)
2236}
2237
2238/// Set the base epoch a pending read-cut (re-founding) must reach. The re-seal rotates the base only
2239/// while `server_root_epoch < target`, so a retry never double-rotates a base that already advanced. Set
2240/// to `server_root_epoch + 1` on a fresh exclusion delta (ban add / privatize); left untouched on a pure
2241/// resume so the in-flight target is preserved.
2242pub fn set_read_cut_target_epoch(community_id: &str, target: u64) -> Result<(), String> {
2243    let conn = super::get_write_connection_guard_static()?;
2244    conn.execute(
2245        "UPDATE communities SET read_cut_target_epoch = ?1 WHERE community_id = ?2",
2246        params![target as i64, community_id],
2247    )
2248    .map_err(|e| format!("set read_cut_target_epoch: {e}"))?;
2249    Ok(())
2250}
2251
2252/// The base epoch a pending read-cut must reach (see [`set_read_cut_target_epoch`]). `0` for an unknown
2253/// community. Reinterpreted i64->u64 (lossless) for epochs >= 2^63.
2254pub fn get_read_cut_target_epoch(community_id: &str) -> Result<u64, String> {
2255    let conn = super::get_db_connection_guard_static()?;
2256    let v: Option<i64> = conn
2257        .query_row(
2258            "SELECT read_cut_target_epoch FROM communities WHERE community_id = ?1",
2259            params![community_id],
2260            |r| r.get(0),
2261        )
2262        .optional()
2263        .map_err(|e| format!("get read_cut_target_epoch: {e}"))?;
2264    Ok(v.unwrap_or(0) as u64)
2265}
2266
2267/// The base (server-root) epoch a channel was last rekeyed FOR during a read-cut — the per-channel
2268/// progress marker that lets a resumed re-founding skip channels already cut. `0` if unknown.
2269pub fn channel_rekeyed_at_server_epoch(community_id: &str, channel_id: &str) -> Result<u64, String> {
2270    let conn = super::get_db_connection_guard_static()?;
2271    let v: Option<i64> = conn
2272        .query_row(
2273            "SELECT rekeyed_at_server_epoch FROM community_channels WHERE community_id = ?1 AND channel_id = ?2",
2274            params![community_id, channel_id],
2275            |r| r.get(0),
2276        )
2277        .optional()
2278        .map_err(|e| format!("get rekeyed_at_server_epoch: {e}"))?;
2279    Ok(v.unwrap_or(0) as u64)
2280}
2281
2282/// Record that a channel's key has been rotated to cover base epoch `server_epoch` (a read-cut step).
2283/// Best-effort progress marker: written after the channel rekey lands, so a crash before it just re-rotates
2284/// the channel on resume (safe, the rekey is monotonic) rather than skipping a channel that needed cutting.
2285pub fn mark_channel_rekeyed_at_server_epoch(community_id: &str, channel_id: &str, server_epoch: u64) -> Result<(), String> {
2286    let conn = super::get_write_connection_guard_static()?;
2287    conn.execute(
2288        "UPDATE community_channels SET rekeyed_at_server_epoch = ?1 WHERE community_id = ?2 AND channel_id = ?3",
2289        params![server_epoch as i64, community_id, channel_id],
2290    )
2291    .map_err(|e| format!("mark rekeyed_at_server_epoch: {e}"))?;
2292    Ok(())
2293}
2294
2295/// Ids of every locally-stored Community.
2296pub fn list_community_ids() -> Result<Vec<CommunityId>, String> {
2297    let conn = super::get_db_connection_guard_static()?;
2298    let mut stmt = conn
2299        .prepare("SELECT community_id FROM communities ORDER BY created_at")
2300        .map_err(|e| e.to_string())?;
2301    let rows = stmt
2302        .query_map([], |r| r.get::<_, String>(0))
2303        .map_err(|e| e.to_string())?;
2304    let mut ids = Vec::new();
2305    for row in rows {
2306        ids.push(CommunityId(hex_id_to_32(&row.map_err(|e| e.to_string())?)?));
2307    }
2308    Ok(ids)
2309}
2310
2311// ── Concord v2 storage (dual-stack) ──────────────────────────────────────────
2312//
2313// v2 communities reuse the shared community tables (migration 65 added the
2314// `protocol`/`owner_pubkey`/`owner_salt`/`private` columns). The base access key
2315// rides `server_root_key`/`server_root_epoch` (same role as v1's server root).
2316// A public channel stores the community_root in `channel_key` as a placeholder
2317// (its real secret is derived from the root); a private channel stores its own
2318// key. At-rest encryption reuses the same `enc_*`/`dec_*` helpers.
2319
2320/// The protocol a stored community runs, or `None` if it isn't held locally.
2321pub fn community_protocol(id: &CommunityId) -> Result<Option<crate::community::ConcordProtocol>, String> {
2322    let conn = super::get_db_connection_guard_static()?;
2323    let n: Option<i64> = conn
2324        .query_row("SELECT protocol FROM communities WHERE community_id = ?1", params![id.to_hex()], |r| r.get(0))
2325        .optional()
2326        .map_err(|e| e.to_string())?;
2327    Ok(n.map(crate::community::ConcordProtocol::from_i64))
2328}
2329
2330/// The persisted CORD-02 §6 stash riding a community row: the vsk-0 fields
2331/// Vector doesn't drive but must republish verbatim on its own edits.
2332#[derive(serde::Serialize, serde::Deserialize, Default)]
2333struct CommunityMetaStash {
2334    #[serde(default, skip_serializing_if = "Option::is_none")]
2335    custom: Option<serde_json::Map<String, serde_json::Value>>,
2336    #[serde(default, skip_serializing_if = "serde_json::Map::is_empty")]
2337    extra: serde_json::Map<String, serde_json::Value>,
2338}
2339
2340/// As [`CommunityMetaStash`], for a channel row (vsk-2: + the voice flag).
2341#[derive(serde::Serialize, serde::Deserialize, Default)]
2342struct ChannelMetaStash {
2343    #[serde(default, skip_serializing_if = "Option::is_none")]
2344    voice: Option<bool>,
2345    #[serde(default, skip_serializing_if = "Option::is_none")]
2346    custom: Option<serde_json::Map<String, serde_json::Value>>,
2347    #[serde(default, skip_serializing_if = "serde_json::Map::is_empty")]
2348    extra: serde_json::Map<String, serde_json::Value>,
2349}
2350
2351/// Persist a v2 community + its channels atomically. UPSERT so a metadata
2352/// re-save preserves banlist/roles (managed by the fold, not here).
2353pub fn save_community_v2(c: &crate::community::v2::community::CommunityV2) -> Result<(), String> {
2354    let conn = super::get_write_connection_guard_static()?;
2355    let id_hex = crate::simd::hex::bytes_to_hex_32(&c.identity.community_id.0);
2356    let relays_json = serde_json::to_string(&c.relays).map_err(|e| e.to_string())?;
2357    let created = (c.created_at_ms / 1000) as i64;
2358
2359    let enc_root = enc_key(&c.community_root)?;
2360    let enc_name = enc_txt(&c.name)?;
2361    let enc_relays = enc_txt(&relays_json)?;
2362    let enc_desc = enc_txt_opt(&c.description)?;
2363    let enc_owner_pk = enc_txt(&crate::simd::hex::bytes_to_hex_32(&c.identity.owner_xonly))?;
2364    let enc_owner_salt = enc_txt(&crate::simd::hex::bytes_to_hex_32(&c.identity.owner_salt))?;
2365    // ImageRef serializes to v1's CommunityImage JSON shape (`ext` rides the
2366    // flattened `extra`), so the shared icon/banner columns serve both protocols
2367    // — cache_community_image reads a v2 row's images unchanged.
2368    let icon_json = c.icon.as_ref().map(|i| serde_json::to_string(i).map_err(|e| e.to_string())).transpose()?;
2369    let banner_json = c.banner.as_ref().map(|b| serde_json::to_string(b).map_err(|e| e.to_string())).transpose()?;
2370    let enc_icon = enc_txt_opt(&icon_json)?;
2371    let enc_banner = enc_txt_opt(&banner_json)?;
2372    let stash_json = (c.meta_custom.is_some() || !c.meta_extra.is_empty())
2373        .then(|| serde_json::to_string(&CommunityMetaStash { custom: c.meta_custom.clone(), extra: c.meta_extra.clone() }).map_err(|e| e.to_string()))
2374        .transpose()?;
2375    let enc_stash = enc_txt_opt(&stash_json)?;
2376
2377    let tx = conn.unchecked_transaction().map_err(|e| format!("save v2 community tx: {e}"))?;
2378    tx.execute(
2379        "INSERT INTO communities
2380            (community_id, server_root_key, name, relays, created_at, description,
2381             server_root_epoch, dissolved, protocol, owner_pubkey, owner_salt, icon, banner, meta_extra)
2382         VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, 2, ?9, ?10, ?11, ?12, ?13)
2383         ON CONFLICT(community_id) DO UPDATE SET
2384            server_root_key=?2, name=?3, relays=?4, description=?6,
2385            server_root_epoch=?7, dissolved=?8, protocol=2, owner_pubkey=?9, owner_salt=?10,
2386            icon=?11, banner=?12, meta_extra=?13",
2387        params![
2388            id_hex, enc_root, enc_name, enc_relays, created, enc_desc,
2389            c.root_epoch.0 as i64, c.dissolved as i64, enc_owner_pk, enc_owner_salt,
2390            enc_icon, enc_banner, enc_stash,
2391        ],
2392    )
2393    .map_err(|e| format!("save v2 community: {e}"))?;
2394
2395    for ch in &c.channels {
2396        let ch_hex = crate::simd::hex::bytes_to_hex_32(&ch.id.0);
2397        // channel_id is the sole PRIMARY KEY, so an UPSERT keyed on it alone would
2398        // let a bundle reusing ANOTHER community's channel_id overwrite that row's
2399        // key/epoch/private in place (a chat-plane hijack). Channel ids are random-32
2400        // (a genuine cross-community collision is negligible), so refuse rather than
2401        // clobber a foreign community's row.
2402        let owner_of: Option<String> = tx
2403            .query_row("SELECT community_id FROM community_channels WHERE channel_id=?1", params![ch_hex], |r| r.get(0))
2404            .optional()
2405            .map_err(|e| format!("channel ownership check: {e}"))?;
2406        if owner_of.is_some_and(|existing| existing != id_hex) {
2407            // SKIP the foreign-owned channel rather than fail the whole save: a
2408            // single replayed phantom (a same-owner cross-community vsk-2 edition)
2409            // would otherwise wedge ALL of this community's control-plane persistence
2410            // on every fold. The foreign row stays untouched; this community just
2411            // never acquires a row for that id.
2412            continue;
2413        }
2414        // A public channel has no independent key; store the community_root as a
2415        // placeholder so the NOT NULL column is satisfied (the real secret is
2416        // derived from the root at read time via `channel_secret`).
2417        let stored_key = ch.key.unwrap_or(c.community_root);
2418        let enc_ch_key = enc_key(&stored_key)?;
2419        let enc_ch_name = enc_txt(&ch.name)?;
2420        let ch_stash_json = (ch.voice.is_some() || ch.meta_custom.is_some() || !ch.meta_extra.is_empty())
2421            .then(|| {
2422                serde_json::to_string(&ChannelMetaStash { voice: ch.voice, custom: ch.meta_custom.clone(), extra: ch.meta_extra.clone() })
2423                    .map_err(|e| e.to_string())
2424            })
2425            .transpose()?;
2426        let enc_ch_stash = enc_txt_opt(&ch_stash_json)?;
2427        tx.execute(
2428            "INSERT INTO community_channels
2429                (channel_id, community_id, channel_key, epoch, name, created_at, private, meta_extra)
2430             VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)
2431             ON CONFLICT(channel_id) DO UPDATE SET
2432                channel_key=?3, epoch=?4, name=?5, private=?7, meta_extra=?8",
2433            params![ch_hex, id_hex, enc_ch_key, ch.epoch.0 as i64, enc_ch_name, created, ch.private as i64, enc_ch_stash],
2434        )
2435        .map_err(|e| format!("save v2 channel: {e}"))?;
2436    }
2437
2438    // Prune channels no longer in the in-memory set — the persisted set is
2439    // authoritative, so a control-follow delete or a rekey removal doesn't
2440    // resurrect (with a stale key) on the next reload. No FK references
2441    // community_channels, so this cascades to nothing.
2442    let keep: Vec<String> = c.channels.iter().map(|ch| crate::simd::hex::bytes_to_hex_32(&ch.id.0)).collect();
2443    if keep.is_empty() {
2444        tx.execute("DELETE FROM community_channels WHERE community_id=?1", params![id_hex])
2445            .map_err(|e| format!("prune v2 channels: {e}"))?;
2446    } else {
2447        let placeholders = std::iter::repeat("?").take(keep.len()).collect::<Vec<_>>().join(",");
2448        let sql = format!("DELETE FROM community_channels WHERE community_id=? AND channel_id NOT IN ({placeholders})");
2449        let mut binds: Vec<String> = Vec::with_capacity(keep.len() + 1);
2450        binds.push(id_hex.clone());
2451        binds.extend(keep);
2452        tx.execute(&sql, rusqlite::params_from_iter(binds.iter()))
2453            .map_err(|e| format!("prune v2 channels: {e}"))?;
2454    }
2455
2456    tx.commit().map_err(|e| format!("commit v2 community: {e}"))?;
2457    // The save may have pruned channels (DELETE ... NOT IN the new set); evict so a
2458    // pruned channel stops resolving to this community.
2459    forget_community_channels(&id_hex);
2460    Ok(())
2461}
2462
2463/// Load a v2 community by id, or `None` if absent / not a v2 community.
2464pub fn load_community_v2(id: &CommunityId) -> Result<Option<crate::community::v2::community::CommunityV2>, String> {
2465    use crate::community::v2::community::{ChannelV2, CommunityV2};
2466    use crate::community::v2::control::CommunityIdentity;
2467    let conn = super::get_db_connection_guard_static()?;
2468    let id_hex = id.to_hex();
2469
2470    let row = conn
2471        .query_row(
2472            "SELECT server_root_key, name, relays, created_at, description,
2473                    server_root_epoch, dissolved, protocol, owner_pubkey, owner_salt,
2474                    icon, banner, meta_extra
2475             FROM communities WHERE community_id = ?1",
2476            params![id_hex],
2477            |r| {
2478                Ok((
2479                    r.get::<_, Vec<u8>>(0)?,
2480                    r.get::<_, String>(1)?,
2481                    r.get::<_, String>(2)?,
2482                    r.get::<_, i64>(3)?,
2483                    r.get::<_, Option<String>>(4)?,
2484                    r.get::<_, i64>(5)?,
2485                    r.get::<_, i64>(6)?,
2486                    r.get::<_, i64>(7)?,
2487                    r.get::<_, Option<String>>(8)?,
2488                    r.get::<_, Option<String>>(9)?,
2489                    r.get::<_, Option<String>>(10)?,
2490                    r.get::<_, Option<String>>(11)?,
2491                    r.get::<_, Option<String>>(12)?,
2492                ))
2493            },
2494        )
2495        .optional()
2496        .map_err(|e| e.to_string())?;
2497    let Some((root_blob, name_e, relays_e, created, desc_e, root_epoch, dissolved, protocol, owner_pk_e, owner_salt_e, icon_e, banner_e, stash_e)) = row
2498    else {
2499        return Ok(None);
2500    };
2501    if crate::community::ConcordProtocol::from_i64(protocol) != crate::community::ConcordProtocol::V2 {
2502        return Ok(None);
2503    }
2504    let (Some(owner_pk_e), Some(owner_salt_e)) = (owner_pk_e, owner_salt_e) else {
2505        return Err("v2 community row is missing its owner commitment".to_string());
2506    };
2507
2508    let community_root = dec_key(&root_blob)?;
2509    let owner_xonly = parse_hex32(&dec_txt(&owner_pk_e))?;
2510    let owner_salt = parse_hex32(&dec_txt(&owner_salt_e))?;
2511    let identity = CommunityIdentity { community_id: *id, owner_xonly, owner_salt };
2512    let relays: Vec<String> = serde_json::from_str(&dec_txt(&relays_e)).unwrap_or_default();
2513
2514    let mut channels = Vec::new();
2515    {
2516        let mut stmt = conn
2517            .prepare(
2518                "SELECT channel_id, channel_key, epoch, name, private, meta_extra
2519                 FROM community_channels WHERE community_id = ?1 ORDER BY created_at",
2520            )
2521            .map_err(|e| e.to_string())?;
2522        let rows = stmt
2523            .query_map(params![id_hex], |r| {
2524                Ok((
2525                    r.get::<_, String>(0)?,
2526                    r.get::<_, Vec<u8>>(1)?,
2527                    r.get::<_, i64>(2)?,
2528                    r.get::<_, String>(3)?,
2529                    r.get::<_, i64>(4)?,
2530                    r.get::<_, Option<String>>(5)?,
2531                ))
2532            })
2533            .map_err(|e| e.to_string())?;
2534        for row in rows {
2535            let (ch_hex, key_blob, epoch, name_e, private, ch_stash_e) = row.map_err(|e| e.to_string())?;
2536            let private = private != 0;
2537            let key = dec_key(&key_blob)?;
2538            // Unparseable stash degrades to empty — the fold re-persists the
2539            // authoritative value on its next pass.
2540            let ch_stash: ChannelMetaStash = ch_stash_e
2541                .map(|s| dec_txt(&s))
2542                .and_then(|j| serde_json::from_str(&j).ok())
2543                .unwrap_or_default();
2544            channels.push(ChannelV2 {
2545                id: ChannelId(hex_id_to_32(&ch_hex)?),
2546                name: dec_txt(&name_e),
2547                private,
2548                // A public channel derives from the root — drop the placeholder. A
2549                // PRIVATE channel stored with the root value is the KEYLESS placeholder
2550                // (key not yet delivered over the rekey plane): a real private key is
2551                // independently random (CORD-03 §1), never the root, so reconstruct
2552                // None and keep every read/send path behind the keyless guards instead
2553                // of silently addressing the public plane.
2554                key: (private && key != community_root).then_some(key),
2555                epoch: Epoch(epoch as u64),
2556                voice: ch_stash.voice,
2557                meta_custom: ch_stash.custom,
2558                meta_extra: ch_stash.extra,
2559            });
2560        }
2561    }
2562
2563    // Unparseable image JSON degrades to no-image rather than failing the load —
2564    // the fold re-persists the authoritative value on its next pass.
2565    let icon = icon_e
2566        .map(|s| dec_txt(&s))
2567        .and_then(|j| serde_json::from_str::<crate::community::v2::control::ImageRef>(&j).ok());
2568    let banner = banner_e
2569        .map(|s| dec_txt(&s))
2570        .and_then(|j| serde_json::from_str::<crate::community::v2::control::ImageRef>(&j).ok());
2571    let stash: CommunityMetaStash = stash_e
2572        .map(|s| dec_txt(&s))
2573        .and_then(|j| serde_json::from_str(&j).ok())
2574        .unwrap_or_default();
2575
2576    Ok(Some(CommunityV2 {
2577        identity,
2578        community_root,
2579        root_epoch: Epoch(root_epoch as u64),
2580        name: dec_txt(&name_e),
2581        description: desc_e.map(|d| dec_txt(&d)),
2582        icon,
2583        banner,
2584        meta_custom: stash.custom,
2585        meta_extra: stash.extra,
2586        relays,
2587        channels,
2588        dissolved: dissolved != 0,
2589        created_at_ms: (created as u64).saturating_mul(1000),
2590    }))
2591}
2592
2593fn parse_hex32(hex: &str) -> Result<[u8; 32], String> {
2594    if hex.len() != 64 || !hex.bytes().all(|b| b.is_ascii_hexdigit()) {
2595        return Err("stored value is not 32-byte hex".to_string());
2596    }
2597    Ok(crate::simd::hex::hex_to_bytes_32(hex))
2598}
2599
2600/// Load a community's persisted Guestbook: the raw membership events + the
2601/// newest-seen cursor (relay seconds). `([], 0)` when never synced; unparseable
2602/// stored JSON degrades the same way (the next sync re-seeds from zero).
2603pub fn get_guestbook(community_id: &str) -> Result<(Vec<crate::community::v2::guestbook::GuestbookEvent>, u64), String> {
2604    let conn = super::get_db_connection_guard_static()?;
2605    let row = conn
2606        .query_row(
2607            "SELECT events, cursor_secs FROM community_guestbook WHERE community_id = ?1",
2608            params![community_id],
2609            |r| Ok((r.get::<_, String>(0)?, r.get::<_, i64>(1)?)),
2610        )
2611        .optional()
2612        .map_err(|e| format!("load guestbook: {e}"))?;
2613    let Some((events_e, cursor)) = row else {
2614        return Ok((Vec::new(), 0));
2615    };
2616    let events = serde_json::from_str(&dec_txt(&events_e)).unwrap_or_default();
2617    Ok((events, cursor.max(0) as u64))
2618}
2619
2620/// Persist a community's Guestbook events + cursor (encrypted at rest, like the
2621/// community row itself). The caller owns dedup/merge — this is a plain replace.
2622pub fn set_guestbook(
2623    community_id: &str,
2624    events: &[crate::community::v2::guestbook::GuestbookEvent],
2625    cursor_secs: u64,
2626) -> Result<(), String> {
2627    let conn = super::get_write_connection_guard_static()?;
2628    let json = serde_json::to_string(events).map_err(|e| e.to_string())?;
2629    let enc = enc_txt(&json)?;
2630    conn.execute(
2631        "INSERT INTO community_guestbook (community_id, events, cursor_secs)
2632         VALUES (?1, ?2, ?3)
2633         ON CONFLICT(community_id) DO UPDATE SET events=?2, cursor_secs=?3",
2634        params![community_id, enc, cursor_secs as i64],
2635    )
2636    .map_err(|e| format!("save guestbook: {e}"))?;
2637    Ok(())
2638}
2639
2640#[cfg(test)]
2641mod tests {
2642    use nostr_sdk::prelude::FinalizeEvent;
2643    use super::*;
2644
2645    static TEST_COUNTER: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(0);
2646
2647    /// A unique, syntactically-valid test npub per call (bech32 charset, correct
2648    /// length). Uniqueness isolates each test's account DB so state can't bleed.
2649    fn make_test_npub(n: u32) -> String {
2650        const BECH32: &[u8] = b"qpzry9x8gf2tvdw0s3jn54khce6mua7l";
2651        let mut payload = vec![b'q'; 58];
2652        let mut x = n as u64;
2653        let mut i = 58;
2654        while x > 0 && i > 0 {
2655            i -= 1;
2656            payload[i] = BECH32[(x as usize) % 32];
2657            x /= 32;
2658        }
2659        format!("npub1{}", std::str::from_utf8(&payload).unwrap())
2660    }
2661
2662    fn init_test_db() -> (tempfile::TempDir, std::sync::MutexGuard<'static, ()>) {
2663        let guard = crate::db::DB_TEST_GUARD.lock().unwrap_or_else(|e| e.into_inner());
2664        crate::db::close_database();
2665        // Per-account row-id caches survive close_database; clear them so a stale entry from a prior
2666        // test's DB can't point into this fresh account's DB and FK-fail an insert.
2667        crate::db::clear_id_caches();
2668        let tmp = tempfile::tempdir().unwrap();
2669        let n = TEST_COUNTER.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
2670        let account = make_test_npub(n);
2671        std::fs::create_dir_all(tmp.path().join(&account)).unwrap();
2672        crate::db::set_app_data_dir(tmp.path().to_path_buf());
2673        crate::db::set_current_account(account.clone()).unwrap();
2674        crate::db::init_database(&account).unwrap();
2675        (tmp, guard)
2676    }
2677
2678    #[test]
2679    fn edition_head_round_trips_and_upserts() {
2680        let (_tmp, _guard) = init_test_db();
2681        let cid = "f".repeat(64);
2682        let entity = "a".repeat(64);
2683
2684        // No head yet → None (the next edition is genesis v1).
2685        assert_eq!(get_edition_head(&cid, &entity).unwrap(), None);
2686
2687        // Set v1, read it back exactly.
2688        let h1 = [0x11u8; 32];
2689        set_edition_head(&cid, &entity, 1, &h1).unwrap();
2690        assert_eq!(get_edition_head(&cid, &entity).unwrap(), Some((1, h1)));
2691
2692        // Upsert to v2 — the head advances in place (one row per (community, entity)).
2693        let h2 = [0x22u8; 32];
2694        set_edition_head(&cid, &entity, 2, &h2).unwrap();
2695        assert_eq!(get_edition_head(&cid, &entity).unwrap(), Some((2, h2)));
2696
2697        // MONOTONIC: a lower-or-equal version write is a no-op — the refuse-downgrade floor never
2698        // rolls back, even against a stale or hostile rollback attempt.
2699        set_edition_head(&cid, &entity, 1, &[0xEEu8; 32]).unwrap();
2700        assert_eq!(get_edition_head(&cid, &entity).unwrap(), Some((2, h2)), "rollback to v1 ignored");
2701        set_edition_head(&cid, &entity, 2, &[0xEEu8; 32]).unwrap();
2702        assert_eq!(get_edition_head(&cid, &entity).unwrap(), Some((2, h2)), "equal version is a no-op too");
2703
2704        // A different entity is tracked independently.
2705        let other = "b".repeat(64);
2706        assert_eq!(get_edition_head(&cid, &other).unwrap(), None);
2707    }
2708
2709    #[test]
2710    fn guestbook_round_trips_events_and_cursor() {
2711        let (_tmp, _guard) = init_test_db();
2712        let member = nostr_sdk::prelude::Keys::generate();
2713        let ev = crate::community::v2::guestbook::GuestbookEvent {
2714            rumor_id: [7u8; 32],
2715            entry: crate::community::v2::guestbook::GuestbookEntry::Join {
2716                member: member.public_key(),
2717                invited_by: Some(("creator".into(), "label".into())),
2718                at_ms: 1_000,
2719            },
2720        };
2721        let cid = "d".repeat(64);
2722        assert_eq!(get_guestbook(&cid).unwrap(), (Vec::new(), 0), "absent reads as empty at cursor 0");
2723        set_guestbook(&cid, std::slice::from_ref(&ev), 42).unwrap();
2724        let (events, cursor) = get_guestbook(&cid).unwrap();
2725        assert_eq!(events, vec![ev], "events round-trip through the encrypted blob");
2726        assert_eq!(cursor, 42);
2727    }
2728
2729    #[test]
2730    fn v2_images_round_trip_and_read_as_v1_community_images() {
2731        let (_tmp, _guard) = init_test_db();
2732        let owner = nostr_sdk::prelude::Keys::generate();
2733        let g = crate::community::v2::control::genesis(
2734            &owner,
2735            crate::community::v2::control::CommunityMetadata { name: "Icons".into(), ..Default::default() },
2736            1_000,
2737        )
2738        .unwrap();
2739        let mut c = crate::community::v2::community::CommunityV2::from_genesis(&g, "Icons", None, vec!["wss://r".into()], 1_000);
2740        let mut extra = serde_json::Map::new();
2741        extra.insert("ext".into(), serde_json::Value::String("webp".into()));
2742        c.icon = Some(crate::community::v2::control::ImageRef {
2743            url: "https://blossom.example/abc".into(),
2744            key: "0".repeat(64),
2745            nonce: "1".repeat(32),
2746            hash: "a".repeat(64),
2747            extra,
2748        });
2749        c.meta_custom = Some({
2750            let mut m = serde_json::Map::new();
2751            m.insert("k".into(), serde_json::Value::from("v"));
2752            m
2753        });
2754        c.channels[0].voice = Some(true);
2755        c.channels[0].meta_extra.insert("vnd".into(), serde_json::Value::from(7));
2756        save_community_v2(&c).unwrap();
2757
2758        // The v2 loader round-trips the ImageRef exactly (extra included).
2759        let re = load_community_v2(c.id()).unwrap().unwrap();
2760        assert_eq!(re.icon, c.icon);
2761        assert_eq!(re.banner, None);
2762        // The CORD-02 §6 stash survives the encrypted envelope columns.
2763        assert_eq!(re.meta_custom, c.meta_custom);
2764        assert_eq!(re.channels[0].voice, Some(true));
2765        assert_eq!(re.channels[0].meta_extra.get("vnd"), Some(&serde_json::Value::from(7)));
2766
2767        // Dual-reader guarantee: the SAME stored JSON parses as a v1 CommunityImage
2768        // (`ext` from the flattened extra), so cache_community_image serves a v2
2769        // icon with no v2-awareness.
2770        let v1 = load_community(c.id()).unwrap().unwrap();
2771        let img = v1.icon.expect("v1 reader sees the v2 icon");
2772        assert_eq!(img.url, "https://blossom.example/abc");
2773        assert_eq!(img.ext, "webp");
2774        assert_eq!(img.hash, "a".repeat(64));
2775    }
2776
2777    #[test]
2778    fn server_root_epoch_round_trips() {
2779        // The base read clock survives save/load (default 0; a rotated value preserved exactly).
2780        let (_tmp, _guard) = init_test_db();
2781        let mut c = Community::create("HQ", "general", vec![]);
2782        save_community(&c).unwrap();
2783        assert_eq!(load_community(&c.id).unwrap().unwrap().server_root_epoch, Epoch(0));
2784
2785        c.server_root_epoch = Epoch(5);
2786        c.server_root_key = ServerRootKey([0x42u8; 32]);
2787        save_community(&c).unwrap();
2788        let loaded = load_community(&c.id).unwrap().unwrap();
2789        assert_eq!(loaded.server_root_epoch, Epoch(5));
2790        assert_eq!(loaded.server_root_key.as_bytes(), &[0x42u8; 32]);
2791    }
2792
2793    #[test]
2794    fn epoch_key_archive_retains_every_epoch() {
2795        // a member who lived through a rotation must keep OLD epoch keys. Storing a new
2796        // epoch's key must NOT clobber a prior one (the data-loss bug the archive fixes).
2797        let (_tmp, _guard) = init_test_db();
2798        let cid = "f".repeat(64);
2799        let scope = "a".repeat(64);
2800
2801        store_epoch_key(&cid, &scope, 0, &[0xA0u8; 32]).unwrap();
2802        store_epoch_key(&cid, &scope, 1, &[0xA1u8; 32]).unwrap();
2803        store_epoch_key(&cid, &scope, 2, &[0xA2u8; 32]).unwrap();
2804
2805        let held = held_epoch_keys(&cid, &scope).unwrap();
2806        assert_eq!(held.len(), 3, "all three epoch keys retained");
2807        assert_eq!(held[0], (Epoch(0), [0xA0u8; 32]));
2808        assert_eq!(held[1], (Epoch(1), [0xA1u8; 32]));
2809        assert_eq!(held[2], (Epoch(2), [0xA2u8; 32]));
2810
2811        // Point lookup by epoch (what the open path uses to select a decryption key).
2812        assert_eq!(held_epoch_key(&cid, &scope, 1).unwrap(), Some([0xA1u8; 32]));
2813        assert_eq!(held_epoch_key(&cid, &scope, 9).unwrap(), None, "unheld epoch is None");
2814
2815        // Same coordinate REPLACE = fork-resolution committing a winning key (only legit overwrite).
2816        store_epoch_key(&cid, &scope, 1, &[0xBBu8; 32]).unwrap();
2817        assert_eq!(held_epoch_key(&cid, &scope, 1).unwrap(), Some([0xBBu8; 32]));
2818        assert_eq!(held_epoch_keys(&cid, &scope).unwrap().len(), 3, "replace didn't add a row");
2819
2820        // A different scope is isolated (server-root vs a channel share the table, never collide) —
2821        // at both the list AND the point-lookup level.
2822        assert!(held_epoch_keys(&cid, crate::community::SERVER_ROOT_SCOPE_HEX).unwrap().is_empty());
2823        assert_eq!(
2824            held_epoch_key(&cid, crate::community::SERVER_ROOT_SCOPE_HEX, 1).unwrap(),
2825            None,
2826            "epoch 1 under a different scope is not the channel's key"
2827        );
2828    }
2829
2830    #[test]
2831    fn save_community_populates_the_epoch_archive() {
2832        // save_community mirrors the current base + channel keys into the multi-held archive, so the
2833        // foundation is live without any explicit store_epoch_key call by the caller.
2834        let (_tmp, _guard) = init_test_db();
2835        let c = Community::create("HQ", "general", vec![]);
2836        save_community(&c).unwrap();
2837        let cid = c.id.to_hex();
2838
2839        // Base key archived under the server-root sentinel at epoch 0.
2840        assert_eq!(
2841            held_epoch_key(&cid, crate::community::SERVER_ROOT_SCOPE_HEX, 0).unwrap().as_ref(),
2842            Some(c.server_root_key.as_bytes())
2843        );
2844        // The default channel's key archived under its channel id at epoch 0.
2845        let chan = &c.channels[0];
2846        assert_eq!(
2847            held_epoch_key(&cid, &chan.id.to_hex(), 0).unwrap().as_ref(),
2848            Some(chan.key.as_bytes())
2849        );
2850    }
2851
2852    #[test]
2853    fn at_rest_encryption_wraps_keys_and_metadata_on_disk() {
2854        let (_tmp, _guard) = init_test_db();
2855        // Local Encryption ON with a known vault key (the db-test guard serializes, so toggling these
2856        // globals is safe; reset at the end). `others: &[]` — the slice only allocates a vault lane.
2857        crate::state::ENCRYPTION_KEY.set([0x55u8; 32], &[]);
2858        crate::state::set_encryption_enabled(true);
2859
2860        let mut c = Community::create("Secret HQ", "general", vec!["wss://relay.example".into()]);
2861        c.server_root_key = ServerRootKey([0x42u8; 32]);
2862        c.description = Some("top secret".into());
2863        save_community(&c).unwrap();
2864        let cid = c.id.to_hex();
2865        set_community_banlist(&cid, &["deadbeef".repeat(8)], 1).unwrap();
2866
2867        // On disk: secrets are 60-byte ciphertext (12 nonce + 32 + 16 tag), NOT raw 32-byte keys;
2868        // identifying text is hex ciphertext, never the plaintext.
2869        {
2870            let conn = crate::db::get_db_connection_guard_static().unwrap();
2871            let (root_len, name, banlist): (i64, String, String) = conn
2872                .query_row(
2873                    "SELECT length(server_root_key), name, banlist FROM communities WHERE community_id = ?1",
2874                    params![cid],
2875                    |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)),
2876                )
2877                .unwrap();
2878            assert_eq!(root_len, 60, "server_root_key must be ciphertext, not a raw 32-byte key");
2879            assert_ne!(name, "Secret HQ", "name must not be plaintext on disk");
2880            assert!(crate::crypto::looks_encrypted(&name), "name column is ciphertext");
2881            assert!(crate::crypto::looks_encrypted(&banlist), "banlist column is ciphertext");
2882            let key_len: i64 = conn
2883                .query_row(
2884                    "SELECT length(key) FROM community_epoch_keys WHERE community_id = ?1 LIMIT 1",
2885                    params![cid],
2886                    |r| r.get(0),
2887                )
2888                .unwrap();
2889            assert_eq!(key_len, 60, "epoch-archive key must be ciphertext");
2890        }
2891
2892        // In memory: load decrypts everything back to the originals.
2893        let loaded = load_community(&c.id).unwrap().unwrap();
2894        assert_eq!(loaded.name, "Secret HQ");
2895        assert_eq!(loaded.description.as_deref(), Some("top secret"));
2896        assert_eq!(loaded.server_root_key.as_bytes(), &[0x42u8; 32]);
2897        assert_eq!(loaded.relays, vec!["wss://relay.example".to_string()]);
2898        assert_eq!(get_community_banlist(&cid).unwrap(), vec!["deadbeef".repeat(8)]);
2899
2900        crate::state::set_encryption_enabled(false);
2901        crate::state::ENCRYPTION_KEY.clear(&[]);
2902    }
2903
2904    #[test]
2905    fn at_rest_decrypt_tolerates_a_pre_migration_plaintext_row() {
2906        // A row written BEFORE the at-rest pass (raw key + plaintext text) must still read back once
2907        // encryption is on — the 32-vs-60 byte + `looks_encrypted` discriminators handle the mixed DB.
2908        let (_tmp, _guard) = init_test_db();
2909        crate::state::set_encryption_enabled(false);
2910        let mut c = Community::create("Legacy HQ", "general", vec![]);
2911        c.server_root_key = ServerRootKey([0x42u8; 32]);
2912        save_community(&c).unwrap();
2913
2914        crate::state::ENCRYPTION_KEY.set([0x55u8; 32], &[]);
2915        crate::state::set_encryption_enabled(true);
2916        let loaded = load_community(&c.id).unwrap().unwrap();
2917        assert_eq!(loaded.name, "Legacy HQ", "plaintext name reads through");
2918        assert_eq!(loaded.server_root_key.as_bytes(), &[0x42u8; 32], "raw 32-byte key reads through");
2919
2920        crate::state::set_encryption_enabled(false);
2921        crate::state::ENCRYPTION_KEY.clear(&[]);
2922    }
2923
2924    #[test]
2925    fn save_and_load_round_trip() {
2926        let (_tmp, _guard) = init_test_db();
2927        let original = Community::create("Vector HQ", "general", vec!["wss://r.one".into()]);
2928        save_community(&original).unwrap();
2929
2930        let loaded = load_community(&original.id).unwrap().expect("present");
2931        assert_eq!(loaded.id, original.id);
2932        assert_eq!(loaded.name, "Vector HQ");
2933        assert_eq!(loaded.relays, original.relays);
2934        // Secrets survive the round trip byte-for-byte.
2935        assert_eq!(loaded.server_root_key.as_bytes(), original.server_root_key.as_bytes());
2936        // Channel survives with its key, epoch, and name.
2937        assert_eq!(loaded.channels.len(), 1);
2938        assert_eq!(loaded.channels[0].id, original.channels[0].id);
2939        assert_eq!(loaded.channels[0].key.as_bytes(), original.channels[0].key.as_bytes());
2940        assert_eq!(loaded.channels[0].epoch, Epoch(0));
2941        assert_eq!(loaded.channels[0].name, "general");
2942    }
2943
2944    #[test]
2945    fn owner_is_protected_from_the_banlist_a_member_is_not() {
2946        let (_tmp, _guard) = init_test_db();
2947        let mut community = Community::create("HQ", "general", vec!["wss://r".into()]);
2948        // Give it a proven owner (index 0).
2949        let owner_id = Keys::new(SecretKey::from_slice(&[7u8; 32]).unwrap());
2950        community.owner_attestation = Some(
2951            crate::community::owner::build_owner_attestation_unsigned(
2952                owner_id.public_key(),
2953                &community.id.to_hex(),
2954            )
2955            .finalize(&owner_id)
2956            .unwrap()
2957            .as_json(),
2958        );
2959        save_community(&community).unwrap();
2960
2961        // A banlist naming BOTH the owner and a regular member.
2962        let member = Keys::generate();
2963        set_community_banlist(
2964            &community.id.to_hex(),
2965            &[owner_id.public_key().to_hex(), member.public_key().to_hex()],
2966            1,
2967        )
2968        .unwrap();
2969
2970        let loaded = load_community(&community.id).unwrap().unwrap();
2971        let ch = &loaded.channels[0];
2972        // The owner is filtered OUT of the effective banlist (index 0 can't be banned)...
2973        assert!(!ch.banned.contains(&owner_id.public_key()), "owner is never effectively banned");
2974        assert!(ch.protected.contains(&owner_id.public_key()), "owner is in the protected set");
2975        // ...but a regular member's ban stands.
2976        assert!(ch.banned.contains(&member.public_key()), "a member's ban is honored");
2977    }
2978
2979    #[test]
2980    fn loaded_keys_actually_decrypt() {
2981        // The reconstructed keys must be usable: seal with the original channel key,
2982        // open with the loaded one (proves the blob round-trip preserved key bytes).
2983        let (_tmp, _guard) = init_test_db();
2984        let original = Community::create("HQ", "general", vec![]);
2985        save_community(&original).unwrap();
2986        let loaded = load_community(&original.id).unwrap().unwrap();
2987
2988        let author = nostr_sdk::prelude::Keys::generate();
2989        let chan = &original.channels[0];
2990        let sealed = crate::community::envelope::seal_message(
2991            &author, &chan.key, &chan.id, chan.epoch, "persisted!", 1,
2992        )
2993        .unwrap();
2994        let opened = crate::community::envelope::open_message(
2995            &sealed,
2996            &loaded.channels[0].key,
2997            &loaded.channels[0].id,
2998            loaded.channels[0].epoch,
2999        )
3000        .unwrap();
3001        assert_eq!(opened.content, "persisted!");
3002    }
3003
3004    #[test]
3005    fn member_view_round_trips() {
3006        // A joined member-view Community (keyless) persists + reloads with its
3007        // server-root + channel keys intact.
3008        let (_tmp, _guard) = init_test_db();
3009        let member = Community {
3010            id: CommunityId([7u8; 32]),
3011            server_root_key: ServerRootKey([8u8; 32]),
3012            server_root_epoch: Epoch(0),
3013            name: "Joined".into(),
3014            description: None,
3015            icon: None,
3016            banner: None,
3017            relays: vec!["wss://r".into()],
3018            channels: vec![Channel {
3019                id: ChannelId([9u8; 32]),
3020                key: ChannelKey([10u8; 32]),
3021                epoch: Epoch(0),
3022                name: "general".into(),
3023                banned: Vec::new(),
3024                protected: Vec::new(), roster: Default::default(),
3025                epoch_keys: Vec::new(),
3026                dissolved: false,
3027            }],
3028            owner_attestation: None,
3029            dissolved: false,
3030        };
3031        save_community(&member).unwrap();
3032        let loaded = load_community(&member.id).unwrap().expect("present");
3033        assert_eq!(loaded.server_root_key.as_bytes(), &[8u8; 32]);
3034        assert_eq!(loaded.channels[0].key.as_bytes(), &[10u8; 32]);
3035    }
3036
3037    #[test]
3038    fn large_epoch_round_trips_losslessly() {
3039        // Epoch >= 2^63 stored as i64 then reinterpreted as u64 must be exact.
3040        let (_tmp, _guard) = init_test_db();
3041        let mut c = Community::create("HQ", "g", vec![]);
3042        c.channels[0].epoch = Epoch(u64::MAX - 7);
3043        save_community(&c).unwrap();
3044        let loaded = load_community(&c.id).unwrap().unwrap();
3045        assert_eq!(loaded.channels[0].epoch, Epoch(u64::MAX - 7));
3046    }
3047
3048    #[test]
3049    fn malformed_channel_id_row_errors_not_corrupts() {
3050        // A corrupted (short/non-hex) channel_id must error on load, not silently
3051        // reconstruct a wrong-but-self-consistent id.
3052        let (_tmp, _guard) = init_test_db();
3053        let c = Community::create("HQ", "g", vec![]);
3054        save_community(&c).unwrap();
3055        {
3056            let conn = crate::db::get_write_connection_guard_static().unwrap();
3057            conn.execute(
3058                "INSERT OR REPLACE INTO community_channels
3059                    (channel_id, community_id, channel_key, epoch, name, created_at)
3060                 VALUES (?1, ?2, ?3, 0, 'bad', 0)",
3061                rusqlite::params!["zz_not_hex", c.id.to_hex(), &[0u8; 32][..]],
3062            )
3063            .unwrap();
3064        }
3065        assert!(load_community(&c.id).is_err(), "malformed id must error, not corrupt");
3066    }
3067
3068    #[test]
3069    fn message_key_store_take_round_trip() {
3070        let (_tmp, _guard) = init_test_db();
3071        let eph = Keys::generate();
3072        let relays = vec!["wss://r.one".to_string()];
3073        // Keyed by INNER message id; resolves to the OUTER event id + key + relays.
3074        store_message_key("inner_msg_id", "outer_evid", &eph, &relays).unwrap();
3075
3076        let (loaded, outer, r) = take_message_key("inner_msg_id").unwrap().expect("present");
3077        assert_eq!(
3078            loaded.secret_key().as_secret_bytes(),
3079            eph.secret_key().as_secret_bytes()
3080        );
3081        assert_eq!(outer, "outer_evid");
3082        assert_eq!(r, relays);
3083        // `take` is single-use: the row is removed.
3084        assert!(take_message_key("inner_msg_id").unwrap().is_none());
3085    }
3086
3087    #[test]
3088    fn missing_community_is_none() {
3089        let (_tmp, _guard) = init_test_db();
3090        let absent = CommunityId([0x33u8; 32]);
3091        assert!(load_community(&absent).unwrap().is_none());
3092    }
3093
3094    #[test]
3095    fn list_ids_reflects_saved() {
3096        let (_tmp, _guard) = init_test_db();
3097        let a = Community::create("A", "g", vec![]);
3098        let b = Community::create("B", "g", vec![]);
3099        save_community(&a).unwrap();
3100        save_community(&b).unwrap();
3101        let ids = list_community_ids().unwrap();
3102        assert_eq!(ids.len(), 2);
3103        assert!(ids.contains(&a.id) && ids.contains(&b.id));
3104    }
3105
3106    #[test]
3107    fn delete_community_clears_all_local_state() {
3108        let (_tmp, _guard) = init_test_db();
3109        let c = Community::create("HQ", "general", vec!["r1".into()]);
3110        save_community(&c).unwrap();
3111        let cid = c.id.to_hex();
3112        save_public_invite(&"ab".repeat(32), &cid, "url", None, None).unwrap();
3113        save_pending_invite(&"cd".repeat(32), "{}", "npub1x", 0).unwrap();
3114        set_edition_head(&cid, &"a".repeat(64), 3, &[0x11u8; 32]).unwrap();
3115
3116        // The save above archived the base + channel keys; this proves delete clears them too.
3117        assert!(!held_epoch_keys(&cid, crate::community::SERVER_ROOT_SCOPE_HEX).unwrap().is_empty());
3118
3119        delete_community(&cid).unwrap();
3120        assert!(!community_exists(&c.id).unwrap());
3121        assert!(community_id_for_channel(&c.channels[0].id.to_hex()).unwrap().is_none());
3122        assert!(list_public_invites(&cid).unwrap().is_empty());
3123        assert_eq!(get_edition_head(&cid, &"a".repeat(64)).unwrap(), None, "edition heads cleared on delete");
3124        assert!(held_epoch_keys(&cid, crate::community::SERVER_ROOT_SCOPE_HEX).unwrap().is_empty(), "epoch keys cleared on delete");
3125    }
3126
3127    #[test]
3128    fn delete_community_retain_keys_drops_state_but_keeps_epoch_keys() {
3129        // self-removal teardown: drop chat/membership/control state but KEEP the held epoch keys so a
3130        // later self-scrub of own past messages stays possible.
3131        let (_tmp, _guard) = init_test_db();
3132        let c = Community::create("HQ", "general", vec!["r1".into()]);
3133        save_community(&c).unwrap();
3134        let cid = c.id.to_hex();
3135        save_public_invite(&"ab".repeat(32), &cid, "url", None, None).unwrap();
3136        set_edition_head(&cid, &"a".repeat(64), 3, &[0x11u8; 32]).unwrap();
3137
3138        let base_before = held_epoch_keys(&cid, crate::community::SERVER_ROOT_SCOPE_HEX).unwrap();
3139        let chan_before = held_epoch_keys(&cid, &c.channels[0].id.to_hex()).unwrap();
3140        assert!(!base_before.is_empty() && !chan_before.is_empty(), "save archived base + channel keys");
3141
3142        delete_community_retain_keys(&cid).unwrap();
3143
3144        // State is gone.
3145        assert!(!community_exists(&c.id).unwrap());
3146        assert!(community_id_for_channel(&c.channels[0].id.to_hex()).unwrap().is_none());
3147        assert!(list_public_invites(&cid).unwrap().is_empty());
3148        assert_eq!(get_edition_head(&cid, &"a".repeat(64)).unwrap(), None);
3149        // Epoch keys (base + channel, every epoch) survive intact.
3150        assert_eq!(held_epoch_keys(&cid, crate::community::SERVER_ROOT_SCOPE_HEX).unwrap(), base_before,
3151            "base epoch keys retained for self-scrub");
3152        assert_eq!(held_epoch_keys(&cid, &c.channels[0].id.to_hex()).unwrap(), chan_before,
3153            "channel epoch keys retained for self-scrub");
3154    }
3155
3156    #[test]
3157    fn channel_resolves_to_owning_community() {
3158        let (_tmp, _guard) = init_test_db();
3159        let c = Community::create("HQ", "general", vec![]);
3160        save_community(&c).unwrap();
3161        let chan = c.channels[0].id.to_hex();
3162        assert_eq!(community_id_for_channel(&chan).unwrap().as_deref(), Some(c.id.to_hex().as_str()));
3163        assert!(community_id_for_channel(&"ff".repeat(32)).unwrap().is_none());
3164    }
3165
3166    #[test]
3167    fn community_exists_reflects_saved() {
3168        let (_tmp, _guard) = init_test_db();
3169        let c = Community::create("A", "g", vec![]);
3170        assert!(!community_exists(&c.id).unwrap());
3171        save_community(&c).unwrap();
3172        assert!(community_exists(&c.id).unwrap());
3173    }
3174
3175    #[test]
3176    fn reparent_moves_channels_stamps_fence_and_invalidates_cache() {
3177        let (_tmp, _guard) = init_test_db();
3178        let v1 = Community::create("HQ", "general", vec![]);
3179        save_community(&v1).unwrap();
3180        let v1_cid = v1.id.to_hex();
3181        let v2_cid = "ab".repeat(32);
3182        let chan = v1.channels[0].id.to_hex();
3183
3184        // Warm the positives-only cache with the v1 mapping, then flip.
3185        assert_eq!(community_id_for_channel(&chan).unwrap().as_deref(), Some(v1_cid.as_str()));
3186        reparent_channels_and_fence(&v1_cid, &v2_cid).unwrap();
3187
3188        // Channel re-parented (cache invalidated → refills as v2), fence stamped both ways.
3189        assert_eq!(community_id_for_channel(&chan).unwrap().as_deref(), Some(v2_cid.as_str()),
3190            "stale v1 cache entry must not survive the re-parent");
3191        assert_eq!(get_migrated_to(&v1_cid).unwrap().as_deref(), Some(v2_cid.as_str()));
3192        assert!(get_community_dissolved(&v1_cid).unwrap(), "flip seals v1 (fence layer 0)");
3193
3194        // Idempotent: a second flip re-parents zero rows and the one-way fence holds.
3195        reparent_channels_and_fence(&v1_cid, &"cd".repeat(32)).unwrap();
3196        assert_eq!(get_migrated_to(&v1_cid).unwrap().as_deref(), Some(v2_cid.as_str()),
3197            "migrated_to is one-way — a second flip cannot repoint it");
3198    }
3199
3200    #[test]
3201    fn migration_sweep_candidates_are_sealed_unflipped_unchecked() {
3202        let (_tmp, _guard) = init_test_db();
3203        let a = Community::create("A", "g", vec![]);
3204        let b = Community::create("B", "g", vec![]);
3205        let c = Community::create("C", "g", vec![]);
3206        for x in [&a, &b, &c] { save_community(x).unwrap(); }
3207        // a: sealed, unchecked → candidate. b: sealed but flipped → not. c: live → not.
3208        set_community_dissolved(&a.id.to_hex()).unwrap();
3209        set_community_dissolved(&b.id.to_hex()).unwrap();
3210        set_migrated_to(&b.id.to_hex(), &"ab".repeat(32)).unwrap();
3211        let cands = migration_sweep_candidates().unwrap();
3212        assert!(cands.contains(&a.id.to_hex()));
3213        assert!(!cands.contains(&b.id.to_hex()), "flipped is not a candidate");
3214        assert!(!cands.contains(&c.id.to_hex()), "live is not a candidate");
3215        // Marking checked converges the sweep.
3216        set_migration_checked(&a.id.to_hex()).unwrap();
3217        assert!(!migration_sweep_candidates().unwrap().contains(&a.id.to_hex()));
3218    }
3219
3220    #[test]
3221    fn pending_invite_first_wins_and_round_trips() {
3222        let (_tmp, _guard) = init_test_db();
3223        let cid = "ab".repeat(32);
3224        // First park inserts; a re-invite for the same id is IGNORED (first-wins, so a
3225        // hostile re-send can't rewrite a parked bundle or re-notify).
3226        assert!(save_pending_invite(&cid, "{\"bundle\":1}", "npub1inviter", 0).unwrap());
3227        assert!(!save_pending_invite(&cid, "{\"bundle\":2}", "npub1other", 0).unwrap());
3228        assert!(pending_invite_exists(&cid).unwrap());
3229
3230        let listed = list_pending_invites().unwrap();
3231        assert_eq!(listed.len(), 1);
3232        assert_eq!(listed[0].community_id, cid);
3233        assert_eq!(listed[0].bundle_json, "{\"bundle\":1}", "original bundle preserved");
3234        assert_eq!(listed[0].inviter_npub, "npub1inviter");
3235
3236        // get is non-destructive; delete then removes it.
3237        assert_eq!(get_pending_invite(&cid).unwrap().as_deref(), Some("{\"bundle\":1}"));
3238        assert!(pending_invite_exists(&cid).unwrap(), "get must not delete");
3239        delete_pending_invite(&cid).unwrap();
3240        assert!(!pending_invite_exists(&cid).unwrap());
3241        assert!(get_pending_invite(&cid).unwrap().is_none());
3242    }
3243
3244    #[test]
3245    fn purge_drops_invites_for_held_communities_only() {
3246        let (_tmp, _guard) = init_test_db();
3247        // A community we hold + a parked invite for it (the cross-device race: invite landed
3248        // before the membership list rehydrated the community).
3249        let held = Community::create("Held", "general", vec![]);
3250        save_community(&held).unwrap();
3251        let held_hex = held.id.to_hex();
3252        save_pending_invite(&held_hex, "{\"bundle\":1}", "npub1inviter", 0).unwrap();
3253        // An invite for a community we do NOT hold must survive the purge.
3254        let stranger = "ab".repeat(32);
3255        save_pending_invite(&stranger, "{\"bundle\":2}", "npub1inviter", 0).unwrap();
3256
3257        let n = purge_pending_invites_for_held_communities().unwrap();
3258        assert_eq!(n, 1, "only the held community's invite is purged");
3259        assert!(!pending_invite_exists(&held_hex).unwrap(), "held → invite gone");
3260        assert!(pending_invite_exists(&stranger).unwrap(), "unknown community → invite kept");
3261    }
3262
3263    #[test]
3264    fn decline_drops_pending_invite() {
3265        let (_tmp, _guard) = init_test_db();
3266        let cid = "cd".repeat(32);
3267        save_pending_invite(&cid, "{}", "npub1x", 0).unwrap();
3268        delete_pending_invite(&cid).unwrap();
3269        assert!(!pending_invite_exists(&cid).unwrap());
3270    }
3271
3272    #[test]
3273    fn pending_invites_are_capped_keeping_the_newest() {
3274        let (_tmp, _guard) = init_test_db();
3275        // 150 distinct invites with strictly increasing received_at (the helper stamps now_secs(),
3276        // so vary the id and rely on insertion order; to make ordering deterministic we bump the
3277        // stored time directly after each insert isn't needed — received_at ties break on id DESC).
3278        // Insert 150; the table must cap at 100.
3279        for i in 0..150u32 {
3280            let cid = format!("{:064x}", i);
3281            save_pending_invite(&cid, "{}", "npub1x", 0).unwrap();
3282        }
3283        let all = list_pending_invites().unwrap();
3284        assert_eq!(all.len(), 100, "table capped at MAX_PENDING_INVITES");
3285        // A spam flood can't grow it past the cap regardless of how many arrive.
3286        for i in 150..400u32 {
3287            let cid = format!("{:064x}", i);
3288            save_pending_invite(&cid, "{}", "npub1x", 0).unwrap();
3289        }
3290        assert_eq!(list_pending_invites().unwrap().len(), 100, "cap holds under flood");
3291    }
3292
3293    /// A parked invite past its sender-declared NIP-40 deadline is invisible to BOTH reads:
3294    /// the list (so it stops cluttering) and the peek the accept path uses (so it can't be
3295    /// redeemed). Relays only stop delivering an expired invite; the already-parked row is
3296    /// ours to enforce.
3297    #[test]
3298    fn expired_parked_invites_are_hidden_from_list_and_accept() {
3299        let (_tmp, _guard) = init_test_db();
3300        let now = now_secs();
3301        let live = "aa".repeat(32);
3302        let expired = "bb".repeat(32);
3303        let permanent = "cc".repeat(32);
3304
3305        save_pending_invite(&live, "{\"live\":1}", "npub1x", now + 3600).unwrap();
3306        save_pending_invite(&expired, "{\"dead\":1}", "npub1x", now - 1).unwrap();
3307        // 0 = the sender declared no deadline (a pre-expiry client): stays permanent, because
3308        // an invite whose sender never promised a deadline isn't ours to revoke.
3309        save_pending_invite(&permanent, "{\"forever\":1}", "npub1x", 0).unwrap();
3310
3311        let listed: Vec<String> = list_pending_invites().unwrap().into_iter().map(|i| i.community_id).collect();
3312        assert!(listed.contains(&live), "an unexpired invite still lists");
3313        assert!(listed.contains(&permanent), "a no-deadline invite still lists");
3314        assert!(!listed.contains(&expired), "an expired invite is hidden from the list");
3315
3316        assert!(get_pending_invite(&live).unwrap().is_some());
3317        assert!(get_pending_invite(&permanent).unwrap().is_some());
3318        assert!(
3319            get_pending_invite(&expired).unwrap().is_none(),
3320            "an expired invite must not be redeemable"
3321        );
3322
3323        // The row still exists until swept, and the sweep reclaims exactly the expired one.
3324        assert!(pending_invite_exists(&expired).unwrap(), "hidden, not yet deleted");
3325        assert_eq!(purge_expired_pending_invites().unwrap(), 1);
3326        assert!(!pending_invite_exists(&expired).unwrap());
3327        assert!(pending_invite_exists(&live).unwrap(), "the sweep spares live invites");
3328        assert!(pending_invite_exists(&permanent).unwrap(), "and no-deadline ones");
3329    }
3330
3331    /// The expiry is per-invite from the SENDER's tag, not derived from local receive time,
3332    /// so a short-fuse invite expires on the sender's schedule even if it was just received.
3333    #[test]
3334    fn expiry_follows_the_senders_deadline_not_receipt_time() {
3335        let (_tmp, _guard) = init_test_db();
3336        let cid = "de".repeat(32);
3337        // Received right now, but the sender's deadline already passed (a stale wrap that a
3338        // NIP-40-ignoring relay still served).
3339        save_pending_invite(&cid, "{}", "npub1x", now_secs() - 10).unwrap();
3340        assert!(list_pending_invites().unwrap().is_empty(), "receipt time does not extend the deadline");
3341        assert!(get_pending_invite(&cid).unwrap().is_none());
3342    }
3343}