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::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/// Store one held epoch key in the multi-held archive. `scope_id` is a channel_id hex or
150/// [`crate::community::SERVER_ROOT_SCOPE_HEX`]. The `(community, scope, epoch)` PK makes a write for
151/// one epoch unable to disturb another epoch's key — so retained history survives a rekey. Uses
152/// REPLACE on the exact coordinate so the fork-resolution apply path can commit the *winning*
153/// key for a contested epoch over a previously-stored loser (the only legitimate same-coordinate
154/// overwrite; an epoch key is otherwise immutable).
155pub fn store_epoch_key(community_id: &str, scope_id: &str, epoch: u64, key: &[u8; 32]) -> Result<(), String> {
156    let conn = super::get_write_connection_guard_static()?;
157    store_epoch_key_tx(&conn, community_id, scope_id, epoch, key)
158}
159
160/// Shared INSERT body so `save_community` can archive keys inside its own transaction and the
161/// standalone [`store_epoch_key`] can run on a borrowed connection. `C: Deref<Target=Connection>`
162/// covers both a `Connection` and a `Transaction`.
163fn store_epoch_key_tx<C: std::ops::Deref<Target = rusqlite::Connection>>(
164    conn: &C,
165    community_id: &str,
166    scope_id: &str,
167    epoch: u64,
168    key: &[u8; 32],
169) -> Result<(), String> {
170    let enc = enc_key(key)?;
171    conn.execute(
172        "INSERT OR REPLACE INTO community_epoch_keys
173            (community_id, scope_id, epoch, key, created_at)
174         VALUES (?1, ?2, ?3, ?4, ?5)",
175        // epoch reinterpreted u64->i64 (lossless); never ORDER BY / range-filter it in SQL (see save).
176        params![community_id, scope_id, epoch as i64, &enc[..], now_secs()],
177    )
178    .map_err(|e| format!("store epoch key: {e}"))?;
179    Ok(())
180}
181
182/// Apply a received channel rekey's new key — the atomic archive+head dual-write: in ONE transaction,
183/// ARCHIVE `(channel, new_epoch) -> new_key` in `community_epoch_keys` AND advance the channel's
184/// read-head (`community_channels.epoch` + `channel_key`) iff `new_epoch` exceeds the current head.
185/// A caught-up OLDER epoch is archived (its history stays decryptable) but never regresses the head.
186/// Atomic so a crash can't leave the archive ahead of the head or the reverse. Returns whether the
187/// head advanced. Epoch comparison is done in RUST (the u64-as-i64 ≥2^63 SQL mis-order trap).
188pub fn advance_channel_epoch(
189    community_id: &str,
190    channel_id: &str,
191    new_epoch: u64,
192    new_key: &[u8; 32],
193) -> Result<bool, String> {
194    let conn = super::get_write_connection_guard_static()?;
195    let tx = conn.unchecked_transaction().map_err(|e| format!("advance channel epoch tx: {e}"))?;
196    // Archive always (PK includes epoch → never clobbers another epoch's key).
197    store_epoch_key_tx(&tx, community_id, channel_id, new_epoch, new_key)?;
198    // Monotonic head advance, compared in Rust.
199    let cur: Option<i64> = tx
200        .query_row(
201            "SELECT epoch FROM community_channels WHERE community_id = ?1 AND channel_id = ?2",
202            params![community_id, channel_id],
203            |r| r.get(0),
204        )
205        .optional()
206        .map_err(|e| format!("read channel head: {e}"))?;
207    let advanced = matches!(cur, Some(c) if new_epoch > c as u64);
208    if advanced {
209        let enc = enc_key(new_key)?;
210        tx.execute(
211            "UPDATE community_channels SET epoch = ?1, channel_key = ?2
212               WHERE community_id = ?3 AND channel_id = ?4",
213            params![new_epoch as i64, &enc[..], community_id, channel_id],
214        )
215        .map_err(|e| format!("advance channel head: {e}"))?;
216    }
217    tx.commit().map_err(|e| format!("advance channel epoch commit: {e}"))?;
218    Ok(advanced)
219}
220
221/// Apply a received SERVER-ROOT (base) rekey's new root — the base counterpart to
222/// [`advance_channel_epoch`], atomic: in ONE transaction, ARCHIVE `(server-root scope, new_epoch) ->
223/// new_root` in `community_epoch_keys` AND advance the base head (`communities.server_root_epoch` +
224/// `server_root_key`) iff `new_epoch` exceeds the current base epoch (monotonic, compared in RUST). A
225/// caught-up OLDER base epoch is archived (its control/base history stays decryptable) but never
226/// regresses the head. Returns whether the head advanced.
227pub fn advance_server_root_epoch(community_id: &str, new_epoch: u64, new_root: &[u8; 32]) -> Result<bool, String> {
228    let conn = super::get_write_connection_guard_static()?;
229    let tx = conn.unchecked_transaction().map_err(|e| format!("advance server root tx: {e}"))?;
230    // Archive always, under the all-zero server-root scope sentinel (PK includes epoch → never clobbers
231    // another epoch's root).
232    store_epoch_key_tx(&tx, community_id, crate::community::SERVER_ROOT_SCOPE_HEX, new_epoch, new_root)?;
233    let cur: Option<i64> = tx
234        .query_row(
235            "SELECT server_root_epoch FROM communities WHERE community_id = ?1",
236            params![community_id],
237            |r| r.get(0),
238        )
239        .optional()
240        .map_err(|e| format!("read server-root epoch: {e}"))?;
241    let advanced = matches!(cur, Some(c) if new_epoch > c as u64);
242    if advanced {
243        let enc = enc_key(new_root)?;
244        tx.execute(
245            "UPDATE communities SET server_root_epoch = ?1, server_root_key = ?2 WHERE community_id = ?3",
246            params![new_epoch as i64, &enc[..], community_id],
247        )
248        .map_err(|e| format!("advance server-root head: {e}"))?;
249    }
250    tx.commit().map_err(|e| format!("advance server root commit: {e}"))?;
251    Ok(advanced)
252}
253
254/// SAME-EPOCH convergence for the server root (concurrent re-founding heal): two BAN-holders who
255/// re-founded at the same time each sit on their OWN root at the SAME epoch. `advance_server_root_epoch`
256/// refuses to switch (its guard is strictly monotonic), so this is the sibling that REPLACES the head root
257/// at `epoch` with the deterministic winner (lowest root bytes — the caller decides). Archives the new root
258/// + swaps the head, but ONLY while we're still AT `epoch` (a later real rotation must win over a stale
259/// converge). Returns whether it switched.
260pub fn converge_server_root_epoch(community_id: &str, epoch: u64, new_root: &[u8; 32]) -> Result<bool, String> {
261    let conn = super::get_write_connection_guard_static()?;
262    let tx = conn.unchecked_transaction().map_err(|e| format!("converge server root tx: {e}"))?;
263    store_epoch_key_tx(&tx, community_id, crate::community::SERVER_ROOT_SCOPE_HEX, epoch, new_root)?;
264    let enc = enc_key(new_root)?;
265    let switched = tx
266        .execute(
267            "UPDATE communities SET server_root_key = ?1 WHERE community_id = ?2 AND server_root_epoch = ?3",
268            params![&enc[..], community_id, epoch as i64],
269        )
270        .map_err(|e| format!("converge server-root head: {e}"))?
271        > 0;
272    tx.commit().map_err(|e| format!("converge server root commit: {e}"))?;
273    Ok(switched)
274}
275
276/// SAME-EPOCH convergence for a channel key (concurrent re-founding heal) — the channel counterpart to
277/// [`converge_server_root_epoch`]. Adopts the winning re-founding's channel key at `epoch` (the rekey
278/// addressed under the converged server root), replacing the one we minted in our own losing fork. Switches
279/// only while the channel is still AT `epoch`. Returns whether it switched.
280pub fn converge_channel_epoch(community_id: &str, channel_id: &str, epoch: u64, new_key: &[u8; 32]) -> Result<bool, String> {
281    let conn = super::get_write_connection_guard_static()?;
282    let tx = conn.unchecked_transaction().map_err(|e| format!("converge channel tx: {e}"))?;
283    store_epoch_key_tx(&tx, community_id, channel_id, epoch, new_key)?;
284    let enc = enc_key(new_key)?;
285    let switched = tx
286        .execute(
287            "UPDATE community_channels SET channel_key = ?1 WHERE community_id = ?2 AND channel_id = ?3 AND epoch = ?4",
288            params![&enc[..], community_id, channel_id, epoch as i64],
289        )
290        .map_err(|e| format!("converge channel head: {e}"))?
291        > 0;
292    tx.commit().map_err(|e| format!("converge channel commit: {e}"))?;
293    Ok(switched)
294}
295
296/// Every held `(epoch, key)` for a scope, ascending by epoch. The read paths derive a pseudonym per
297/// returned epoch (`#z` OR-set) so cross-epoch history isn't stranded. Sorted in Rust (not SQL):
298/// epoch is a u64 stored as i64, so a SQL `ORDER BY` would mis-order epochs >= 2^63.
299pub fn held_epoch_keys(community_id: &str, scope_id: &str) -> Result<Vec<(Epoch, [u8; 32])>, String> {
300    let conn = super::get_db_connection_guard_static()?;
301    let mut stmt = conn
302        .prepare("SELECT epoch, key FROM community_epoch_keys WHERE community_id = ?1 AND scope_id = ?2")
303        .map_err(|e| e.to_string())?;
304    let rows = stmt
305        .query_map(params![community_id, scope_id], |r| {
306            Ok((r.get::<_, i64>(0)?, r.get::<_, Vec<u8>>(1)?))
307        })
308        .map_err(|e| e.to_string())?;
309    let mut out: Vec<(Epoch, [u8; 32])> = Vec::new();
310    for row in rows {
311        let (epoch, key_blob) = row.map_err(|e| e.to_string())?;
312        out.push((Epoch(epoch as u64), dec_key(&key_blob)?));
313    }
314    out.sort_by_key(|(e, _)| e.0);
315    Ok(out)
316}
317
318/// The held key for one specific `(scope, epoch)`, or `None` if not held. The open path uses this to
319/// select the decryption key by the inbound event's `epoch` tag.
320pub fn held_epoch_key(community_id: &str, scope_id: &str, epoch: u64) -> Result<Option<[u8; 32]>, String> {
321    let conn = super::get_db_connection_guard_static()?;
322    let blob: Option<Vec<u8>> = conn
323        .query_row(
324            "SELECT key FROM community_epoch_keys WHERE community_id = ?1 AND scope_id = ?2 AND epoch = ?3",
325            params![community_id, scope_id, epoch as i64],
326            |r| r.get(0),
327        )
328        .optional()
329        .map_err(|e| format!("held epoch key: {e}"))?;
330    blob.map(|b| dec_key(&b)).transpose()
331}
332
333/// Local first-save time of a community (≈ when this account joined or created it), in ms.
334/// `created_at` is set on the first save and preserved across metadata re-saves, so it tracks
335/// the join moment. Used to sort a not-yet-active community by join time. `None` if unknown.
336pub fn community_created_at_ms(id: &CommunityId) -> Option<u64> {
337    let conn = super::get_db_connection_guard_static().ok()?;
338    conn.query_row(
339        "SELECT created_at FROM communities WHERE community_id = ?1",
340        params![id.to_hex()],
341        |r| r.get::<_, i64>(0),
342    )
343    .optional()
344    .ok()
345    .flatten()
346    .map(|secs| (secs.max(0) as u64) * 1000)
347}
348
349/// Load a Community and its channels by id. Returns `None` if not stored locally.
350pub fn load_community(id: &CommunityId) -> Result<Option<Community>, String> {
351    let conn = super::get_db_connection_guard_static()?;
352    let id_hex = id.to_hex();
353
354    let row = conn
355        .query_row(
356            "SELECT server_root_key, name, relays,
357                    description, icon, banner, banlist, owner_attestation, server_root_epoch, dissolved
358               FROM communities WHERE community_id = ?1",
359            params![id_hex],
360            |r| {
361                Ok((
362                    r.get::<_, Vec<u8>>(0)?,
363                    r.get::<_, String>(1)?,
364                    r.get::<_, String>(2)?,
365                    r.get::<_, Option<String>>(3)?,
366                    r.get::<_, Option<String>>(4)?,
367                    r.get::<_, Option<String>>(5)?,
368                    r.get::<_, String>(6)?,
369                    r.get::<_, Option<String>>(7)?,
370                    r.get::<_, i64>(8)?,
371                    r.get::<_, i64>(9)?,
372                ))
373            },
374        )
375        .optional()
376        .map_err(|e| format!("load community: {e}"))?;
377
378    let (root_blob, name, relays_json, description, icon_json, banner_json, banlist_json, owner_attestation, server_root_epoch, dissolved_int) =
379        match row {
380            Some(t) => t,
381            None => return Ok(None),
382        };
383    let dissolved = dissolved_int != 0;
384
385    // Unwrap at-rest encryption before parsing (no-op when off, or for not-yet-wrapped rows).
386    let name = dec_txt(&name);
387    let relays_json = dec_txt(&relays_json);
388    let description = description.map(|s| dec_txt(&s));
389    let icon_json = icon_json.map(|s| dec_txt(&s));
390    let banner_json = banner_json.map(|s| dec_txt(&s));
391    let banlist_json = dec_txt(&banlist_json);
392    let owner_attestation = owner_attestation.map(|s| dec_txt(&s));
393
394    // Banlist: stored as a JSON array of hex pubkeys; parse to PublicKeys (skipping any
395    // malformed entry) and denormalize onto every channel so the inbound path can drop
396    // banned authors. A bad/empty column degrades to "no bans", never an error.
397    let banned: Vec<PublicKey> = serde_json::from_str::<Vec<String>>(&banlist_json)
398        .unwrap_or_default()
399        .iter()
400        .filter_map(|h| PublicKey::from_hex(h).ok())
401        .collect();
402
403    let icon = icon_json
404        .map(|j| serde_json::from_str(&j))
405        .transpose()
406        .map_err(|e| format!("icon json: {e}"))?;
407    let banner = banner_json
408        .map(|j| serde_json::from_str(&j))
409        .transpose()
410        .map_err(|e| format!("banner json: {e}"))?;
411
412    let server_root_key = ServerRootKey(dec_key(&root_blob)?);
413    let relays: Vec<String> = serde_json::from_str(&relays_json).map_err(|e| e.to_string())?;
414
415    // hierarchy invariant (apply-time): the OWNER is the uppermost role and can never be
416    // effectively banned or hidden — by anyone. Everyone knows the owner from the attestation, so
417    // ALL members enforce this (the owner is filtered out of `banned` and protected from hides).
418    // Admins are NOT absolutely protected: the owner outranks them and CAN ban/hide an admin.
419    // (Admin-vs-admin peer protection — a lower rank can't act on an equal — is a later
420    // position-relative refinement gated on the author proof; the owner protection is the invariant.)
421    let mut protected: Vec<PublicKey> = Vec::new();
422    if let Some(owner) = owner_attestation
423        .as_ref()
424        .and_then(|att| crate::community::owner::verify_owner_attestation(att, &id_hex))
425    {
426        protected.push(owner);
427    }
428    let banned: Vec<PublicKey> = banned.into_iter().filter(|pk| !protected.contains(pk)).collect();
429
430    // Collect the channel head rows FIRST (drops the borrow on `conn`) so we can then query each
431    // channel's full epoch-key archive on the same connection without a borrow conflict.
432    let raw_channels: Vec<(String, Vec<u8>, i64, String)> = {
433        let mut stmt = conn
434            .prepare(
435                "SELECT channel_id, channel_key, epoch, name
436                   FROM community_channels WHERE community_id = ?1 ORDER BY created_at",
437            )
438            .map_err(|e| e.to_string())?;
439        let rows = stmt
440            .query_map(params![id_hex], |r| {
441                Ok((
442                    r.get::<_, String>(0)?,
443                    r.get::<_, Vec<u8>>(1)?,
444                    r.get::<_, i64>(2)?,
445                    r.get::<_, String>(3)?,
446                ))
447            })
448            .map_err(|e| e.to_string())?;
449        rows.collect::<Result<Vec<_>, _>>().map_err(|e| e.to_string())?
450    };
451
452    // The AUTHORIZED roster (cached by fetch_and_apply_roles, post delegation check), denormalized
453    // onto each channel so the inbound delete path can verify a keyless moderation-hide.
454    let roster = get_community_roles(&id_hex).unwrap_or_default();
455
456    let mut channels = Vec::new();
457    for (cid_hex, key_blob, epoch, cname) in raw_channels {
458        // Every retained epoch key for this channel (multi-held archive), so the read path can fetch +
459        // decrypt across rekeys. Best-effort: a read hiccup degrades to the head epoch (read_epoch_keys
460        // falls back), never an error.
461        let epoch_keys: Vec<(Epoch, crate::community::ChannelKey)> = {
462            let mut ek_stmt = conn
463                .prepare("SELECT epoch, key FROM community_epoch_keys WHERE community_id = ?1 AND scope_id = ?2")
464                .map_err(|e| e.to_string())?;
465            let rows = ek_stmt
466                .query_map(params![id_hex, cid_hex], |r| Ok((r.get::<_, i64>(0)?, r.get::<_, Vec<u8>>(1)?)))
467                .map_err(|e| e.to_string())?;
468            let mut out = Vec::new();
469            for row in rows {
470                let (e, blob) = row.map_err(|e| e.to_string())?;
471                if let Ok(k) = dec_key(&blob) {
472                    out.push((Epoch(e as u64), crate::community::ChannelKey(k)));
473                }
474            }
475            out
476        };
477        channels.push(Channel {
478            id: ChannelId(hex_id_to_32(&cid_hex)?),
479            key: ChannelKey(dec_key(&key_blob)?),
480            // Stored as i64; reinterpreted back to u64 (two's-complement is exact,
481            // so the bit pattern round-trips losslessly even for epoch >= 2^63).
482            epoch: Epoch(epoch as u64),
483            name: dec_txt(&cname),
484            banned: banned.clone(),
485            protected: protected.clone(),
486            roster: roster.clone(),
487            epoch_keys,
488            dissolved,
489        });
490    }
491
492    Ok(Some(Community {
493        id: *id,
494        server_root_key,
495        // Stored as i64; reinterpreted to u64 (two's-complement is exact), same as channel epochs.
496        server_root_epoch: Epoch(server_root_epoch as u64),
497        name,
498        description,
499        icon,
500        banner,
501        relays,
502        channels,
503        owner_attestation,
504        dissolved,
505    }))
506}
507
508/// Retain the ephemeral signing key of a message I published, so I can later
509/// NIP-09-delete it. `relays` is where the deletion must be sent.
510pub fn store_message_key(
511    message_id: &str,
512    outer_event_id: &str,
513    ephemeral: &Keys,
514    relays: &[String],
515) -> Result<(), String> {
516    let conn = super::get_write_connection_guard_static()?;
517    let relays_json = serde_json::to_string(relays).map_err(|e| e.to_string())?;
518    let sk_bytes = to_32(ephemeral.secret_key().as_secret_bytes())?;
519    let enc_secret = enc_key(&sk_bytes)?;
520    let enc_relays = enc_txt(&relays_json)?;
521    conn.execute(
522        "INSERT OR REPLACE INTO community_message_keys
523            (outer_event_id, message_id, ephemeral_secret, relays, created_at)
524         VALUES (?1, ?2, ?3, ?4, ?5)",
525        params![
526            outer_event_id,
527            message_id,
528            &enc_secret[..],
529            enc_relays,
530            now_secs(),
531        ],
532    )
533    .map_err(|e| format!("store message key: {e}"))?;
534    Ok(())
535}
536
537/// Read (WITHOUT removing) the retained key for a message by its INNER message id (what
538/// the UI holds). Returns the ephemeral signing `Keys`, the OUTER event id to
539/// NIP-09-delete, and the relay set — or `None` if not retained (someone else's message,
540/// or already deleted). Peek-only so the key survives a failed deletion publish; the
541/// caller removes it with [`delete_message_key`] only after the publish succeeds.
542pub fn get_message_key(message_id: &str) -> Result<Option<(Keys, String, Vec<String>)>, String> {
543    let conn = super::get_db_connection_guard_static()?;
544    let row = conn
545        .query_row(
546            "SELECT ephemeral_secret, outer_event_id, relays
547               FROM community_message_keys WHERE message_id = ?1",
548            params![message_id],
549            |r| Ok((r.get::<_, Vec<u8>>(0)?, r.get::<_, String>(1)?, r.get::<_, String>(2)?)),
550        )
551        .optional()
552        .map_err(|e| format!("get message key: {e}"))?;
553    let (secret_blob, outer_event_id, relays_json) = match row {
554        Some(t) => t,
555        None => return Ok(None),
556    };
557    let secret = SecretKey::from_slice(&dec_key(&secret_blob)?).map_err(|e| format!("ephemeral secret: {e}"))?;
558    let relays: Vec<String> = serde_json::from_str(&dec_txt(&relays_json)).map_err(|e| e.to_string())?;
559    Ok(Some((Keys::new(secret), outer_event_id, relays)))
560}
561
562/// Remove a retained message key (after a successful deletion publish).
563pub fn delete_message_key(message_id: &str) -> Result<(), String> {
564    let conn = super::get_write_connection_guard_static()?;
565    conn.execute(
566        "DELETE FROM community_message_keys WHERE message_id = ?1",
567        params![message_id],
568    )
569    .map_err(|e| format!("remove message key: {e}"))?;
570    Ok(())
571}
572
573/// Peek + remove in one call. Prefer [`get_message_key`] + [`delete_message_key`] when a
574/// fallible step sits between, so a failure doesn't strand the key.
575pub fn take_message_key(message_id: &str) -> Result<Option<(Keys, String, Vec<String>)>, String> {
576    let r = get_message_key(message_id)?;
577    if r.is_some() {
578        delete_message_key(message_id)?;
579    }
580    Ok(r)
581}
582
583/// The hex id of the Community that owns `channel_id`, if any is stored locally. Used to
584/// resolve a channel-addressed chat back to its Community for sending.
585pub fn community_id_for_channel(channel_id: &str) -> Result<Option<String>, String> {
586    let conn = super::get_db_connection_guard_static()?;
587    conn.query_row(
588        "SELECT community_id FROM community_channels WHERE channel_id = ?1",
589        params![channel_id],
590        |r| r.get::<_, String>(0),
591    )
592    .optional()
593    .map_err(|e| format!("community_id_for_channel: {e}"))
594}
595
596/// Whether a Community with this id is already stored locally (joined). Cheaper than
597/// `load_community` when only existence matters (e.g. inbound-invite dedup).
598pub fn community_exists(id: &CommunityId) -> Result<bool, String> {
599    let conn = super::get_db_connection_guard_static()?;
600    let found: Option<i64> = conn
601        .query_row(
602            "SELECT 1 FROM communities WHERE community_id = ?1",
603            params![id.to_hex()],
604            |r| r.get(0),
605        )
606        .optional()
607        .map_err(|e| format!("community_exists: {e}"))?;
608    Ok(found.is_some())
609}
610
611/// A parked invite awaiting the user's accept/decline decision.
612#[derive(Debug, Clone, serde::Serialize)]
613pub struct PendingCommunityInvite {
614    pub community_id: String,
615    pub bundle_json: String,
616    pub inviter_npub: String,
617    pub received_at: i64,
618}
619
620/// Park an inbound invite bundle for explicit user consent (the carrier never
621/// auto-joins). First-invite-wins: `INSERT OR IGNORE` means a later invite for the
622/// same `community_id` can't silently rewrite a parked bundle. Returns whether a new
623/// row was inserted (`false` = already pending, caller should not re-notify).
624pub fn save_pending_invite(
625    community_id: &str,
626    bundle_json: &str,
627    inviter_npub: &str,
628) -> Result<bool, String> {
629    /// Cap on parked invites. Each row is one gift-wrapped invite from an arbitrary sender, so an
630    /// attacker fabricating unbounded community_ids could otherwise grow this table without limit
631    /// (#298). Newest-wins: a stale months-old park is the safe thing to shed.
632    const MAX_PENDING_INVITES: usize = 100;
633
634    let conn = super::get_write_connection_guard_static()?;
635    let enc_bundle = enc_txt(bundle_json)?;
636    let enc_inviter = enc_txt(inviter_npub)?;
637    // First-wins: a parked invite is never silently overwritten by a later different
638    // bundle (that would let an attacker replace a genuine parked invite). For v2, a
639    // pre-planted forged-root bundle sharing a real community_id is instead cleared on
640    // a failed accept (see `accept_pending_invite`), so a genuine re-invite can re-park.
641    let changed = conn
642        .execute(
643            "INSERT OR IGNORE INTO pending_community_invites
644                (community_id, bundle_json, inviter_npub, received_at)
645             VALUES (?1, ?2, ?3, ?4)",
646            params![community_id, enc_bundle, enc_inviter, now_secs()],
647        )
648        .map_err(|e| format!("save pending invite: {e}"))?;
649    // Only growth can breach the cap. Evict everything past the newest MAX rows
650    // (LIMIT -1 OFFSET cap = "all rows after the first cap"); community_id tie-breaks equal times.
651    if changed > 0 {
652        let _ = conn.execute(
653            "DELETE FROM pending_community_invites
654               WHERE community_id IN (
655                 SELECT community_id FROM pending_community_invites
656                 ORDER BY received_at DESC, community_id DESC
657                 LIMIT -1 OFFSET ?1
658               )",
659            params![MAX_PENDING_INVITES],
660        );
661    }
662    Ok(changed > 0)
663}
664
665/// Drop every parked invite for a community we ALREADY hold — once joined on any device, the
666/// invite must never resurface. Ordering-independent: covers the cross-device case where the
667/// historical gift-wrapped invites are ingested BEFORE the synced membership list rehydrates
668/// those communities (so the ingest-time `community_exists` guard saw nothing yet). Returns the
669/// count purged.
670pub fn purge_pending_invites_for_held_communities() -> Result<usize, String> {
671    let conn = super::get_write_connection_guard_static()?;
672    let n = conn
673        .execute(
674            "DELETE FROM pending_community_invites
675               WHERE community_id IN (SELECT community_id FROM communities)",
676            [],
677        )
678        .map_err(|e| format!("purge held pending invites: {e}"))?;
679    Ok(n)
680}
681
682/// All parked invites, newest first.
683pub fn list_pending_invites() -> Result<Vec<PendingCommunityInvite>, String> {
684    let conn = super::get_db_connection_guard_static()?;
685    let mut stmt = conn
686        .prepare(
687            "SELECT community_id, bundle_json, inviter_npub, received_at
688               FROM pending_community_invites ORDER BY received_at DESC",
689        )
690        .map_err(|e| e.to_string())?;
691    let rows = stmt
692        .query_map([], |r| {
693            Ok(PendingCommunityInvite {
694                community_id: r.get(0)?,
695                bundle_json: dec_txt(&r.get::<_, String>(1)?),
696                inviter_npub: dec_txt(&r.get::<_, String>(2)?),
697                received_at: r.get(3)?,
698            })
699        })
700        .map_err(|e| e.to_string())?;
701    let mut out = Vec::new();
702    for row in rows {
703        out.push(row.map_err(|e| e.to_string())?);
704    }
705    Ok(out)
706}
707
708/// Read a parked invite's bundle WITHOUT removing it. Accept is fallible (caps,
709/// owner/authority collision), so the row must survive a rejected accept — peek here,
710/// then [`delete_pending_invite`] only after the join succeeds.
711pub fn get_pending_invite(community_id: &str) -> Result<Option<String>, String> {
712    let conn = super::get_db_connection_guard_static()?;
713    let raw: Option<String> = conn
714        .query_row(
715            "SELECT bundle_json FROM pending_community_invites WHERE community_id = ?1",
716            params![community_id],
717            |r| r.get::<_, String>(0),
718        )
719        .optional()
720        .map_err(|e| format!("get pending invite: {e}"))?;
721    Ok(raw.map(|s| dec_txt(&s)))
722}
723
724/// Drop a parked invite without joining (the user declined).
725pub fn delete_pending_invite(community_id: &str) -> Result<(), String> {
726    let conn = super::get_write_connection_guard_static()?;
727    conn.execute(
728        "DELETE FROM pending_community_invites WHERE community_id = ?1",
729        params![community_id],
730    )
731    .map_err(|e| format!("delete pending invite: {e}"))?;
732    Ok(())
733}
734
735/// Whether an invite for this id is already parked (inbound dedup).
736pub fn pending_invite_exists(community_id: &str) -> Result<bool, String> {
737    let conn = super::get_db_connection_guard_static()?;
738    let found: Option<i64> = conn
739        .query_row(
740            "SELECT 1 FROM pending_community_invites WHERE community_id = ?1",
741            params![community_id],
742            |r| r.get(0),
743        )
744        .optional()
745        .map_err(|e| format!("pending_invite_exists: {e}"))?;
746    Ok(found.is_some())
747}
748
749/// A minted public-invite link the owner retains (to list + revoke).
750#[derive(Debug, Clone, serde::Serialize)]
751pub struct PublicInviteRecord {
752    /// Hex token (the link's whole secret; lives only in the local account DB).
753    pub token: String,
754    pub community_id: String,
755    pub url: String,
756    pub expires_at: Option<i64>,
757    pub created_at: i64,
758    /// Optional human label set at mint time (e.g. "Twitter", "Discord"). None if unset.
759    pub label: Option<String>,
760    /// Distinct members who joined via this link (by label attribution). 0 if none/unknown.
761    #[serde(default)]
762    pub join_count: u64,
763}
764
765/// Retain a minted public-invite token so the owner can later list + revoke it.
766pub fn save_public_invite(
767    token: &str,
768    community_id: &str,
769    url: &str,
770    expires_at: Option<i64>,
771    label: Option<&str>,
772) -> Result<(), String> {
773    let conn = super::get_write_connection_guard_static()?;
774    // token + url are the link's secret; encrypted, the token PK becomes per-write-unique (random
775    // nonce) so this is effectively an INSERT — fine, mints generate a fresh token each time.
776    let enc_token = enc_txt(token)?;
777    let enc_url = enc_txt(url)?;
778    // Encrypt the label at rest like the url; NULL when no label was set.
779    let enc_label = label.map(enc_txt).transpose()?;
780    conn.execute(
781        "INSERT OR REPLACE INTO community_public_invites
782            (token, community_id, url, expires_at, created_at, label)
783         VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
784        params![enc_token, community_id, enc_url, expires_at, now_secs(), enc_label],
785    )
786    .map_err(|e| format!("save public invite: {e}"))?;
787    Ok(())
788}
789
790/// All minted public-invite links for a Community, newest first.
791pub fn list_public_invites(community_id: &str) -> Result<Vec<PublicInviteRecord>, String> {
792    let conn = super::get_db_connection_guard_static()?;
793    let mut stmt = conn
794        .prepare(
795            "SELECT token, community_id, url, expires_at, created_at, label
796               FROM community_public_invites WHERE community_id = ?1 ORDER BY created_at DESC",
797        )
798        .map_err(|e| e.to_string())?;
799    let rows = stmt
800        .query_map(params![community_id], |r| {
801            Ok(PublicInviteRecord {
802                token: dec_txt(&r.get::<_, String>(0)?),
803                community_id: r.get(1)?,
804                url: dec_txt(&r.get::<_, String>(2)?),
805                expires_at: r.get(3)?,
806                created_at: r.get(4)?,
807                label: r.get::<_, Option<String>>(5)?.map(|s| dec_txt(&s)),
808                join_count: 0,
809            })
810        })
811        .map_err(|e| e.to_string())?;
812    let mut out = Vec::new();
813    for row in rows {
814        out.push(row.map_err(|e| e.to_string())?);
815    }
816    // Fill per-link join counts (distinct joiners via each label, attributed to me).
817    if let Some(me) = crate::state::my_public_key().and_then(|pk| pk.to_bech32().ok()) {
818        if let Ok(counts) = community_invite_join_counts(community_id, &me) {
819            for rec in &mut out {
820                if let Some(l) = rec.label.as_deref() {
821                    rec.join_count = counts.get(l).copied().unwrap_or(0);
822                }
823            }
824        }
825    }
826    Ok(out)
827}
828
829/// Forget a minted public-invite token (after revoking it on relays).
830pub fn delete_public_invite(token: &str) -> Result<(), String> {
831    let conn = super::get_write_connection_guard_static()?;
832    // Stored tokens are encrypted (random nonce), so an equality DELETE can't match — scan,
833    // decrypt, and delete the row whose plaintext token matches (by rowid). Few rows, owner-only.
834    let rows: Vec<(i64, String)> = {
835        let mut stmt = conn
836            .prepare("SELECT rowid, token FROM community_public_invites")
837            .map_err(|e| e.to_string())?;
838        let mapped = stmt
839            .query_map([], |r| Ok((r.get::<_, i64>(0)?, r.get::<_, String>(1)?)))
840            .map_err(|e| e.to_string())?;
841        mapped.filter_map(|r| r.ok()).collect()
842    };
843    for (rowid, stored) in rows {
844        if dec_txt(&stored) == token {
845            conn.execute("DELETE FROM community_public_invites WHERE rowid = ?1", params![rowid])
846                .map_err(|e| format!("delete public invite: {e}"))?;
847        }
848    }
849    Ok(())
850}
851
852/// All minted public-invite links across ALL communities (backfill source for the synced Invite List).
853pub fn list_all_public_invites() -> Result<Vec<PublicInviteRecord>, String> {
854    let conn = super::get_db_connection_guard_static()?;
855    let mut stmt = conn
856        .prepare(
857            "SELECT token, community_id, url, expires_at, created_at, label
858               FROM community_public_invites ORDER BY created_at DESC",
859        )
860        .map_err(|e| e.to_string())?;
861    let rows = stmt
862        .query_map([], |r| {
863            Ok(PublicInviteRecord {
864                token: dec_txt(&r.get::<_, String>(0)?),
865                community_id: r.get(1)?,
866                url: dec_txt(&r.get::<_, String>(2)?),
867                expires_at: r.get(3)?,
868                created_at: r.get(4)?,
869                label: r.get::<_, Option<String>>(5)?.map(|s| dec_txt(&s)),
870                join_count: 0,
871            })
872        })
873        .map_err(|e| e.to_string())?;
874    let mut out = Vec::new();
875    for row in rows {
876        out.push(row.map_err(|e| e.to_string())?);
877    }
878    Ok(out)
879}
880
881/// Insert a public-invite row only if its (decrypted) token isn't already present — idempotent hydration
882/// from the synced Invite List, PRESERVING the original `created_at` (unlike `save_public_invite`, which
883/// stamps now). Returns true if a row was inserted. Tokens are stored encrypted with a random nonce, so SQL
884/// equality can't dedup; scan + decrypt (few rows per community, owner-only).
885pub fn upsert_public_invite(
886    token: &str,
887    community_id: &str,
888    url: &str,
889    expires_at: Option<i64>,
890    created_at: i64,
891    label: Option<&str>,
892) -> Result<bool, String> {
893    let conn = super::get_write_connection_guard_static()?;
894    let already = {
895        let mut stmt = conn
896            .prepare("SELECT token FROM community_public_invites WHERE community_id = ?1")
897            .map_err(|e| e.to_string())?;
898        let stored: Vec<String> = stmt
899            .query_map(params![community_id], |r| r.get::<_, String>(0))
900            .map_err(|e| e.to_string())?
901            .filter_map(|r| r.ok())
902            .collect();
903        stored.iter().any(|s| dec_txt(s) == token)
904    };
905    if already {
906        return Ok(false);
907    }
908    let enc_token = enc_txt(token)?;
909    let enc_url = enc_txt(url)?;
910    let enc_label = label.map(enc_txt).transpose()?;
911    conn.execute(
912        "INSERT INTO community_public_invites
913            (token, community_id, url, expires_at, created_at, label)
914         VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
915        params![enc_token, community_id, enc_url, expires_at, created_at, enc_label],
916    )
917    .map_err(|e| format!("upsert public invite: {e}"))?;
918    Ok(true)
919}
920
921/// Remove a Community and all its local state (channels, retained message keys, parked
922/// invites, minted public-invite tokens). Used when the user leaves a Community — there
923/// is no protocol "leave" (membership is key possession), so leaving is purely local:
924/// drop the keys + stop subscribing.
925pub fn delete_community(community_id: &str) -> Result<(), String> {
926    delete_community_inner(community_id, false)
927}
928
929/// self-removal teardown: drop all local community state EXCEPT the held epoch keys
930/// (`community_epoch_keys`). Read access to future epochs is already gone (the post-removal
931/// keys are never delivered); retaining the OLD keys only preserves the ability to author a
932/// `3305` self-delete of one's own past messages, each sealed under the epoch key it was sent
933/// at. Used by every self-removal trigger (voluntary leave, kick of me, ban-rekey exclusion).
934pub fn delete_community_retain_keys(community_id: &str) -> Result<(), String> {
935    delete_community_inner(community_id, true)
936}
937
938fn delete_community_inner(community_id: &str, retain_keys: bool) -> Result<(), String> {
939    let conn = super::get_write_connection_guard_static()?;
940    // Atomic: a crash/error mid-delete must not orphan channel/invite rows under a
941    // now-missing parent (community_id_for_channel would still resolve them).
942    let tx = conn.unchecked_transaction().map_err(|e| format!("delete community tx: {e}"))?;
943    for sql in [
944        Some("DELETE FROM communities WHERE community_id = ?1"),
945        Some("DELETE FROM community_channels WHERE community_id = ?1"),
946        // Multi-held epoch keys (base + per-channel, all epochs). RETAINED on a self-removal so a
947        // later self-scrub of own past messages stays possible; dropped on an explicit delete/re-join reset
948        // (else a re-join inherits stale rotated keys).
949        (!retain_keys).then_some("DELETE FROM community_epoch_keys WHERE community_id = ?1"),
950        Some("DELETE FROM community_public_invites WHERE community_id = ?1"),
951        Some("DELETE FROM community_invite_link_sets WHERE community_id = ?1"),
952        Some("DELETE FROM pending_community_invites WHERE community_id = ?1"),
953        // Per-entity edition heads (keyless model) — else stale refuse-downgrade floors + self_hash
954        // anchors survive a leave/re-join and reject a legitimately reset chain.
955        Some("DELETE FROM community_edition_heads WHERE community_id = ?1"),
956    ]
957    .into_iter()
958    .flatten()
959    {
960        tx.execute(sql, params![community_id])
961            .map_err(|e| format!("delete community: {e}"))?;
962    }
963    tx.commit().map_err(|e| format!("delete community commit: {e}"))?;
964    // `community_message_keys` is INTENTIONALLY left intact: those are our OWN ephemeral signing keys for
965    // NIP-09-deleting our own messages. The right to erase our own content from relays outlives membership
966    // — even after a ban or leave we must keep the ability to purge what we sent — so they survive a
967    // community delete. (Keyed by message_id, no community_id; there is nothing community-scoped to drop.)
968    Ok(())
969}
970
971/// Observed participants: the best-effort member list of a Community, newest-active first.
972/// Membership is NOT authoritative (a lurker who never posts and never announced won't appear).
973/// A member is included when they have real activity — a posted message/reaction/edit, OR a
974/// join presence (kind 3306) — UNLESS that is superseded by a more-recent leave, OR they are
975/// banned. So a "leave" actually removes a member, and a leave-then-rejoin/post re-adds them.
976/// `created_at` is in seconds. Result is capped (anti-flood); see [`COMMUNITY_MEMBER_CAP`].
977pub fn community_member_activity(community_id: &str) -> Result<Vec<(String, u64)>, String> {
978    /// Cap on rendered members — bounds a presence-flood (fresh-identity 3306 spam) from
979    /// growing the list / profile-fetch fan-out without limit.
980    const COMMUNITY_MEMBER_CAP: usize = 500;
981    use std::collections::HashMap;
982
983    let community = match load_community(&CommunityId(hex_id_to_32(community_id)?))? {
984        Some(c) => c,
985        None => return Ok(Vec::new()),
986    };
987    // The proven owner is ALWAYS a member of their own community. Seed them so a freshly-created
988    // community (no message/presence events yet) still shows its creator instead of an empty roster.
989    // `now_secs()` is just a presence baseline; real activity below overwrites it, and the UI re-sorts
990    // by role tier regardless.
991    let owner_b32: Option<String> = community
992        .owner_attestation
993        .as_deref()
994        .and_then(|att| crate::community::owner::verify_owner_attestation(att, community_id))
995        .and_then(|pk| pk.to_bech32().ok());
996
997    // Map each channel's hex id → its integer chat row id (skip channels with no events yet).
998    let mut chat_ints: Vec<i64> = Vec::new();
999    for ch in &community.channels {
1000        if let Ok(cid) = super::id_cache::get_chat_id_by_identifier(&ch.id.to_hex()) {
1001            chat_ints.push(cid);
1002        }
1003    }
1004
1005    // APPLICATION_SPECIFIC (30078) is the kind for presence/system events; everything else in a
1006    // community channel is real message activity. Inlined as a constant integer (no injection).
1007    let sys = crate::stored_event::event_kind::APPLICATION_SPECIFIC;
1008
1009    // active_at[npub] = newest real-activity time (any non-presence event), folded with joins below. The
1010    // proven owner + roster grant-holders are NOT seeded here — they're re-asserted AFTER the leave/ban
1011    // filter (else a stale message would overwrite the seed and a later `left` would wrongly cut a current
1012    // admin — the retain-set inversion). See the re-assert block below.
1013    let mut active: HashMap<String, u64> = HashMap::new();
1014    let mut left: HashMap<String, u64> = HashMap::new();
1015    // No channel has any events yet (e.g. fresh community) → skip the activity queries; the owner + roster
1016    // are still surfaced by the post-filter re-assert below.
1017    if !chat_ints.is_empty() {
1018    let conn = super::get_db_connection_guard_static()?;
1019    let placeholders = chat_ints.iter().map(|_| "?").collect::<Vec<_>>().join(",");
1020
1021    {
1022        let sql = format!(
1023            "SELECT npub, MAX(created_at) FROM events \
1024             WHERE chat_id IN ({placeholders}) AND kind != {sys} AND npub IS NOT NULL AND npub != '' \
1025             GROUP BY npub"
1026        );
1027        let mut stmt = conn.prepare(&sql).map_err(|e| e.to_string())?;
1028        let rows = stmt
1029            .query_map(rusqlite::params_from_iter(chat_ints.iter()), |r| {
1030                Ok((r.get::<_, String>(0)?, r.get::<_, i64>(1)?.max(0) as u64))
1031            })
1032            .map_err(|e| e.to_string())?;
1033        for row in rows {
1034            let (npub, at) = row.map_err(|e| e.to_string())?;
1035            active.insert(npub, at);
1036        }
1037    }
1038
1039    // Fold presence: a join (event-type "1") is activity; a leave (event-type "0") may remove.
1040    {
1041        let sql = format!(
1042            "SELECT npub, created_at, tags FROM events \
1043             WHERE chat_id IN ({placeholders}) AND kind = {sys} AND npub IS NOT NULL AND npub != ''"
1044        );
1045        let mut stmt = conn.prepare(&sql).map_err(|e| e.to_string())?;
1046        let rows = stmt
1047            .query_map(rusqlite::params_from_iter(chat_ints.iter()), |r| {
1048                Ok((r.get::<_, String>(0)?, r.get::<_, i64>(1)?.max(0) as u64, r.get::<_, String>(2)?))
1049            })
1050            .map_err(|e| e.to_string())?;
1051        for row in rows {
1052            let (npub, at, tags_json) = row.map_err(|e| e.to_string())?;
1053            // SystemEventType: 1 = MemberJoined, 0 = MemberLeft (carried in an ["event-type", n] tag).
1054            let etype = serde_json::from_str::<Vec<Vec<String>>>(&tags_json)
1055                .ok()
1056                .and_then(|tags| {
1057                    tags.into_iter()
1058                        .find(|t| t.first().map(|s| s == "event-type").unwrap_or(false))
1059                        .and_then(|t| t.into_iter().nth(1))
1060                });
1061            match etype.as_deref() {
1062                Some("1") => {
1063                    let e = active.entry(npub).or_insert(0);
1064                    if at > *e { *e = at; }
1065                }
1066                Some("0") => {
1067                    let e = left.entry(npub).or_insert(0);
1068                    if at > *e { *e = at; }
1069                }
1070                _ => {}
1071            }
1072        }
1073    }
1074    }
1075
1076    // Exclude banned (banlist is hex; events store bech32 — compare on bech32). Denormalized
1077    // identically onto every channel at load, so reading channels[0] is sufficient.
1078    let banned: std::collections::HashSet<String> = community
1079        .channels
1080        .first()
1081        .map(|c| c.banned.iter().filter_map(|pk| pk.to_bech32().ok()).collect())
1082        .unwrap_or_default();
1083
1084    // Member iff active, not banned, and last activity is at-or-after the last leave.
1085    let mut out: Vec<(String, u64)> = active
1086        .into_iter()
1087        .filter(|(npub, at)| !banned.contains(npub) && left.get(npub).map_or(true, |l| at >= l))
1088        .collect();
1089
1090    // RE-ASSERT authorized members AFTER the activity/leave filter: the proven owner + every
1091    // non-empty-grant roster holder is a member regardless of stale activity or a `left` — a privatize/ban
1092    // retain set must NEVER silently shed an authorized member (a leave or an old message must not drop a
1093    // current admin; that read-cut would lock a sitting admin out of their own community). Banned is the
1094    // only exclusion (a ban revokes the role anyway). Stamped `now_secs()` so they sort to the top and
1095    // survive the cap. Computed POST-filter so neither the leave filter nor a stale overwrite can cut them.
1096    {
1097        let mut present: std::collections::HashSet<String> = out.iter().map(|(n, _)| n.clone()).collect();
1098        let mut reassert = |npub: String| {
1099            if !banned.contains(&npub) && present.insert(npub.clone()) {
1100                out.push((npub, now_secs() as u64));
1101            }
1102        };
1103        if let Some(o) = owner_b32 {
1104            reassert(o);
1105        }
1106        if let Ok(roles) = get_community_roles(community_id) {
1107            for g in &roles.grants {
1108                if g.role_ids.is_empty() {
1109                    continue; // an empty grant is a revoked role, not a member
1110                }
1111                if let Some(b32) = PublicKey::from_hex(&g.member).ok().and_then(|pk| pk.to_bech32().ok()) {
1112                    reassert(b32);
1113                }
1114            }
1115        }
1116    }
1117    out.sort_by(|a, b| b.1.cmp(&a.1));
1118    out.truncate(COMMUNITY_MEMBER_CAP);
1119    Ok(out)
1120}
1121
1122/// Per-link join counts for the owner's public invites: `label -> distinct joiners` who joined
1123/// via a link minted by `inviter_npub` (bech32). Reads the `invited-by` / `invited-label` tags on
1124/// MemberJoined system events; distinct by joiner npub so a rejoin isn't double-counted. Labels are
1125/// unique per creator (random fallback ensures it), so (inviter, label) keys a single link.
1126pub fn community_invite_join_counts(
1127    community_id: &str,
1128    inviter_npub: &str,
1129) -> Result<std::collections::HashMap<String, u64>, String> {
1130    use std::collections::{HashMap, HashSet};
1131    let community = match load_community(&CommunityId(hex_id_to_32(community_id)?))? {
1132        Some(c) => c,
1133        None => return Ok(HashMap::new()),
1134    };
1135    let mut chat_ints: Vec<i64> = Vec::new();
1136    for ch in &community.channels {
1137        if let Ok(cid) = super::id_cache::get_chat_id_by_identifier(&ch.id.to_hex()) {
1138            chat_ints.push(cid);
1139        }
1140    }
1141    if chat_ints.is_empty() {
1142        return Ok(HashMap::new());
1143    }
1144    let sys = crate::stored_event::event_kind::APPLICATION_SPECIFIC;
1145    let conn = super::get_db_connection_guard_static()?;
1146    let placeholders = chat_ints.iter().map(|_| "?").collect::<Vec<_>>().join(",");
1147    let sql = format!(
1148        "SELECT npub, tags FROM events \
1149         WHERE chat_id IN ({placeholders}) AND kind = {sys} AND npub IS NOT NULL AND npub != ''"
1150    );
1151    let mut stmt = conn.prepare(&sql).map_err(|e| e.to_string())?;
1152    let rows = stmt
1153        .query_map(rusqlite::params_from_iter(chat_ints.iter()), |r| {
1154            Ok((r.get::<_, String>(0)?, r.get::<_, String>(1)?))
1155        })
1156        .map_err(|e| e.to_string())?;
1157    // label -> set of distinct joiner npubs
1158    let mut per_label: HashMap<String, HashSet<String>> = HashMap::new();
1159    for row in rows {
1160        let (joiner, tags_json) = row.map_err(|e| e.to_string())?;
1161        let tags = match serde_json::from_str::<Vec<Vec<String>>>(&tags_json) {
1162            Ok(t) => t,
1163            Err(_) => continue,
1164        };
1165        let tag_val = |key: &str| -> Option<String> {
1166            tags.iter()
1167                .find(|t| t.first().map(|s| s == key).unwrap_or(false))
1168                .and_then(|t| t.get(1).cloned())
1169        };
1170        // MemberJoined (event-type "1") attributed to THIS owner's link, with a label.
1171        if tag_val("event-type").as_deref() != Some("1") {
1172            continue;
1173        }
1174        if tag_val("invited-by").as_deref() != Some(inviter_npub) {
1175            continue;
1176        }
1177        if let Some(label) = tag_val("invited-label") {
1178            per_label.entry(label).or_default().insert(joiner);
1179        }
1180    }
1181    Ok(per_label.into_iter().map(|(k, v)| (k, v.len() as u64)).collect())
1182}
1183
1184/// Replace a Community's stored banlist (JSON array of hex pubkeys) + the `created_at` (secs) of
1185/// the edition it came from. `at` is the version: the owner's own ban/unban writes its freshly
1186/// built event time, and `fetch_and_apply_banlist` only calls this with a strictly-newer edition,
1187/// so the stored banlist can never roll backwards.
1188pub fn set_community_banlist(community_id: &str, banned_hex: &[String], at: i64) -> Result<(), String> {
1189    let json = enc_txt(&serde_json::to_string(banned_hex).map_err(|e| e.to_string())?)?;
1190    let conn = super::get_write_connection_guard_static()?;
1191    conn.execute(
1192        "UPDATE communities SET banlist = ?1, banlist_at = ?2 WHERE community_id = ?3",
1193        params![json, at, community_id],
1194    )
1195    .map_err(|e| format!("set banlist: {e}"))?;
1196    Ok(())
1197}
1198
1199/// The `created_at` (secs) of the banlist edition currently stored, or 0 if none. The version
1200/// floor the rollback guard compares against.
1201pub fn get_community_banlist_at(community_id: &str) -> Result<i64, String> {
1202    let conn = super::get_db_connection_guard_static()?;
1203    let at: Option<i64> = conn
1204        .query_row(
1205            "SELECT banlist_at FROM communities WHERE community_id = ?1",
1206            params![community_id],
1207            |r| r.get(0),
1208        )
1209        .optional()
1210        .map_err(|e| format!("get banlist_at: {e}"))?;
1211    Ok(at.unwrap_or(0))
1212}
1213
1214/// Replace a Community's cached role graph (the aggregated `CommunityRoles`) + the `created_at`
1215/// (secs) of the newest per-entity edition it was built from. `at` is the version floor: the
1216/// fetch path only calls this with a strictly-newer aggregate, so the role graph can't roll
1217/// backwards (same guard as the banlist).
1218pub fn set_community_roles(
1219    community_id: &str,
1220    roles: &crate::community::roles::CommunityRoles,
1221    at: i64,
1222) -> Result<(), String> {
1223    let json = enc_txt(&serde_json::to_string(roles).map_err(|e| e.to_string())?)?;
1224    let conn = super::get_write_connection_guard_static()?;
1225    conn.execute(
1226        "UPDATE communities SET roles = ?1, roles_at = ?2 WHERE community_id = ?3",
1227        params![json, at, community_id],
1228    )
1229    .map_err(|e| format!("set roles: {e}"))?;
1230    Ok(())
1231}
1232
1233/// A Community's cached role graph. Empty (default) for an unknown community or none stored.
1234pub fn get_community_roles(
1235    community_id: &str,
1236) -> Result<crate::community::roles::CommunityRoles, String> {
1237    let conn = super::get_db_connection_guard_static()?;
1238    let json: Option<String> = conn
1239        .query_row(
1240            "SELECT roles FROM communities WHERE community_id = ?1",
1241            params![community_id],
1242            |r| r.get(0),
1243        )
1244        .optional()
1245        .map_err(|e| format!("get roles: {e}"))?;
1246    Ok(json.and_then(|j| serde_json::from_str(&dec_txt(&j)).ok()).unwrap_or_default())
1247}
1248
1249/// The `created_at` (secs) of the role-graph edition currently stored, or 0 if none.
1250pub fn get_community_roles_at(community_id: &str) -> Result<i64, String> {
1251    let conn = super::get_db_connection_guard_static()?;
1252    let at: Option<i64> = conn
1253        .query_row(
1254            "SELECT roles_at FROM communities WHERE community_id = ?1",
1255            params![community_id],
1256            |r| r.get(0),
1257        )
1258        .optional()
1259        .map_err(|e| format!("get roles_at: {e}"))?;
1260    Ok(at.unwrap_or(0))
1261}
1262
1263/// Record the current head (version + self_hash) of a control entity's edition chain (keyless model).
1264/// The send side reads this to emit the next edition as `version+1` citing `self_hash` as `prev_hash`;
1265/// the fold uses it as the per-entity refuse-downgrade floor + anchor. Upserts per (community, entity).
1266/// `inner_id` is the head edition's deterministic tiebreak key (used only by [`converge_edition_head`]).
1267pub fn set_edition_head(community_id: &str, entity_id: &str, version: u64, self_hash: &[u8; 32]) -> Result<(), String> {
1268    set_edition_head_inner(community_id, entity_id, version, self_hash, None, None)
1269}
1270
1271/// As [`set_edition_head`], but also records the head edition's `inner_id` (the deterministic tiebreak
1272/// key), so a later same-version convergence can rank against it. A plain advance carries it through.
1273pub fn set_edition_head_with_id(community_id: &str, entity_id: &str, version: u64, self_hash: &[u8; 32], inner_id: &[u8; 32]) -> Result<(), String> {
1274    set_edition_head_inner(community_id, entity_id, version, self_hash, Some(inner_id), None)
1275}
1276
1277/// As [`set_edition_head_with_id`], stamping an EXPLICIT epoch — the epoch the caller's fold actually
1278/// ran under — instead of reading the community row at write time. Closes the TOCTOU where a
1279/// concurrent re-founding bumps `server_root_epoch` between a fold and its head persist, which would
1280/// stamp an old-plane version as the new epoch's floor and wedge the new epoch's genuine head.
1281pub 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> {
1282    set_edition_head_inner(community_id, entity_id, version, self_hash, Some(inner_id), Some(epoch))
1283}
1284
1285fn 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> {
1286    let conn = super::get_write_connection_guard_static()?;
1287    // MONOTONIC, EPOCH-PRIMARY: the head IS the refuse-downgrade floor. The recorded `epoch` is
1288    // the fold's epoch when given explicitly, else the community's current server-root epoch
1289    // (re-founding bumps it + resets versions to 1). A higher epoch ALWAYS supersedes (so a
1290    // re-founding's v1 lands over a held v21); within an epoch, version still only advances. So a
1291    // stale/hostile rollback can lower neither the epoch nor the in-epoch version.
1292    conn.execute(
1293        "INSERT INTO community_edition_heads (community_id, entity_id, version, self_hash, inner_id, epoch)
1294         VALUES (?1, ?2, ?3, ?4, ?5, COALESCE(?6, (SELECT server_root_epoch FROM communities WHERE community_id = ?1), 0))
1295         ON CONFLICT(community_id, entity_id) DO UPDATE SET
1296            version = excluded.version,
1297            self_hash = excluded.self_hash,
1298            inner_id = excluded.inner_id,
1299            epoch = excluded.epoch
1300         WHERE excluded.epoch > community_edition_heads.epoch
1301            OR (excluded.epoch = community_edition_heads.epoch AND excluded.version > community_edition_heads.version)",
1302        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)],
1303    )
1304    .map_err(|e| format!("set edition head: {e}"))?;
1305    Ok(())
1306}
1307
1308/// Converge the head to a same-version fork winner (concurrent-edit resolution). Unlike
1309/// [`set_edition_head`] (which only ADVANCES the version), this resolves a fork AT the current version:
1310/// two authorized editors editing concurrently from the same base both produce `version`, and every
1311/// client must adopt the SAME one. The winner is the lower deterministic `inner_id`, so this update
1312/// fires only when the incoming edition ties the stored version AND carries a strictly lower `inner_id`
1313/// — monotonic toward the global minimum, so it can never flip-flop (a relay can't churn the head by
1314/// reordering, and a held row with a NULL `inner_id`, pre-migration, is treated as "always replaceable"
1315/// so it heals to a ranked id). The version-advance path is unchanged and still handled by
1316/// [`set_edition_head_with_id`]; callers run BOTH (advance covers v+1, converge covers a same-v fork).
1317pub fn converge_edition_head(community_id: &str, entity_id: &str, version: u64, self_hash: &[u8; 32], inner_id: &[u8; 32]) -> Result<(), String> {
1318    converge_edition_head_inner(community_id, entity_id, version, self_hash, inner_id, None)
1319}
1320
1321/// As [`converge_edition_head`], scoped to an EXPLICIT epoch (the epoch the caller's fold ran under)
1322/// rather than the community row's write-time value — same TOCTOU rationale as
1323/// [`set_edition_head_at_epoch`].
1324pub 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> {
1325    converge_edition_head_inner(community_id, entity_id, version, self_hash, inner_id, Some(epoch))
1326}
1327
1328fn 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> {
1329    let conn = super::get_write_connection_guard_static()?;
1330    // Scoped to the CURRENT epoch's head: a fork is resolved within an epoch, never across one (an epoch
1331    // bump is a re-founding, handled by the advance path). `epoch` matches the community's current epoch.
1332    conn.execute(
1333        "UPDATE community_edition_heads
1334            SET self_hash = ?4, inner_id = ?5
1335          WHERE community_id = ?1 AND entity_id = ?2
1336            AND version = ?3
1337            AND epoch = COALESCE(?6, (SELECT server_root_epoch FROM communities WHERE community_id = ?1), 0)
1338            AND (inner_id IS NULL OR ?5 < inner_id)",
1339        params![community_id, entity_id, version as i64, self_hash.as_slice(), inner_id.as_slice(), epoch.map(|e| e as i64)],
1340    )
1341    .map_err(|e| format!("converge edition head: {e}"))?;
1342    Ok(())
1343}
1344
1345/// The held head's tiebreak key (`inner_id`), or `None` if unheld or pre-migration (NULL). The consumer
1346/// uses this to decide a same-version convergence exactly as [`converge_edition_head`]'s SQL does (a
1347/// NULL/None held id is "always replaceable") — so it never applies a display edit the head write would
1348/// then refuse.
1349pub fn get_edition_head_inner_id(community_id: &str, entity_id: &str) -> Result<Option<[u8; 32]>, String> {
1350    let conn = super::get_db_connection_guard_static()?;
1351    let row: Option<Option<Vec<u8>>> = conn
1352        .query_row(
1353            "SELECT inner_id FROM community_edition_heads WHERE community_id = ?1 AND entity_id = ?2",
1354            params![community_id, entity_id],
1355            |r| r.get(0),
1356        )
1357        .optional()
1358        .map_err(|e| format!("get edition head inner_id: {e}"))?;
1359    match row.flatten() {
1360        Some(blob) if blob.len() == 32 => {
1361            let mut h = [0u8; 32];
1362            h.copy_from_slice(&blob);
1363            Ok(Some(h))
1364        }
1365        _ => Ok(None),
1366    }
1367}
1368
1369/// The current head `(version, self_hash)` of a control entity's edition chain, or `None` if no
1370/// edition is held yet (so the next edition is the genesis, version 1, no prev_hash).
1371pub fn get_edition_head(community_id: &str, entity_id: &str) -> Result<Option<(u64, [u8; 32])>, String> {
1372    let conn = super::get_db_connection_guard_static()?;
1373    let row: Option<(i64, Vec<u8>)> = conn
1374        .query_row(
1375            "SELECT version, self_hash FROM community_edition_heads WHERE community_id = ?1 AND entity_id = ?2",
1376            params![community_id, entity_id],
1377            |r| Ok((r.get(0)?, r.get(1)?)),
1378        )
1379        .optional()
1380        .map_err(|e| format!("get edition head: {e}"))?;
1381    match row {
1382        Some((v, hash)) if hash.len() == 32 => {
1383            let mut h = [0u8; 32];
1384            h.copy_from_slice(&hash);
1385            Ok(Some((v as u64, h)))
1386        }
1387        _ => Ok(None),
1388    }
1389}
1390
1391/// The set of control-entity ids (hex) this account tracks a head for. A base rotation gates its
1392/// head-advance on re-anchoring covering EVERY one of these (not just a matching count), so a relay
1393/// that withholds one entity's editions while over-serving another's can't slip a thinned control
1394/// plane past the rotator.
1395pub fn edition_head_entity_ids(community_id: &str) -> Result<std::collections::HashSet<String>, String> {
1396    let conn = super::get_db_connection_guard_static()?;
1397    let mut stmt = conn
1398        .prepare("SELECT entity_id FROM community_edition_heads WHERE community_id = ?1")
1399        .map_err(|e| e.to_string())?;
1400    let rows = stmt
1401        .query_map(params![community_id], |r| r.get::<_, String>(0))
1402        .map_err(|e| e.to_string())?;
1403    let mut out = std::collections::HashSet::new();
1404    for row in rows {
1405        out.insert(row.map_err(|e| e.to_string())?);
1406    }
1407    Ok(out)
1408}
1409
1410
1411/// Every tracked control entity's persisted head `(entity_id hex → (version, self_hash))`. This is the
1412/// per-entity refuse-downgrade FLOOR: the fold seeds each entity's chain from its held head, so a
1413/// withholding relay serving editions BELOW what we already hold can't roll an authority chain back
1414/// (e.g. resurrecting a since-revoked admin's old grant). An empty map = a bootstrapping joiner (folds
1415/// from genesis, floor 0).
1416pub fn get_all_edition_heads(community_id: &str) -> Result<std::collections::HashMap<String, (u64, [u8; 32])>, String> {
1417    let conn = super::get_db_connection_guard_static()?;
1418    let mut stmt = conn
1419        .prepare("SELECT entity_id, version, self_hash FROM community_edition_heads WHERE community_id = ?1")
1420        .map_err(|e| e.to_string())?;
1421    let rows = stmt
1422        .query_map(params![community_id], |r| {
1423            Ok((r.get::<_, String>(0)?, r.get::<_, i64>(1)?, r.get::<_, Vec<u8>>(2)?))
1424        })
1425        .map_err(|e| e.to_string())?;
1426    let mut out = std::collections::HashMap::new();
1427    for row in rows {
1428        let (entity, version, hash) = row.map_err(|e| e.to_string())?;
1429        if hash.len() == 32 {
1430            let mut h = [0u8; 32];
1431            h.copy_from_slice(&hash);
1432            out.insert(entity, (version as u64, h));
1433        }
1434    }
1435    Ok(out)
1436}
1437
1438/// Every tracked head as `entity_hex → (epoch, version, self_hash)` — the epoch-primary floor.
1439/// The caller seeds the fold with ONLY the entities at the community's CURRENT epoch (a head recorded
1440/// at a PRIOR epoch belongs to a superseded founding, so its entity folds fresh from the new epoch's v1
1441/// genesis). This is what lets a re-founding's compacted v1 plane land without a version-only downgrade.
1442/// Every tracked head as `entity_hex → (epoch, version, self_hash, inner_id)` — the epoch-primary
1443/// floor INCLUDING the deterministic tiebreak key, so a fold can resolve a same-version fork at the
1444/// floor (converge to the lower inner id) instead of wedging on it.
1445pub fn get_all_edition_heads_full(community_id: &str) -> Result<std::collections::HashMap<String, (u64, u64, [u8; 32], Option<[u8; 32]>)>, String> {
1446    let conn = super::get_db_connection_guard_static()?;
1447    let mut stmt = conn
1448        .prepare("SELECT entity_id, epoch, version, self_hash, inner_id FROM community_edition_heads WHERE community_id = ?1")
1449        .map_err(|e| e.to_string())?;
1450    let rows = stmt
1451        .query_map(params![community_id], |r| {
1452            Ok((r.get::<_, String>(0)?, r.get::<_, i64>(1)?, r.get::<_, i64>(2)?, r.get::<_, Vec<u8>>(3)?, r.get::<_, Option<Vec<u8>>>(4)?))
1453        })
1454        .map_err(|e| e.to_string())?;
1455    let mut out = std::collections::HashMap::new();
1456    for row in rows {
1457        let (entity, epoch, version, hash, inner) = row.map_err(|e| e.to_string())?;
1458        if hash.len() == 32 {
1459            let mut h = [0u8; 32];
1460            h.copy_from_slice(&hash);
1461            let inner_id = inner.and_then(|b| {
1462                (b.len() == 32).then(|| {
1463                    let mut i = [0u8; 32];
1464                    i.copy_from_slice(&b);
1465                    i
1466                })
1467            });
1468            out.insert(entity, (epoch as u64, version as u64, h, inner_id));
1469        }
1470    }
1471    Ok(out)
1472}
1473
1474pub fn get_all_edition_heads_epoched(community_id: &str) -> Result<std::collections::HashMap<String, (u64, u64, [u8; 32])>, String> {
1475    let conn = super::get_db_connection_guard_static()?;
1476    let mut stmt = conn
1477        .prepare("SELECT entity_id, epoch, version, self_hash FROM community_edition_heads WHERE community_id = ?1")
1478        .map_err(|e| e.to_string())?;
1479    let rows = stmt
1480        .query_map(params![community_id], |r| {
1481            Ok((r.get::<_, String>(0)?, r.get::<_, i64>(1)?, r.get::<_, i64>(2)?, r.get::<_, Vec<u8>>(3)?))
1482        })
1483        .map_err(|e| e.to_string())?;
1484    let mut out = std::collections::HashMap::new();
1485    for row in rows {
1486        let (entity, epoch, version, hash) = row.map_err(|e| e.to_string())?;
1487        if hash.len() == 32 {
1488            let mut h = [0u8; 32];
1489            h.copy_from_slice(&hash);
1490            out.insert(entity, (epoch as u64, version as u64, h));
1491        }
1492    }
1493    Ok(out)
1494}
1495
1496/// A Community's current banlist (hex pubkeys). Empty for an unknown community or empty list.
1497pub fn get_community_banlist(community_id: &str) -> Result<Vec<String>, String> {
1498    let conn = super::get_db_connection_guard_static()?;
1499    let json: Option<String> = conn
1500        .query_row(
1501            "SELECT banlist FROM communities WHERE community_id = ?1",
1502            params![community_id],
1503            |r| r.get(0),
1504        )
1505        .optional()
1506        .map_err(|e| format!("get banlist: {e}"))?;
1507    Ok(json.and_then(|j| serde_json::from_str(&dec_txt(&j)).ok()).unwrap_or_default())
1508}
1509
1510/// Replace a Community's cached invite-link registry (active link locators, hex), folded from the
1511/// owner-signed vsk=5 edition. Empty = Private. The version floor lives in `community_edition_heads`
1512/// (the registry's own entity), so this is just the content cache (mirrors `set_community_banlist`).
1513pub fn set_community_invite_registry(community_id: &str, link_locators: &[String]) -> Result<(), String> {
1514    let json = enc_txt(&serde_json::to_string(link_locators).map_err(|e| e.to_string())?)?;
1515    let conn = super::get_write_connection_guard_static()?;
1516    conn.execute(
1517        "UPDATE communities SET invite_registry = ?1 WHERE community_id = ?2",
1518        params![json, community_id],
1519    )
1520    .map_err(|e| format!("set invite registry: {e}"))?;
1521    Ok(())
1522}
1523
1524/// A Community's current invite-link registry (active link locators, hex). Empty for an unknown
1525/// community or a Private one. `is_public` = this is non-empty (computed mode).
1526pub fn get_community_invite_registry(community_id: &str) -> Result<Vec<String>, String> {
1527    let conn = super::get_db_connection_guard_static()?;
1528    let json: Option<String> = conn
1529        .query_row(
1530            "SELECT invite_registry FROM communities WHERE community_id = ?1",
1531            params![community_id],
1532            |r| r.get(0),
1533        )
1534        .optional()
1535        .map_err(|e| format!("get invite registry: {e}"))?;
1536    Ok(json.and_then(|j| serde_json::from_str(&dec_txt(&j)).ok()).unwrap_or_default())
1537}
1538
1539/// A folded per-creator public-invite-link set: the creator's pubkey (hex) and their active link
1540/// locators. Used to surface "X has N active invite links" in the UI.
1541pub struct InviteLinkSetRow {
1542    pub creator_hex: String,
1543    pub locators: Vec<String>,
1544}
1545
1546/// Replace ALL of a Community's per-creator invite-link sets with the freshly-folded set (latest-wins).
1547/// Replacing wholesale (not upserting) drops a creator who has revoked every link, so the per-creator
1548/// view stays in lockstep with the flat registry computed in the same fold.
1549pub fn replace_invite_link_sets(community_id: &str, sets: &[InviteLinkSetRow]) -> Result<(), String> {
1550    let mut conn = super::get_write_connection_guard_static()?;
1551    let tx = conn.transaction().map_err(|e| format!("invite-link-sets tx: {e}"))?;
1552    tx.execute("DELETE FROM community_invite_link_sets WHERE community_id = ?1", params![community_id])
1553        .map_err(|e| format!("clear invite-link-sets: {e}"))?;
1554    for s in sets {
1555        if s.locators.is_empty() {
1556            continue; // a creator with no active links is just absent (count 0)
1557        }
1558        let enc_creator = enc_txt(&s.creator_hex)?;
1559        let enc_locators = enc_txt(&serde_json::to_string(&s.locators).map_err(|e| e.to_string())?)?;
1560        // Plain INSERT: the DELETE above cleared the community's rows and `sets` has distinct creators
1561        // (an encrypted creator can't act as a dedup key anyway — random nonce per write).
1562        tx.execute(
1563            "INSERT INTO community_invite_link_sets (community_id, creator, locators) VALUES (?1, ?2, ?3)",
1564            params![community_id, enc_creator, enc_locators],
1565        )
1566        .map_err(|e| format!("insert invite-link-set: {e}"))?;
1567    }
1568    tx.commit().map_err(|e| format!("commit invite-link-sets: {e}"))?;
1569    Ok(())
1570}
1571
1572/// Upsert ONE creator's invite-link set (optimistic local update after the local user mints/revokes their
1573/// own links, mirroring the flat-registry merge). An empty set removes the row.
1574pub fn upsert_invite_link_set(community_id: &str, creator_hex: &str, locators: &[String]) -> Result<(), String> {
1575    let conn = super::get_write_connection_guard_static()?;
1576    // `creator` is encrypted (random nonce), so locate any existing row by decrypting + matching.
1577    let existing_rowid: Option<i64> = {
1578        let mut stmt = conn
1579            .prepare("SELECT rowid, creator FROM community_invite_link_sets WHERE community_id = ?1")
1580            .map_err(|e| e.to_string())?;
1581        let rows = stmt
1582            .query_map(params![community_id], |r| Ok((r.get::<_, i64>(0)?, r.get::<_, String>(1)?)))
1583            .map_err(|e| e.to_string())?;
1584        let mut found = None;
1585        for row in rows {
1586            let (rowid, stored) = row.map_err(|e| e.to_string())?;
1587            if dec_txt(&stored) == creator_hex {
1588                found = Some(rowid);
1589                break;
1590            }
1591        }
1592        found
1593    };
1594    if locators.is_empty() {
1595        if let Some(rowid) = existing_rowid {
1596            conn.execute("DELETE FROM community_invite_link_sets WHERE rowid = ?1", params![rowid])
1597                .map_err(|e| format!("delete invite-link-set: {e}"))?;
1598        }
1599        return Ok(());
1600    }
1601    let enc_locators = enc_txt(&serde_json::to_string(locators).map_err(|e| e.to_string())?)?;
1602    match existing_rowid {
1603        Some(rowid) => {
1604            conn.execute(
1605                "UPDATE community_invite_link_sets SET locators = ?1 WHERE rowid = ?2",
1606                params![enc_locators, rowid],
1607            )
1608            .map_err(|e| format!("upsert invite-link-set: {e}"))?;
1609        }
1610        None => {
1611            let enc_creator = enc_txt(creator_hex)?;
1612            conn.execute(
1613                "INSERT INTO community_invite_link_sets (community_id, creator, locators) VALUES (?1, ?2, ?3)",
1614                params![community_id, enc_creator, enc_locators],
1615            )
1616            .map_err(|e| format!("upsert invite-link-set: {e}"))?;
1617        }
1618    }
1619    Ok(())
1620}
1621
1622/// Every creator's active invite-link set for a Community (creator hex + locators). Empty for a Private
1623/// community (or one not yet re-folded since this table was added).
1624pub fn get_invite_link_sets(community_id: &str) -> Result<Vec<InviteLinkSetRow>, String> {
1625    let conn = super::get_db_connection_guard_static()?;
1626    let mut stmt = conn
1627        .prepare("SELECT creator, locators FROM community_invite_link_sets WHERE community_id = ?1")
1628        .map_err(|e| format!("prepare invite-link-sets: {e}"))?;
1629    let rows = stmt
1630        .query_map(params![community_id], |r| {
1631            let creator_hex: String = r.get(0)?;
1632            let json: String = r.get(1)?;
1633            Ok((creator_hex, json))
1634        })
1635        .map_err(|e| format!("query invite-link-sets: {e}"))?;
1636    let mut out = Vec::new();
1637    for row in rows {
1638        let (creator_hex, json) = row.map_err(|e| format!("row invite-link-sets: {e}"))?;
1639        let locators: Vec<String> = serde_json::from_str(&dec_txt(&json)).unwrap_or_default();
1640        out.push(InviteLinkSetRow { creator_hex: dec_txt(&creator_hex), locators });
1641    }
1642    Ok(out)
1643}
1644
1645/// Mark (or clear) that a PRIVATE-community ban's base re-seal (read-cut) is OUTSTANDING — set when
1646/// the re-seal is attempted and cleared only when it succeeds, so a transient failure is retried later
1647/// instead of silently leaving a banned member with read access.
1648pub fn set_read_cut_pending(community_id: &str, pending: bool) -> Result<(), String> {
1649    let conn = super::get_write_connection_guard_static()?;
1650    conn.execute(
1651        "UPDATE communities SET read_cut_pending = ?1 WHERE community_id = ?2",
1652        params![pending as i64, community_id],
1653    )
1654    .map_err(|e| format!("set read_cut_pending: {e}"))?;
1655    Ok(())
1656}
1657
1658/// Set the owner-dissolution SEAL on a community — PERMANENT + irreversible (no clear path; there
1659/// is no un-dissolve). Idempotent: re-setting an already-dissolved community is a harmless no-op. Once
1660/// set, the control fold stops advancing and the inbound path drops every subsequent event.
1661/// Seal a community as dissolved. Returns whether this call TRANSITIONED it (a
1662/// live→dissolved flip) so the caller can fire the one-time death notification
1663/// exactly once — a re-wrapped tombstone (fresh outer id, same owner seal) then
1664/// can't spam the handler.
1665pub fn set_community_dissolved(community_id: &str) -> Result<bool, String> {
1666    let conn = super::get_write_connection_guard_static()?;
1667    let changed = conn
1668        .execute(
1669            "UPDATE communities SET dissolved = 1 WHERE community_id = ?1 AND dissolved = 0",
1670            params![community_id],
1671        )
1672        .map_err(|e| format!("set dissolved: {e}"))?;
1673    Ok(changed > 0)
1674}
1675
1676/// Whether a community has been sealed by a folded + owner-verified GroupDissolved tombstone.
1677/// `false` for an unknown community.
1678pub fn get_community_dissolved(community_id: &str) -> Result<bool, String> {
1679    let conn = super::get_db_connection_guard_static()?;
1680    let v: Option<i64> = conn
1681        .query_row(
1682            "SELECT dissolved FROM communities WHERE community_id = ?1",
1683            params![community_id],
1684            |r| r.get(0),
1685        )
1686        .optional()
1687        .map_err(|e| format!("get dissolved: {e}"))?;
1688    Ok(v.unwrap_or(0) != 0)
1689}
1690
1691/// Whether a PRIVATE-community read-cut re-seal is still outstanding (a prior attempt failed). The ban
1692/// flow retries the re-seal whenever this is set. `false` for an unknown community.
1693pub fn get_read_cut_pending(community_id: &str) -> Result<bool, String> {
1694    let conn = super::get_db_connection_guard_static()?;
1695    let v: Option<i64> = conn
1696        .query_row(
1697            "SELECT read_cut_pending FROM communities WHERE community_id = ?1",
1698            params![community_id],
1699            |r| r.get(0),
1700        )
1701        .optional()
1702        .map_err(|e| format!("get read_cut_pending: {e}"))?;
1703    Ok(v.unwrap_or(0) != 0)
1704}
1705
1706/// Set the base epoch a pending read-cut (re-founding) must reach. The re-seal rotates the base only
1707/// while `server_root_epoch < target`, so a retry never double-rotates a base that already advanced. Set
1708/// to `server_root_epoch + 1` on a fresh exclusion delta (ban add / privatize); left untouched on a pure
1709/// resume so the in-flight target is preserved.
1710pub fn set_read_cut_target_epoch(community_id: &str, target: u64) -> Result<(), String> {
1711    let conn = super::get_write_connection_guard_static()?;
1712    conn.execute(
1713        "UPDATE communities SET read_cut_target_epoch = ?1 WHERE community_id = ?2",
1714        params![target as i64, community_id],
1715    )
1716    .map_err(|e| format!("set read_cut_target_epoch: {e}"))?;
1717    Ok(())
1718}
1719
1720/// The base epoch a pending read-cut must reach (see [`set_read_cut_target_epoch`]). `0` for an unknown
1721/// community. Reinterpreted i64->u64 (lossless) for epochs >= 2^63.
1722pub fn get_read_cut_target_epoch(community_id: &str) -> Result<u64, String> {
1723    let conn = super::get_db_connection_guard_static()?;
1724    let v: Option<i64> = conn
1725        .query_row(
1726            "SELECT read_cut_target_epoch FROM communities WHERE community_id = ?1",
1727            params![community_id],
1728            |r| r.get(0),
1729        )
1730        .optional()
1731        .map_err(|e| format!("get read_cut_target_epoch: {e}"))?;
1732    Ok(v.unwrap_or(0) as u64)
1733}
1734
1735/// The base (server-root) epoch a channel was last rekeyed FOR during a read-cut — the per-channel
1736/// progress marker that lets a resumed re-founding skip channels already cut. `0` if unknown.
1737pub fn channel_rekeyed_at_server_epoch(community_id: &str, channel_id: &str) -> Result<u64, String> {
1738    let conn = super::get_db_connection_guard_static()?;
1739    let v: Option<i64> = conn
1740        .query_row(
1741            "SELECT rekeyed_at_server_epoch FROM community_channels WHERE community_id = ?1 AND channel_id = ?2",
1742            params![community_id, channel_id],
1743            |r| r.get(0),
1744        )
1745        .optional()
1746        .map_err(|e| format!("get rekeyed_at_server_epoch: {e}"))?;
1747    Ok(v.unwrap_or(0) as u64)
1748}
1749
1750/// Record that a channel's key has been rotated to cover base epoch `server_epoch` (a read-cut step).
1751/// Best-effort progress marker: written after the channel rekey lands, so a crash before it just re-rotates
1752/// the channel on resume (safe, the rekey is monotonic) rather than skipping a channel that needed cutting.
1753pub fn mark_channel_rekeyed_at_server_epoch(community_id: &str, channel_id: &str, server_epoch: u64) -> Result<(), String> {
1754    let conn = super::get_write_connection_guard_static()?;
1755    conn.execute(
1756        "UPDATE community_channels SET rekeyed_at_server_epoch = ?1 WHERE community_id = ?2 AND channel_id = ?3",
1757        params![server_epoch as i64, community_id, channel_id],
1758    )
1759    .map_err(|e| format!("mark rekeyed_at_server_epoch: {e}"))?;
1760    Ok(())
1761}
1762
1763/// Ids of every locally-stored Community.
1764pub fn list_community_ids() -> Result<Vec<CommunityId>, String> {
1765    let conn = super::get_db_connection_guard_static()?;
1766    let mut stmt = conn
1767        .prepare("SELECT community_id FROM communities ORDER BY created_at")
1768        .map_err(|e| e.to_string())?;
1769    let rows = stmt
1770        .query_map([], |r| r.get::<_, String>(0))
1771        .map_err(|e| e.to_string())?;
1772    let mut ids = Vec::new();
1773    for row in rows {
1774        ids.push(CommunityId(hex_id_to_32(&row.map_err(|e| e.to_string())?)?));
1775    }
1776    Ok(ids)
1777}
1778
1779// ── Concord v2 storage (dual-stack) ──────────────────────────────────────────
1780//
1781// v2 communities reuse the shared community tables (migration 65 added the
1782// `protocol`/`owner_pubkey`/`owner_salt`/`private` columns). The base access key
1783// rides `server_root_key`/`server_root_epoch` (same role as v1's server root).
1784// A public channel stores the community_root in `channel_key` as a placeholder
1785// (its real secret is derived from the root); a private channel stores its own
1786// key. At-rest encryption reuses the same `enc_*`/`dec_*` helpers.
1787
1788/// The protocol a stored community runs, or `None` if it isn't held locally.
1789pub fn community_protocol(id: &CommunityId) -> Result<Option<crate::community::ConcordProtocol>, String> {
1790    let conn = super::get_db_connection_guard_static()?;
1791    let n: Option<i64> = conn
1792        .query_row("SELECT protocol FROM communities WHERE community_id = ?1", params![id.to_hex()], |r| r.get(0))
1793        .optional()
1794        .map_err(|e| e.to_string())?;
1795    Ok(n.map(crate::community::ConcordProtocol::from_i64))
1796}
1797
1798/// Persist a v2 community + its channels atomically. UPSERT so a metadata
1799/// re-save preserves banlist/roles (managed by the fold, not here).
1800pub fn save_community_v2(c: &crate::community::v2::community::CommunityV2) -> Result<(), String> {
1801    let conn = super::get_write_connection_guard_static()?;
1802    let id_hex = crate::simd::hex::bytes_to_hex_32(&c.identity.community_id.0);
1803    let relays_json = serde_json::to_string(&c.relays).map_err(|e| e.to_string())?;
1804    let created = (c.created_at_ms / 1000) as i64;
1805
1806    let enc_root = enc_key(&c.community_root)?;
1807    let enc_name = enc_txt(&c.name)?;
1808    let enc_relays = enc_txt(&relays_json)?;
1809    let enc_desc = enc_txt_opt(&c.description)?;
1810    let enc_owner_pk = enc_txt(&crate::simd::hex::bytes_to_hex_32(&c.identity.owner_xonly))?;
1811    let enc_owner_salt = enc_txt(&crate::simd::hex::bytes_to_hex_32(&c.identity.owner_salt))?;
1812
1813    let tx = conn.unchecked_transaction().map_err(|e| format!("save v2 community tx: {e}"))?;
1814    tx.execute(
1815        "INSERT INTO communities
1816            (community_id, server_root_key, name, relays, created_at, description,
1817             server_root_epoch, dissolved, protocol, owner_pubkey, owner_salt)
1818         VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, 2, ?9, ?10)
1819         ON CONFLICT(community_id) DO UPDATE SET
1820            server_root_key=?2, name=?3, relays=?4, description=?6,
1821            server_root_epoch=?7, dissolved=?8, protocol=2, owner_pubkey=?9, owner_salt=?10",
1822        params![
1823            id_hex, enc_root, enc_name, enc_relays, created, enc_desc,
1824            c.root_epoch.0 as i64, c.dissolved as i64, enc_owner_pk, enc_owner_salt,
1825        ],
1826    )
1827    .map_err(|e| format!("save v2 community: {e}"))?;
1828
1829    for ch in &c.channels {
1830        let ch_hex = crate::simd::hex::bytes_to_hex_32(&ch.id.0);
1831        // channel_id is the sole PRIMARY KEY, so an UPSERT keyed on it alone would
1832        // let a bundle reusing ANOTHER community's channel_id overwrite that row's
1833        // key/epoch/private in place (a chat-plane hijack). Channel ids are random-32
1834        // (a genuine cross-community collision is negligible), so refuse rather than
1835        // clobber a foreign community's row.
1836        let owner_of: Option<String> = tx
1837            .query_row("SELECT community_id FROM community_channels WHERE channel_id=?1", params![ch_hex], |r| r.get(0))
1838            .optional()
1839            .map_err(|e| format!("channel ownership check: {e}"))?;
1840        if owner_of.is_some_and(|existing| existing != id_hex) {
1841            // SKIP the foreign-owned channel rather than fail the whole save: a
1842            // single replayed phantom (a same-owner cross-community vsk-2 edition)
1843            // would otherwise wedge ALL of this community's control-plane persistence
1844            // on every fold. The foreign row stays untouched; this community just
1845            // never acquires a row for that id.
1846            continue;
1847        }
1848        // A public channel has no independent key; store the community_root as a
1849        // placeholder so the NOT NULL column is satisfied (the real secret is
1850        // derived from the root at read time via `channel_secret`).
1851        let stored_key = ch.key.unwrap_or(c.community_root);
1852        let enc_ch_key = enc_key(&stored_key)?;
1853        let enc_ch_name = enc_txt(&ch.name)?;
1854        tx.execute(
1855            "INSERT INTO community_channels
1856                (channel_id, community_id, channel_key, epoch, name, created_at, private)
1857             VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)
1858             ON CONFLICT(channel_id) DO UPDATE SET
1859                channel_key=?3, epoch=?4, name=?5, private=?7",
1860            params![ch_hex, id_hex, enc_ch_key, ch.epoch.0 as i64, enc_ch_name, created, ch.private as i64],
1861        )
1862        .map_err(|e| format!("save v2 channel: {e}"))?;
1863    }
1864
1865    // Prune channels no longer in the in-memory set — the persisted set is
1866    // authoritative, so a control-follow delete or a rekey removal doesn't
1867    // resurrect (with a stale key) on the next reload. No FK references
1868    // community_channels, so this cascades to nothing.
1869    let keep: Vec<String> = c.channels.iter().map(|ch| crate::simd::hex::bytes_to_hex_32(&ch.id.0)).collect();
1870    if keep.is_empty() {
1871        tx.execute("DELETE FROM community_channels WHERE community_id=?1", params![id_hex])
1872            .map_err(|e| format!("prune v2 channels: {e}"))?;
1873    } else {
1874        let placeholders = std::iter::repeat("?").take(keep.len()).collect::<Vec<_>>().join(",");
1875        let sql = format!("DELETE FROM community_channels WHERE community_id=? AND channel_id NOT IN ({placeholders})");
1876        let mut binds: Vec<String> = Vec::with_capacity(keep.len() + 1);
1877        binds.push(id_hex.clone());
1878        binds.extend(keep);
1879        tx.execute(&sql, rusqlite::params_from_iter(binds.iter()))
1880            .map_err(|e| format!("prune v2 channels: {e}"))?;
1881    }
1882
1883    tx.commit().map_err(|e| format!("commit v2 community: {e}"))?;
1884    Ok(())
1885}
1886
1887/// Load a v2 community by id, or `None` if absent / not a v2 community.
1888pub fn load_community_v2(id: &CommunityId) -> Result<Option<crate::community::v2::community::CommunityV2>, String> {
1889    use crate::community::v2::community::{ChannelV2, CommunityV2};
1890    use crate::community::v2::control::CommunityIdentity;
1891    let conn = super::get_db_connection_guard_static()?;
1892    let id_hex = id.to_hex();
1893
1894    let row = conn
1895        .query_row(
1896            "SELECT server_root_key, name, relays, created_at, description,
1897                    server_root_epoch, dissolved, protocol, owner_pubkey, owner_salt
1898             FROM communities WHERE community_id = ?1",
1899            params![id_hex],
1900            |r| {
1901                Ok((
1902                    r.get::<_, Vec<u8>>(0)?,
1903                    r.get::<_, String>(1)?,
1904                    r.get::<_, String>(2)?,
1905                    r.get::<_, i64>(3)?,
1906                    r.get::<_, Option<String>>(4)?,
1907                    r.get::<_, i64>(5)?,
1908                    r.get::<_, i64>(6)?,
1909                    r.get::<_, i64>(7)?,
1910                    r.get::<_, Option<String>>(8)?,
1911                    r.get::<_, Option<String>>(9)?,
1912                ))
1913            },
1914        )
1915        .optional()
1916        .map_err(|e| e.to_string())?;
1917    let Some((root_blob, name_e, relays_e, created, desc_e, root_epoch, dissolved, protocol, owner_pk_e, owner_salt_e)) = row
1918    else {
1919        return Ok(None);
1920    };
1921    if crate::community::ConcordProtocol::from_i64(protocol) != crate::community::ConcordProtocol::V2 {
1922        return Ok(None);
1923    }
1924    let (Some(owner_pk_e), Some(owner_salt_e)) = (owner_pk_e, owner_salt_e) else {
1925        return Err("v2 community row is missing its owner commitment".to_string());
1926    };
1927
1928    let community_root = dec_key(&root_blob)?;
1929    let owner_xonly = parse_hex32(&dec_txt(&owner_pk_e))?;
1930    let owner_salt = parse_hex32(&dec_txt(&owner_salt_e))?;
1931    let identity = CommunityIdentity { community_id: *id, owner_xonly, owner_salt };
1932    let relays: Vec<String> = serde_json::from_str(&dec_txt(&relays_e)).unwrap_or_default();
1933
1934    let mut channels = Vec::new();
1935    {
1936        let mut stmt = conn
1937            .prepare(
1938                "SELECT channel_id, channel_key, epoch, name, private
1939                 FROM community_channels WHERE community_id = ?1 ORDER BY created_at",
1940            )
1941            .map_err(|e| e.to_string())?;
1942        let rows = stmt
1943            .query_map(params![id_hex], |r| {
1944                Ok((
1945                    r.get::<_, String>(0)?,
1946                    r.get::<_, Vec<u8>>(1)?,
1947                    r.get::<_, i64>(2)?,
1948                    r.get::<_, String>(3)?,
1949                    r.get::<_, i64>(4)?,
1950                ))
1951            })
1952            .map_err(|e| e.to_string())?;
1953        for row in rows {
1954            let (ch_hex, key_blob, epoch, name_e, private) = row.map_err(|e| e.to_string())?;
1955            let private = private != 0;
1956            let key = dec_key(&key_blob)?;
1957            channels.push(ChannelV2 {
1958                id: ChannelId(hex_id_to_32(&ch_hex)?),
1959                name: dec_txt(&name_e),
1960                private,
1961                // A public channel derives from the root — drop the placeholder. A
1962                // PRIVATE channel stored with the root value is the KEYLESS placeholder
1963                // (key not yet delivered over the rekey plane): a real private key is
1964                // independently random (CORD-03 §1), never the root, so reconstruct
1965                // None and keep every read/send path behind the keyless guards instead
1966                // of silently addressing the public plane.
1967                key: (private && key != community_root).then_some(key),
1968                epoch: Epoch(epoch as u64),
1969            });
1970        }
1971    }
1972
1973    Ok(Some(CommunityV2 {
1974        identity,
1975        community_root,
1976        root_epoch: Epoch(root_epoch as u64),
1977        name: dec_txt(&name_e),
1978        description: desc_e.map(|d| dec_txt(&d)),
1979        relays,
1980        channels,
1981        dissolved: dissolved != 0,
1982        created_at_ms: (created as u64).saturating_mul(1000),
1983    }))
1984}
1985
1986fn parse_hex32(hex: &str) -> Result<[u8; 32], String> {
1987    if hex.len() != 64 || !hex.bytes().all(|b| b.is_ascii_hexdigit()) {
1988        return Err("stored value is not 32-byte hex".to_string());
1989    }
1990    Ok(crate::simd::hex::hex_to_bytes_32(hex))
1991}
1992
1993#[cfg(test)]
1994mod tests {
1995    use super::*;
1996
1997    static TEST_COUNTER: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(0);
1998
1999    /// A unique, syntactically-valid test npub per call (bech32 charset, correct
2000    /// length). Uniqueness isolates each test's account DB so state can't bleed.
2001    fn make_test_npub(n: u32) -> String {
2002        const BECH32: &[u8] = b"qpzry9x8gf2tvdw0s3jn54khce6mua7l";
2003        let mut payload = vec![b'q'; 58];
2004        let mut x = n as u64;
2005        let mut i = 58;
2006        while x > 0 && i > 0 {
2007            i -= 1;
2008            payload[i] = BECH32[(x as usize) % 32];
2009            x /= 32;
2010        }
2011        format!("npub1{}", std::str::from_utf8(&payload).unwrap())
2012    }
2013
2014    fn init_test_db() -> (tempfile::TempDir, std::sync::MutexGuard<'static, ()>) {
2015        let guard = crate::db::DB_TEST_GUARD.lock().unwrap_or_else(|e| e.into_inner());
2016        crate::db::close_database();
2017        // Per-account row-id caches survive close_database; clear them so a stale entry from a prior
2018        // test's DB can't point into this fresh account's DB and FK-fail an insert.
2019        crate::db::clear_id_caches();
2020        let tmp = tempfile::tempdir().unwrap();
2021        let n = TEST_COUNTER.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
2022        let account = make_test_npub(n);
2023        std::fs::create_dir_all(tmp.path().join(&account)).unwrap();
2024        crate::db::set_app_data_dir(tmp.path().to_path_buf());
2025        crate::db::set_current_account(account.clone()).unwrap();
2026        crate::db::init_database(&account).unwrap();
2027        (tmp, guard)
2028    }
2029
2030    #[test]
2031    fn edition_head_round_trips_and_upserts() {
2032        let (_tmp, _guard) = init_test_db();
2033        let cid = "f".repeat(64);
2034        let entity = "a".repeat(64);
2035
2036        // No head yet → None (the next edition is genesis v1).
2037        assert_eq!(get_edition_head(&cid, &entity).unwrap(), None);
2038
2039        // Set v1, read it back exactly.
2040        let h1 = [0x11u8; 32];
2041        set_edition_head(&cid, &entity, 1, &h1).unwrap();
2042        assert_eq!(get_edition_head(&cid, &entity).unwrap(), Some((1, h1)));
2043
2044        // Upsert to v2 — the head advances in place (one row per (community, entity)).
2045        let h2 = [0x22u8; 32];
2046        set_edition_head(&cid, &entity, 2, &h2).unwrap();
2047        assert_eq!(get_edition_head(&cid, &entity).unwrap(), Some((2, h2)));
2048
2049        // MONOTONIC: a lower-or-equal version write is a no-op — the refuse-downgrade floor never
2050        // rolls back, even against a stale or hostile rollback attempt.
2051        set_edition_head(&cid, &entity, 1, &[0xEEu8; 32]).unwrap();
2052        assert_eq!(get_edition_head(&cid, &entity).unwrap(), Some((2, h2)), "rollback to v1 ignored");
2053        set_edition_head(&cid, &entity, 2, &[0xEEu8; 32]).unwrap();
2054        assert_eq!(get_edition_head(&cid, &entity).unwrap(), Some((2, h2)), "equal version is a no-op too");
2055
2056        // A different entity is tracked independently.
2057        let other = "b".repeat(64);
2058        assert_eq!(get_edition_head(&cid, &other).unwrap(), None);
2059    }
2060
2061    #[test]
2062    fn server_root_epoch_round_trips() {
2063        // The base read clock survives save/load (default 0; a rotated value preserved exactly).
2064        let (_tmp, _guard) = init_test_db();
2065        let mut c = Community::create("HQ", "general", vec![]);
2066        save_community(&c).unwrap();
2067        assert_eq!(load_community(&c.id).unwrap().unwrap().server_root_epoch, Epoch(0));
2068
2069        c.server_root_epoch = Epoch(5);
2070        c.server_root_key = ServerRootKey([0x42u8; 32]);
2071        save_community(&c).unwrap();
2072        let loaded = load_community(&c.id).unwrap().unwrap();
2073        assert_eq!(loaded.server_root_epoch, Epoch(5));
2074        assert_eq!(loaded.server_root_key.as_bytes(), &[0x42u8; 32]);
2075    }
2076
2077    #[test]
2078    fn epoch_key_archive_retains_every_epoch() {
2079        // a member who lived through a rotation must keep OLD epoch keys. Storing a new
2080        // epoch's key must NOT clobber a prior one (the data-loss bug the archive fixes).
2081        let (_tmp, _guard) = init_test_db();
2082        let cid = "f".repeat(64);
2083        let scope = "a".repeat(64);
2084
2085        store_epoch_key(&cid, &scope, 0, &[0xA0u8; 32]).unwrap();
2086        store_epoch_key(&cid, &scope, 1, &[0xA1u8; 32]).unwrap();
2087        store_epoch_key(&cid, &scope, 2, &[0xA2u8; 32]).unwrap();
2088
2089        let held = held_epoch_keys(&cid, &scope).unwrap();
2090        assert_eq!(held.len(), 3, "all three epoch keys retained");
2091        assert_eq!(held[0], (Epoch(0), [0xA0u8; 32]));
2092        assert_eq!(held[1], (Epoch(1), [0xA1u8; 32]));
2093        assert_eq!(held[2], (Epoch(2), [0xA2u8; 32]));
2094
2095        // Point lookup by epoch (what the open path uses to select a decryption key).
2096        assert_eq!(held_epoch_key(&cid, &scope, 1).unwrap(), Some([0xA1u8; 32]));
2097        assert_eq!(held_epoch_key(&cid, &scope, 9).unwrap(), None, "unheld epoch is None");
2098
2099        // Same coordinate REPLACE = fork-resolution committing a winning key (only legit overwrite).
2100        store_epoch_key(&cid, &scope, 1, &[0xBBu8; 32]).unwrap();
2101        assert_eq!(held_epoch_key(&cid, &scope, 1).unwrap(), Some([0xBBu8; 32]));
2102        assert_eq!(held_epoch_keys(&cid, &scope).unwrap().len(), 3, "replace didn't add a row");
2103
2104        // A different scope is isolated (server-root vs a channel share the table, never collide) —
2105        // at both the list AND the point-lookup level.
2106        assert!(held_epoch_keys(&cid, crate::community::SERVER_ROOT_SCOPE_HEX).unwrap().is_empty());
2107        assert_eq!(
2108            held_epoch_key(&cid, crate::community::SERVER_ROOT_SCOPE_HEX, 1).unwrap(),
2109            None,
2110            "epoch 1 under a different scope is not the channel's key"
2111        );
2112    }
2113
2114    #[test]
2115    fn save_community_populates_the_epoch_archive() {
2116        // save_community mirrors the current base + channel keys into the multi-held archive, so the
2117        // foundation is live without any explicit store_epoch_key call by the caller.
2118        let (_tmp, _guard) = init_test_db();
2119        let c = Community::create("HQ", "general", vec![]);
2120        save_community(&c).unwrap();
2121        let cid = c.id.to_hex();
2122
2123        // Base key archived under the server-root sentinel at epoch 0.
2124        assert_eq!(
2125            held_epoch_key(&cid, crate::community::SERVER_ROOT_SCOPE_HEX, 0).unwrap().as_ref(),
2126            Some(c.server_root_key.as_bytes())
2127        );
2128        // The default channel's key archived under its channel id at epoch 0.
2129        let chan = &c.channels[0];
2130        assert_eq!(
2131            held_epoch_key(&cid, &chan.id.to_hex(), 0).unwrap().as_ref(),
2132            Some(chan.key.as_bytes())
2133        );
2134    }
2135
2136    #[test]
2137    fn at_rest_encryption_wraps_keys_and_metadata_on_disk() {
2138        let (_tmp, _guard) = init_test_db();
2139        // Local Encryption ON with a known vault key (the db-test guard serializes, so toggling these
2140        // globals is safe; reset at the end). `others: &[]` — the slice only allocates a vault lane.
2141        crate::state::ENCRYPTION_KEY.set([0x55u8; 32], &[]);
2142        crate::state::set_encryption_enabled(true);
2143
2144        let mut c = Community::create("Secret HQ", "general", vec!["wss://relay.example".into()]);
2145        c.server_root_key = ServerRootKey([0x42u8; 32]);
2146        c.description = Some("top secret".into());
2147        save_community(&c).unwrap();
2148        let cid = c.id.to_hex();
2149        set_community_banlist(&cid, &["deadbeef".repeat(8)], 1).unwrap();
2150
2151        // On disk: secrets are 60-byte ciphertext (12 nonce + 32 + 16 tag), NOT raw 32-byte keys;
2152        // identifying text is hex ciphertext, never the plaintext.
2153        {
2154            let conn = crate::db::get_db_connection_guard_static().unwrap();
2155            let (root_len, name, banlist): (i64, String, String) = conn
2156                .query_row(
2157                    "SELECT length(server_root_key), name, banlist FROM communities WHERE community_id = ?1",
2158                    params![cid],
2159                    |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)),
2160                )
2161                .unwrap();
2162            assert_eq!(root_len, 60, "server_root_key must be ciphertext, not a raw 32-byte key");
2163            assert_ne!(name, "Secret HQ", "name must not be plaintext on disk");
2164            assert!(crate::crypto::looks_encrypted(&name), "name column is ciphertext");
2165            assert!(crate::crypto::looks_encrypted(&banlist), "banlist column is ciphertext");
2166            let key_len: i64 = conn
2167                .query_row(
2168                    "SELECT length(key) FROM community_epoch_keys WHERE community_id = ?1 LIMIT 1",
2169                    params![cid],
2170                    |r| r.get(0),
2171                )
2172                .unwrap();
2173            assert_eq!(key_len, 60, "epoch-archive key must be ciphertext");
2174        }
2175
2176        // In memory: load decrypts everything back to the originals.
2177        let loaded = load_community(&c.id).unwrap().unwrap();
2178        assert_eq!(loaded.name, "Secret HQ");
2179        assert_eq!(loaded.description.as_deref(), Some("top secret"));
2180        assert_eq!(loaded.server_root_key.as_bytes(), &[0x42u8; 32]);
2181        assert_eq!(loaded.relays, vec!["wss://relay.example".to_string()]);
2182        assert_eq!(get_community_banlist(&cid).unwrap(), vec!["deadbeef".repeat(8)]);
2183
2184        crate::state::set_encryption_enabled(false);
2185        crate::state::ENCRYPTION_KEY.clear(&[]);
2186    }
2187
2188    #[test]
2189    fn at_rest_decrypt_tolerates_a_pre_migration_plaintext_row() {
2190        // A row written BEFORE the at-rest pass (raw key + plaintext text) must still read back once
2191        // encryption is on — the 32-vs-60 byte + `looks_encrypted` discriminators handle the mixed DB.
2192        let (_tmp, _guard) = init_test_db();
2193        crate::state::set_encryption_enabled(false);
2194        let mut c = Community::create("Legacy HQ", "general", vec![]);
2195        c.server_root_key = ServerRootKey([0x42u8; 32]);
2196        save_community(&c).unwrap();
2197
2198        crate::state::ENCRYPTION_KEY.set([0x55u8; 32], &[]);
2199        crate::state::set_encryption_enabled(true);
2200        let loaded = load_community(&c.id).unwrap().unwrap();
2201        assert_eq!(loaded.name, "Legacy HQ", "plaintext name reads through");
2202        assert_eq!(loaded.server_root_key.as_bytes(), &[0x42u8; 32], "raw 32-byte key reads through");
2203
2204        crate::state::set_encryption_enabled(false);
2205        crate::state::ENCRYPTION_KEY.clear(&[]);
2206    }
2207
2208    #[test]
2209    fn save_and_load_round_trip() {
2210        let (_tmp, _guard) = init_test_db();
2211        let original = Community::create("Vector HQ", "general", vec!["wss://r.one".into()]);
2212        save_community(&original).unwrap();
2213
2214        let loaded = load_community(&original.id).unwrap().expect("present");
2215        assert_eq!(loaded.id, original.id);
2216        assert_eq!(loaded.name, "Vector HQ");
2217        assert_eq!(loaded.relays, original.relays);
2218        // Secrets survive the round trip byte-for-byte.
2219        assert_eq!(loaded.server_root_key.as_bytes(), original.server_root_key.as_bytes());
2220        // Channel survives with its key, epoch, and name.
2221        assert_eq!(loaded.channels.len(), 1);
2222        assert_eq!(loaded.channels[0].id, original.channels[0].id);
2223        assert_eq!(loaded.channels[0].key.as_bytes(), original.channels[0].key.as_bytes());
2224        assert_eq!(loaded.channels[0].epoch, Epoch(0));
2225        assert_eq!(loaded.channels[0].name, "general");
2226    }
2227
2228    #[test]
2229    fn owner_is_protected_from_the_banlist_a_member_is_not() {
2230        use nostr_sdk::JsonUtil;
2231        let (_tmp, _guard) = init_test_db();
2232        let mut community = Community::create("HQ", "general", vec!["wss://r".into()]);
2233        // Give it a proven owner (index 0).
2234        let owner_id = Keys::new(SecretKey::from_slice(&[7u8; 32]).unwrap());
2235        community.owner_attestation = Some(
2236            crate::community::owner::build_owner_attestation_unsigned(
2237                owner_id.public_key(),
2238                &community.id.to_hex(),
2239            )
2240            .sign_with_keys(&owner_id)
2241            .unwrap()
2242            .as_json(),
2243        );
2244        save_community(&community).unwrap();
2245
2246        // A banlist naming BOTH the owner and a regular member.
2247        let member = Keys::generate();
2248        set_community_banlist(
2249            &community.id.to_hex(),
2250            &[owner_id.public_key().to_hex(), member.public_key().to_hex()],
2251            1,
2252        )
2253        .unwrap();
2254
2255        let loaded = load_community(&community.id).unwrap().unwrap();
2256        let ch = &loaded.channels[0];
2257        // The owner is filtered OUT of the effective banlist (index 0 can't be banned)...
2258        assert!(!ch.banned.contains(&owner_id.public_key()), "owner is never effectively banned");
2259        assert!(ch.protected.contains(&owner_id.public_key()), "owner is in the protected set");
2260        // ...but a regular member's ban stands.
2261        assert!(ch.banned.contains(&member.public_key()), "a member's ban is honored");
2262    }
2263
2264    #[test]
2265    fn loaded_keys_actually_decrypt() {
2266        // The reconstructed keys must be usable: seal with the original channel key,
2267        // open with the loaded one (proves the blob round-trip preserved key bytes).
2268        let (_tmp, _guard) = init_test_db();
2269        let original = Community::create("HQ", "general", vec![]);
2270        save_community(&original).unwrap();
2271        let loaded = load_community(&original.id).unwrap().unwrap();
2272
2273        let author = nostr_sdk::prelude::Keys::generate();
2274        let chan = &original.channels[0];
2275        let sealed = crate::community::envelope::seal_message(
2276            &author, &chan.key, &chan.id, chan.epoch, "persisted!", 1,
2277        )
2278        .unwrap();
2279        let opened = crate::community::envelope::open_message(
2280            &sealed,
2281            &loaded.channels[0].key,
2282            &loaded.channels[0].id,
2283            loaded.channels[0].epoch,
2284        )
2285        .unwrap();
2286        assert_eq!(opened.content, "persisted!");
2287    }
2288
2289    #[test]
2290    fn member_view_round_trips() {
2291        // A joined member-view Community (keyless) persists + reloads with its
2292        // server-root + channel keys intact.
2293        let (_tmp, _guard) = init_test_db();
2294        let member = Community {
2295            id: CommunityId([7u8; 32]),
2296            server_root_key: ServerRootKey([8u8; 32]),
2297            server_root_epoch: Epoch(0),
2298            name: "Joined".into(),
2299            description: None,
2300            icon: None,
2301            banner: None,
2302            relays: vec!["wss://r".into()],
2303            channels: vec![Channel {
2304                id: ChannelId([9u8; 32]),
2305                key: ChannelKey([10u8; 32]),
2306                epoch: Epoch(0),
2307                name: "general".into(),
2308                banned: Vec::new(),
2309                protected: Vec::new(), roster: Default::default(),
2310                epoch_keys: Vec::new(),
2311                dissolved: false,
2312            }],
2313            owner_attestation: None,
2314            dissolved: false,
2315        };
2316        save_community(&member).unwrap();
2317        let loaded = load_community(&member.id).unwrap().expect("present");
2318        assert_eq!(loaded.server_root_key.as_bytes(), &[8u8; 32]);
2319        assert_eq!(loaded.channels[0].key.as_bytes(), &[10u8; 32]);
2320    }
2321
2322    #[test]
2323    fn large_epoch_round_trips_losslessly() {
2324        // Epoch >= 2^63 stored as i64 then reinterpreted as u64 must be exact.
2325        let (_tmp, _guard) = init_test_db();
2326        let mut c = Community::create("HQ", "g", vec![]);
2327        c.channels[0].epoch = Epoch(u64::MAX - 7);
2328        save_community(&c).unwrap();
2329        let loaded = load_community(&c.id).unwrap().unwrap();
2330        assert_eq!(loaded.channels[0].epoch, Epoch(u64::MAX - 7));
2331    }
2332
2333    #[test]
2334    fn malformed_channel_id_row_errors_not_corrupts() {
2335        // A corrupted (short/non-hex) channel_id must error on load, not silently
2336        // reconstruct a wrong-but-self-consistent id.
2337        let (_tmp, _guard) = init_test_db();
2338        let c = Community::create("HQ", "g", vec![]);
2339        save_community(&c).unwrap();
2340        {
2341            let conn = crate::db::get_write_connection_guard_static().unwrap();
2342            conn.execute(
2343                "INSERT OR REPLACE INTO community_channels
2344                    (channel_id, community_id, channel_key, epoch, name, created_at)
2345                 VALUES (?1, ?2, ?3, 0, 'bad', 0)",
2346                rusqlite::params!["zz_not_hex", c.id.to_hex(), &[0u8; 32][..]],
2347            )
2348            .unwrap();
2349        }
2350        assert!(load_community(&c.id).is_err(), "malformed id must error, not corrupt");
2351    }
2352
2353    #[test]
2354    fn message_key_store_take_round_trip() {
2355        let (_tmp, _guard) = init_test_db();
2356        let eph = Keys::generate();
2357        let relays = vec!["wss://r.one".to_string()];
2358        // Keyed by INNER message id; resolves to the OUTER event id + key + relays.
2359        store_message_key("inner_msg_id", "outer_evid", &eph, &relays).unwrap();
2360
2361        let (loaded, outer, r) = take_message_key("inner_msg_id").unwrap().expect("present");
2362        assert_eq!(
2363            loaded.secret_key().as_secret_bytes(),
2364            eph.secret_key().as_secret_bytes()
2365        );
2366        assert_eq!(outer, "outer_evid");
2367        assert_eq!(r, relays);
2368        // `take` is single-use: the row is removed.
2369        assert!(take_message_key("inner_msg_id").unwrap().is_none());
2370    }
2371
2372    #[test]
2373    fn missing_community_is_none() {
2374        let (_tmp, _guard) = init_test_db();
2375        let absent = CommunityId([0x33u8; 32]);
2376        assert!(load_community(&absent).unwrap().is_none());
2377    }
2378
2379    #[test]
2380    fn list_ids_reflects_saved() {
2381        let (_tmp, _guard) = init_test_db();
2382        let a = Community::create("A", "g", vec![]);
2383        let b = Community::create("B", "g", vec![]);
2384        save_community(&a).unwrap();
2385        save_community(&b).unwrap();
2386        let ids = list_community_ids().unwrap();
2387        assert_eq!(ids.len(), 2);
2388        assert!(ids.contains(&a.id) && ids.contains(&b.id));
2389    }
2390
2391    #[test]
2392    fn delete_community_clears_all_local_state() {
2393        let (_tmp, _guard) = init_test_db();
2394        let c = Community::create("HQ", "general", vec!["r1".into()]);
2395        save_community(&c).unwrap();
2396        let cid = c.id.to_hex();
2397        save_public_invite(&"ab".repeat(32), &cid, "url", None, None).unwrap();
2398        save_pending_invite(&"cd".repeat(32), "{}", "npub1x").unwrap();
2399        set_edition_head(&cid, &"a".repeat(64), 3, &[0x11u8; 32]).unwrap();
2400
2401        // The save above archived the base + channel keys; this proves delete clears them too.
2402        assert!(!held_epoch_keys(&cid, crate::community::SERVER_ROOT_SCOPE_HEX).unwrap().is_empty());
2403
2404        delete_community(&cid).unwrap();
2405        assert!(!community_exists(&c.id).unwrap());
2406        assert!(community_id_for_channel(&c.channels[0].id.to_hex()).unwrap().is_none());
2407        assert!(list_public_invites(&cid).unwrap().is_empty());
2408        assert_eq!(get_edition_head(&cid, &"a".repeat(64)).unwrap(), None, "edition heads cleared on delete");
2409        assert!(held_epoch_keys(&cid, crate::community::SERVER_ROOT_SCOPE_HEX).unwrap().is_empty(), "epoch keys cleared on delete");
2410    }
2411
2412    #[test]
2413    fn delete_community_retain_keys_drops_state_but_keeps_epoch_keys() {
2414        // self-removal teardown: drop chat/membership/control state but KEEP the held epoch keys so a
2415        // later self-scrub of own past messages stays possible.
2416        let (_tmp, _guard) = init_test_db();
2417        let c = Community::create("HQ", "general", vec!["r1".into()]);
2418        save_community(&c).unwrap();
2419        let cid = c.id.to_hex();
2420        save_public_invite(&"ab".repeat(32), &cid, "url", None, None).unwrap();
2421        set_edition_head(&cid, &"a".repeat(64), 3, &[0x11u8; 32]).unwrap();
2422
2423        let base_before = held_epoch_keys(&cid, crate::community::SERVER_ROOT_SCOPE_HEX).unwrap();
2424        let chan_before = held_epoch_keys(&cid, &c.channels[0].id.to_hex()).unwrap();
2425        assert!(!base_before.is_empty() && !chan_before.is_empty(), "save archived base + channel keys");
2426
2427        delete_community_retain_keys(&cid).unwrap();
2428
2429        // State is gone.
2430        assert!(!community_exists(&c.id).unwrap());
2431        assert!(community_id_for_channel(&c.channels[0].id.to_hex()).unwrap().is_none());
2432        assert!(list_public_invites(&cid).unwrap().is_empty());
2433        assert_eq!(get_edition_head(&cid, &"a".repeat(64)).unwrap(), None);
2434        // Epoch keys (base + channel, every epoch) survive intact.
2435        assert_eq!(held_epoch_keys(&cid, crate::community::SERVER_ROOT_SCOPE_HEX).unwrap(), base_before,
2436            "base epoch keys retained for self-scrub");
2437        assert_eq!(held_epoch_keys(&cid, &c.channels[0].id.to_hex()).unwrap(), chan_before,
2438            "channel epoch keys retained for self-scrub");
2439    }
2440
2441    #[test]
2442    fn channel_resolves_to_owning_community() {
2443        let (_tmp, _guard) = init_test_db();
2444        let c = Community::create("HQ", "general", vec![]);
2445        save_community(&c).unwrap();
2446        let chan = c.channels[0].id.to_hex();
2447        assert_eq!(community_id_for_channel(&chan).unwrap().as_deref(), Some(c.id.to_hex().as_str()));
2448        assert!(community_id_for_channel(&"ff".repeat(32)).unwrap().is_none());
2449    }
2450
2451    #[test]
2452    fn community_exists_reflects_saved() {
2453        let (_tmp, _guard) = init_test_db();
2454        let c = Community::create("A", "g", vec![]);
2455        assert!(!community_exists(&c.id).unwrap());
2456        save_community(&c).unwrap();
2457        assert!(community_exists(&c.id).unwrap());
2458    }
2459
2460    #[test]
2461    fn pending_invite_first_wins_and_round_trips() {
2462        let (_tmp, _guard) = init_test_db();
2463        let cid = "ab".repeat(32);
2464        // First park inserts; a re-invite for the same id is IGNORED (first-wins, so a
2465        // hostile re-send can't rewrite a parked bundle or re-notify).
2466        assert!(save_pending_invite(&cid, "{\"bundle\":1}", "npub1inviter").unwrap());
2467        assert!(!save_pending_invite(&cid, "{\"bundle\":2}", "npub1other").unwrap());
2468        assert!(pending_invite_exists(&cid).unwrap());
2469
2470        let listed = list_pending_invites().unwrap();
2471        assert_eq!(listed.len(), 1);
2472        assert_eq!(listed[0].community_id, cid);
2473        assert_eq!(listed[0].bundle_json, "{\"bundle\":1}", "original bundle preserved");
2474        assert_eq!(listed[0].inviter_npub, "npub1inviter");
2475
2476        // get is non-destructive; delete then removes it.
2477        assert_eq!(get_pending_invite(&cid).unwrap().as_deref(), Some("{\"bundle\":1}"));
2478        assert!(pending_invite_exists(&cid).unwrap(), "get must not delete");
2479        delete_pending_invite(&cid).unwrap();
2480        assert!(!pending_invite_exists(&cid).unwrap());
2481        assert!(get_pending_invite(&cid).unwrap().is_none());
2482    }
2483
2484    #[test]
2485    fn purge_drops_invites_for_held_communities_only() {
2486        let (_tmp, _guard) = init_test_db();
2487        // A community we hold + a parked invite for it (the cross-device race: invite landed
2488        // before the membership list rehydrated the community).
2489        let held = Community::create("Held", "general", vec![]);
2490        save_community(&held).unwrap();
2491        let held_hex = held.id.to_hex();
2492        save_pending_invite(&held_hex, "{\"bundle\":1}", "npub1inviter").unwrap();
2493        // An invite for a community we do NOT hold must survive the purge.
2494        let stranger = "ab".repeat(32);
2495        save_pending_invite(&stranger, "{\"bundle\":2}", "npub1inviter").unwrap();
2496
2497        let n = purge_pending_invites_for_held_communities().unwrap();
2498        assert_eq!(n, 1, "only the held community's invite is purged");
2499        assert!(!pending_invite_exists(&held_hex).unwrap(), "held → invite gone");
2500        assert!(pending_invite_exists(&stranger).unwrap(), "unknown community → invite kept");
2501    }
2502
2503    #[test]
2504    fn decline_drops_pending_invite() {
2505        let (_tmp, _guard) = init_test_db();
2506        let cid = "cd".repeat(32);
2507        save_pending_invite(&cid, "{}", "npub1x").unwrap();
2508        delete_pending_invite(&cid).unwrap();
2509        assert!(!pending_invite_exists(&cid).unwrap());
2510    }
2511
2512    #[test]
2513    fn pending_invites_are_capped_keeping_the_newest() {
2514        let (_tmp, _guard) = init_test_db();
2515        // 150 distinct invites with strictly increasing received_at (the helper stamps now_secs(),
2516        // so vary the id and rely on insertion order; to make ordering deterministic we bump the
2517        // stored time directly after each insert isn't needed — received_at ties break on id DESC).
2518        // Insert 150; the table must cap at 100.
2519        for i in 0..150u32 {
2520            let cid = format!("{:064x}", i);
2521            save_pending_invite(&cid, "{}", "npub1x").unwrap();
2522        }
2523        let all = list_pending_invites().unwrap();
2524        assert_eq!(all.len(), 100, "table capped at MAX_PENDING_INVITES");
2525        // A spam flood can't grow it past the cap regardless of how many arrive.
2526        for i in 150..400u32 {
2527            let cid = format!("{:064x}", i);
2528            save_pending_invite(&cid, "{}", "npub1x").unwrap();
2529        }
2530        assert_eq!(list_pending_invites().unwrap().len(), 100, "cap holds under flood");
2531    }
2532}