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/// The persisted CORD-02 §6 stash riding a community row: the vsk-0 fields
1799/// Vector doesn't drive but must republish verbatim on its own edits.
1800#[derive(serde::Serialize, serde::Deserialize, Default)]
1801struct CommunityMetaStash {
1802    #[serde(default, skip_serializing_if = "Option::is_none")]
1803    custom: Option<serde_json::Map<String, serde_json::Value>>,
1804    #[serde(default, skip_serializing_if = "serde_json::Map::is_empty")]
1805    extra: serde_json::Map<String, serde_json::Value>,
1806}
1807
1808/// As [`CommunityMetaStash`], for a channel row (vsk-2: + the voice flag).
1809#[derive(serde::Serialize, serde::Deserialize, Default)]
1810struct ChannelMetaStash {
1811    #[serde(default, skip_serializing_if = "Option::is_none")]
1812    voice: Option<bool>,
1813    #[serde(default, skip_serializing_if = "Option::is_none")]
1814    custom: Option<serde_json::Map<String, serde_json::Value>>,
1815    #[serde(default, skip_serializing_if = "serde_json::Map::is_empty")]
1816    extra: serde_json::Map<String, serde_json::Value>,
1817}
1818
1819/// Persist a v2 community + its channels atomically. UPSERT so a metadata
1820/// re-save preserves banlist/roles (managed by the fold, not here).
1821pub fn save_community_v2(c: &crate::community::v2::community::CommunityV2) -> Result<(), String> {
1822    let conn = super::get_write_connection_guard_static()?;
1823    let id_hex = crate::simd::hex::bytes_to_hex_32(&c.identity.community_id.0);
1824    let relays_json = serde_json::to_string(&c.relays).map_err(|e| e.to_string())?;
1825    let created = (c.created_at_ms / 1000) as i64;
1826
1827    let enc_root = enc_key(&c.community_root)?;
1828    let enc_name = enc_txt(&c.name)?;
1829    let enc_relays = enc_txt(&relays_json)?;
1830    let enc_desc = enc_txt_opt(&c.description)?;
1831    let enc_owner_pk = enc_txt(&crate::simd::hex::bytes_to_hex_32(&c.identity.owner_xonly))?;
1832    let enc_owner_salt = enc_txt(&crate::simd::hex::bytes_to_hex_32(&c.identity.owner_salt))?;
1833    // ImageRef serializes to v1's CommunityImage JSON shape (`ext` rides the
1834    // flattened `extra`), so the shared icon/banner columns serve both protocols
1835    // — cache_community_image reads a v2 row's images unchanged.
1836    let icon_json = c.icon.as_ref().map(|i| serde_json::to_string(i).map_err(|e| e.to_string())).transpose()?;
1837    let banner_json = c.banner.as_ref().map(|b| serde_json::to_string(b).map_err(|e| e.to_string())).transpose()?;
1838    let enc_icon = enc_txt_opt(&icon_json)?;
1839    let enc_banner = enc_txt_opt(&banner_json)?;
1840    let stash_json = (c.meta_custom.is_some() || !c.meta_extra.is_empty())
1841        .then(|| serde_json::to_string(&CommunityMetaStash { custom: c.meta_custom.clone(), extra: c.meta_extra.clone() }).map_err(|e| e.to_string()))
1842        .transpose()?;
1843    let enc_stash = enc_txt_opt(&stash_json)?;
1844
1845    let tx = conn.unchecked_transaction().map_err(|e| format!("save v2 community tx: {e}"))?;
1846    tx.execute(
1847        "INSERT INTO communities
1848            (community_id, server_root_key, name, relays, created_at, description,
1849             server_root_epoch, dissolved, protocol, owner_pubkey, owner_salt, icon, banner, meta_extra)
1850         VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, 2, ?9, ?10, ?11, ?12, ?13)
1851         ON CONFLICT(community_id) DO UPDATE SET
1852            server_root_key=?2, name=?3, relays=?4, description=?6,
1853            server_root_epoch=?7, dissolved=?8, protocol=2, owner_pubkey=?9, owner_salt=?10,
1854            icon=?11, banner=?12, meta_extra=?13",
1855        params![
1856            id_hex, enc_root, enc_name, enc_relays, created, enc_desc,
1857            c.root_epoch.0 as i64, c.dissolved as i64, enc_owner_pk, enc_owner_salt,
1858            enc_icon, enc_banner, enc_stash,
1859        ],
1860    )
1861    .map_err(|e| format!("save v2 community: {e}"))?;
1862
1863    for ch in &c.channels {
1864        let ch_hex = crate::simd::hex::bytes_to_hex_32(&ch.id.0);
1865        // channel_id is the sole PRIMARY KEY, so an UPSERT keyed on it alone would
1866        // let a bundle reusing ANOTHER community's channel_id overwrite that row's
1867        // key/epoch/private in place (a chat-plane hijack). Channel ids are random-32
1868        // (a genuine cross-community collision is negligible), so refuse rather than
1869        // clobber a foreign community's row.
1870        let owner_of: Option<String> = tx
1871            .query_row("SELECT community_id FROM community_channels WHERE channel_id=?1", params![ch_hex], |r| r.get(0))
1872            .optional()
1873            .map_err(|e| format!("channel ownership check: {e}"))?;
1874        if owner_of.is_some_and(|existing| existing != id_hex) {
1875            // SKIP the foreign-owned channel rather than fail the whole save: a
1876            // single replayed phantom (a same-owner cross-community vsk-2 edition)
1877            // would otherwise wedge ALL of this community's control-plane persistence
1878            // on every fold. The foreign row stays untouched; this community just
1879            // never acquires a row for that id.
1880            continue;
1881        }
1882        // A public channel has no independent key; store the community_root as a
1883        // placeholder so the NOT NULL column is satisfied (the real secret is
1884        // derived from the root at read time via `channel_secret`).
1885        let stored_key = ch.key.unwrap_or(c.community_root);
1886        let enc_ch_key = enc_key(&stored_key)?;
1887        let enc_ch_name = enc_txt(&ch.name)?;
1888        let ch_stash_json = (ch.voice.is_some() || ch.meta_custom.is_some() || !ch.meta_extra.is_empty())
1889            .then(|| {
1890                serde_json::to_string(&ChannelMetaStash { voice: ch.voice, custom: ch.meta_custom.clone(), extra: ch.meta_extra.clone() })
1891                    .map_err(|e| e.to_string())
1892            })
1893            .transpose()?;
1894        let enc_ch_stash = enc_txt_opt(&ch_stash_json)?;
1895        tx.execute(
1896            "INSERT INTO community_channels
1897                (channel_id, community_id, channel_key, epoch, name, created_at, private, meta_extra)
1898             VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)
1899             ON CONFLICT(channel_id) DO UPDATE SET
1900                channel_key=?3, epoch=?4, name=?5, private=?7, meta_extra=?8",
1901            params![ch_hex, id_hex, enc_ch_key, ch.epoch.0 as i64, enc_ch_name, created, ch.private as i64, enc_ch_stash],
1902        )
1903        .map_err(|e| format!("save v2 channel: {e}"))?;
1904    }
1905
1906    // Prune channels no longer in the in-memory set — the persisted set is
1907    // authoritative, so a control-follow delete or a rekey removal doesn't
1908    // resurrect (with a stale key) on the next reload. No FK references
1909    // community_channels, so this cascades to nothing.
1910    let keep: Vec<String> = c.channels.iter().map(|ch| crate::simd::hex::bytes_to_hex_32(&ch.id.0)).collect();
1911    if keep.is_empty() {
1912        tx.execute("DELETE FROM community_channels WHERE community_id=?1", params![id_hex])
1913            .map_err(|e| format!("prune v2 channels: {e}"))?;
1914    } else {
1915        let placeholders = std::iter::repeat("?").take(keep.len()).collect::<Vec<_>>().join(",");
1916        let sql = format!("DELETE FROM community_channels WHERE community_id=? AND channel_id NOT IN ({placeholders})");
1917        let mut binds: Vec<String> = Vec::with_capacity(keep.len() + 1);
1918        binds.push(id_hex.clone());
1919        binds.extend(keep);
1920        tx.execute(&sql, rusqlite::params_from_iter(binds.iter()))
1921            .map_err(|e| format!("prune v2 channels: {e}"))?;
1922    }
1923
1924    tx.commit().map_err(|e| format!("commit v2 community: {e}"))?;
1925    Ok(())
1926}
1927
1928/// Load a v2 community by id, or `None` if absent / not a v2 community.
1929pub fn load_community_v2(id: &CommunityId) -> Result<Option<crate::community::v2::community::CommunityV2>, String> {
1930    use crate::community::v2::community::{ChannelV2, CommunityV2};
1931    use crate::community::v2::control::CommunityIdentity;
1932    let conn = super::get_db_connection_guard_static()?;
1933    let id_hex = id.to_hex();
1934
1935    let row = conn
1936        .query_row(
1937            "SELECT server_root_key, name, relays, created_at, description,
1938                    server_root_epoch, dissolved, protocol, owner_pubkey, owner_salt,
1939                    icon, banner, meta_extra
1940             FROM communities WHERE community_id = ?1",
1941            params![id_hex],
1942            |r| {
1943                Ok((
1944                    r.get::<_, Vec<u8>>(0)?,
1945                    r.get::<_, String>(1)?,
1946                    r.get::<_, String>(2)?,
1947                    r.get::<_, i64>(3)?,
1948                    r.get::<_, Option<String>>(4)?,
1949                    r.get::<_, i64>(5)?,
1950                    r.get::<_, i64>(6)?,
1951                    r.get::<_, i64>(7)?,
1952                    r.get::<_, Option<String>>(8)?,
1953                    r.get::<_, Option<String>>(9)?,
1954                    r.get::<_, Option<String>>(10)?,
1955                    r.get::<_, Option<String>>(11)?,
1956                    r.get::<_, Option<String>>(12)?,
1957                ))
1958            },
1959        )
1960        .optional()
1961        .map_err(|e| e.to_string())?;
1962    let Some((root_blob, name_e, relays_e, created, desc_e, root_epoch, dissolved, protocol, owner_pk_e, owner_salt_e, icon_e, banner_e, stash_e)) = row
1963    else {
1964        return Ok(None);
1965    };
1966    if crate::community::ConcordProtocol::from_i64(protocol) != crate::community::ConcordProtocol::V2 {
1967        return Ok(None);
1968    }
1969    let (Some(owner_pk_e), Some(owner_salt_e)) = (owner_pk_e, owner_salt_e) else {
1970        return Err("v2 community row is missing its owner commitment".to_string());
1971    };
1972
1973    let community_root = dec_key(&root_blob)?;
1974    let owner_xonly = parse_hex32(&dec_txt(&owner_pk_e))?;
1975    let owner_salt = parse_hex32(&dec_txt(&owner_salt_e))?;
1976    let identity = CommunityIdentity { community_id: *id, owner_xonly, owner_salt };
1977    let relays: Vec<String> = serde_json::from_str(&dec_txt(&relays_e)).unwrap_or_default();
1978
1979    let mut channels = Vec::new();
1980    {
1981        let mut stmt = conn
1982            .prepare(
1983                "SELECT channel_id, channel_key, epoch, name, private, meta_extra
1984                 FROM community_channels WHERE community_id = ?1 ORDER BY created_at",
1985            )
1986            .map_err(|e| e.to_string())?;
1987        let rows = stmt
1988            .query_map(params![id_hex], |r| {
1989                Ok((
1990                    r.get::<_, String>(0)?,
1991                    r.get::<_, Vec<u8>>(1)?,
1992                    r.get::<_, i64>(2)?,
1993                    r.get::<_, String>(3)?,
1994                    r.get::<_, i64>(4)?,
1995                    r.get::<_, Option<String>>(5)?,
1996                ))
1997            })
1998            .map_err(|e| e.to_string())?;
1999        for row in rows {
2000            let (ch_hex, key_blob, epoch, name_e, private, ch_stash_e) = row.map_err(|e| e.to_string())?;
2001            let private = private != 0;
2002            let key = dec_key(&key_blob)?;
2003            // Unparseable stash degrades to empty — the fold re-persists the
2004            // authoritative value on its next pass.
2005            let ch_stash: ChannelMetaStash = ch_stash_e
2006                .map(|s| dec_txt(&s))
2007                .and_then(|j| serde_json::from_str(&j).ok())
2008                .unwrap_or_default();
2009            channels.push(ChannelV2 {
2010                id: ChannelId(hex_id_to_32(&ch_hex)?),
2011                name: dec_txt(&name_e),
2012                private,
2013                // A public channel derives from the root — drop the placeholder. A
2014                // PRIVATE channel stored with the root value is the KEYLESS placeholder
2015                // (key not yet delivered over the rekey plane): a real private key is
2016                // independently random (CORD-03 §1), never the root, so reconstruct
2017                // None and keep every read/send path behind the keyless guards instead
2018                // of silently addressing the public plane.
2019                key: (private && key != community_root).then_some(key),
2020                epoch: Epoch(epoch as u64),
2021                voice: ch_stash.voice,
2022                meta_custom: ch_stash.custom,
2023                meta_extra: ch_stash.extra,
2024            });
2025        }
2026    }
2027
2028    // Unparseable image JSON degrades to no-image rather than failing the load —
2029    // the fold re-persists the authoritative value on its next pass.
2030    let icon = icon_e
2031        .map(|s| dec_txt(&s))
2032        .and_then(|j| serde_json::from_str::<crate::community::v2::control::ImageRef>(&j).ok());
2033    let banner = banner_e
2034        .map(|s| dec_txt(&s))
2035        .and_then(|j| serde_json::from_str::<crate::community::v2::control::ImageRef>(&j).ok());
2036    let stash: CommunityMetaStash = stash_e
2037        .map(|s| dec_txt(&s))
2038        .and_then(|j| serde_json::from_str(&j).ok())
2039        .unwrap_or_default();
2040
2041    Ok(Some(CommunityV2 {
2042        identity,
2043        community_root,
2044        root_epoch: Epoch(root_epoch as u64),
2045        name: dec_txt(&name_e),
2046        description: desc_e.map(|d| dec_txt(&d)),
2047        icon,
2048        banner,
2049        meta_custom: stash.custom,
2050        meta_extra: stash.extra,
2051        relays,
2052        channels,
2053        dissolved: dissolved != 0,
2054        created_at_ms: (created as u64).saturating_mul(1000),
2055    }))
2056}
2057
2058fn parse_hex32(hex: &str) -> Result<[u8; 32], String> {
2059    if hex.len() != 64 || !hex.bytes().all(|b| b.is_ascii_hexdigit()) {
2060        return Err("stored value is not 32-byte hex".to_string());
2061    }
2062    Ok(crate::simd::hex::hex_to_bytes_32(hex))
2063}
2064
2065/// Load a community's persisted Guestbook: the raw membership events + the
2066/// newest-seen cursor (relay seconds). `([], 0)` when never synced; unparseable
2067/// stored JSON degrades the same way (the next sync re-seeds from zero).
2068pub fn get_guestbook(community_id: &str) -> Result<(Vec<crate::community::v2::guestbook::GuestbookEvent>, u64), String> {
2069    let conn = super::get_db_connection_guard_static()?;
2070    let row = conn
2071        .query_row(
2072            "SELECT events, cursor_secs FROM community_guestbook WHERE community_id = ?1",
2073            params![community_id],
2074            |r| Ok((r.get::<_, String>(0)?, r.get::<_, i64>(1)?)),
2075        )
2076        .optional()
2077        .map_err(|e| format!("load guestbook: {e}"))?;
2078    let Some((events_e, cursor)) = row else {
2079        return Ok((Vec::new(), 0));
2080    };
2081    let events = serde_json::from_str(&dec_txt(&events_e)).unwrap_or_default();
2082    Ok((events, cursor.max(0) as u64))
2083}
2084
2085/// Persist a community's Guestbook events + cursor (encrypted at rest, like the
2086/// community row itself). The caller owns dedup/merge — this is a plain replace.
2087pub fn set_guestbook(
2088    community_id: &str,
2089    events: &[crate::community::v2::guestbook::GuestbookEvent],
2090    cursor_secs: u64,
2091) -> Result<(), String> {
2092    let conn = super::get_write_connection_guard_static()?;
2093    let json = serde_json::to_string(events).map_err(|e| e.to_string())?;
2094    let enc = enc_txt(&json)?;
2095    conn.execute(
2096        "INSERT INTO community_guestbook (community_id, events, cursor_secs)
2097         VALUES (?1, ?2, ?3)
2098         ON CONFLICT(community_id) DO UPDATE SET events=?2, cursor_secs=?3",
2099        params![community_id, enc, cursor_secs as i64],
2100    )
2101    .map_err(|e| format!("save guestbook: {e}"))?;
2102    Ok(())
2103}
2104
2105#[cfg(test)]
2106mod tests {
2107    use super::*;
2108
2109    static TEST_COUNTER: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(0);
2110
2111    /// A unique, syntactically-valid test npub per call (bech32 charset, correct
2112    /// length). Uniqueness isolates each test's account DB so state can't bleed.
2113    fn make_test_npub(n: u32) -> String {
2114        const BECH32: &[u8] = b"qpzry9x8gf2tvdw0s3jn54khce6mua7l";
2115        let mut payload = vec![b'q'; 58];
2116        let mut x = n as u64;
2117        let mut i = 58;
2118        while x > 0 && i > 0 {
2119            i -= 1;
2120            payload[i] = BECH32[(x as usize) % 32];
2121            x /= 32;
2122        }
2123        format!("npub1{}", std::str::from_utf8(&payload).unwrap())
2124    }
2125
2126    fn init_test_db() -> (tempfile::TempDir, std::sync::MutexGuard<'static, ()>) {
2127        let guard = crate::db::DB_TEST_GUARD.lock().unwrap_or_else(|e| e.into_inner());
2128        crate::db::close_database();
2129        // Per-account row-id caches survive close_database; clear them so a stale entry from a prior
2130        // test's DB can't point into this fresh account's DB and FK-fail an insert.
2131        crate::db::clear_id_caches();
2132        let tmp = tempfile::tempdir().unwrap();
2133        let n = TEST_COUNTER.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
2134        let account = make_test_npub(n);
2135        std::fs::create_dir_all(tmp.path().join(&account)).unwrap();
2136        crate::db::set_app_data_dir(tmp.path().to_path_buf());
2137        crate::db::set_current_account(account.clone()).unwrap();
2138        crate::db::init_database(&account).unwrap();
2139        (tmp, guard)
2140    }
2141
2142    #[test]
2143    fn edition_head_round_trips_and_upserts() {
2144        let (_tmp, _guard) = init_test_db();
2145        let cid = "f".repeat(64);
2146        let entity = "a".repeat(64);
2147
2148        // No head yet → None (the next edition is genesis v1).
2149        assert_eq!(get_edition_head(&cid, &entity).unwrap(), None);
2150
2151        // Set v1, read it back exactly.
2152        let h1 = [0x11u8; 32];
2153        set_edition_head(&cid, &entity, 1, &h1).unwrap();
2154        assert_eq!(get_edition_head(&cid, &entity).unwrap(), Some((1, h1)));
2155
2156        // Upsert to v2 — the head advances in place (one row per (community, entity)).
2157        let h2 = [0x22u8; 32];
2158        set_edition_head(&cid, &entity, 2, &h2).unwrap();
2159        assert_eq!(get_edition_head(&cid, &entity).unwrap(), Some((2, h2)));
2160
2161        // MONOTONIC: a lower-or-equal version write is a no-op — the refuse-downgrade floor never
2162        // rolls back, even against a stale or hostile rollback attempt.
2163        set_edition_head(&cid, &entity, 1, &[0xEEu8; 32]).unwrap();
2164        assert_eq!(get_edition_head(&cid, &entity).unwrap(), Some((2, h2)), "rollback to v1 ignored");
2165        set_edition_head(&cid, &entity, 2, &[0xEEu8; 32]).unwrap();
2166        assert_eq!(get_edition_head(&cid, &entity).unwrap(), Some((2, h2)), "equal version is a no-op too");
2167
2168        // A different entity is tracked independently.
2169        let other = "b".repeat(64);
2170        assert_eq!(get_edition_head(&cid, &other).unwrap(), None);
2171    }
2172
2173    #[test]
2174    fn guestbook_round_trips_events_and_cursor() {
2175        let (_tmp, _guard) = init_test_db();
2176        let member = nostr_sdk::prelude::Keys::generate();
2177        let ev = crate::community::v2::guestbook::GuestbookEvent {
2178            rumor_id: [7u8; 32],
2179            entry: crate::community::v2::guestbook::GuestbookEntry::Join {
2180                member: member.public_key(),
2181                invited_by: Some(("creator".into(), "label".into())),
2182                at_ms: 1_000,
2183            },
2184        };
2185        let cid = "d".repeat(64);
2186        assert_eq!(get_guestbook(&cid).unwrap(), (Vec::new(), 0), "absent reads as empty at cursor 0");
2187        set_guestbook(&cid, std::slice::from_ref(&ev), 42).unwrap();
2188        let (events, cursor) = get_guestbook(&cid).unwrap();
2189        assert_eq!(events, vec![ev], "events round-trip through the encrypted blob");
2190        assert_eq!(cursor, 42);
2191    }
2192
2193    #[test]
2194    fn v2_images_round_trip_and_read_as_v1_community_images() {
2195        let (_tmp, _guard) = init_test_db();
2196        let owner = nostr_sdk::prelude::Keys::generate();
2197        let g = crate::community::v2::control::genesis(
2198            &owner,
2199            crate::community::v2::control::CommunityMetadata { name: "Icons".into(), ..Default::default() },
2200            1_000,
2201        )
2202        .unwrap();
2203        let mut c = crate::community::v2::community::CommunityV2::from_genesis(&g, "Icons", None, vec!["wss://r".into()], 1_000);
2204        let mut extra = serde_json::Map::new();
2205        extra.insert("ext".into(), serde_json::Value::String("webp".into()));
2206        c.icon = Some(crate::community::v2::control::ImageRef {
2207            url: "https://blossom.example/abc".into(),
2208            key: "0".repeat(64),
2209            nonce: "1".repeat(32),
2210            hash: "a".repeat(64),
2211            extra,
2212        });
2213        c.meta_custom = Some({
2214            let mut m = serde_json::Map::new();
2215            m.insert("k".into(), serde_json::Value::from("v"));
2216            m
2217        });
2218        c.channels[0].voice = Some(true);
2219        c.channels[0].meta_extra.insert("vnd".into(), serde_json::Value::from(7));
2220        save_community_v2(&c).unwrap();
2221
2222        // The v2 loader round-trips the ImageRef exactly (extra included).
2223        let re = load_community_v2(c.id()).unwrap().unwrap();
2224        assert_eq!(re.icon, c.icon);
2225        assert_eq!(re.banner, None);
2226        // The CORD-02 §6 stash survives the encrypted envelope columns.
2227        assert_eq!(re.meta_custom, c.meta_custom);
2228        assert_eq!(re.channels[0].voice, Some(true));
2229        assert_eq!(re.channels[0].meta_extra.get("vnd"), Some(&serde_json::Value::from(7)));
2230
2231        // Dual-reader guarantee: the SAME stored JSON parses as a v1 CommunityImage
2232        // (`ext` from the flattened extra), so cache_community_image serves a v2
2233        // icon with no v2-awareness.
2234        let v1 = load_community(c.id()).unwrap().unwrap();
2235        let img = v1.icon.expect("v1 reader sees the v2 icon");
2236        assert_eq!(img.url, "https://blossom.example/abc");
2237        assert_eq!(img.ext, "webp");
2238        assert_eq!(img.hash, "a".repeat(64));
2239    }
2240
2241    #[test]
2242    fn server_root_epoch_round_trips() {
2243        // The base read clock survives save/load (default 0; a rotated value preserved exactly).
2244        let (_tmp, _guard) = init_test_db();
2245        let mut c = Community::create("HQ", "general", vec![]);
2246        save_community(&c).unwrap();
2247        assert_eq!(load_community(&c.id).unwrap().unwrap().server_root_epoch, Epoch(0));
2248
2249        c.server_root_epoch = Epoch(5);
2250        c.server_root_key = ServerRootKey([0x42u8; 32]);
2251        save_community(&c).unwrap();
2252        let loaded = load_community(&c.id).unwrap().unwrap();
2253        assert_eq!(loaded.server_root_epoch, Epoch(5));
2254        assert_eq!(loaded.server_root_key.as_bytes(), &[0x42u8; 32]);
2255    }
2256
2257    #[test]
2258    fn epoch_key_archive_retains_every_epoch() {
2259        // a member who lived through a rotation must keep OLD epoch keys. Storing a new
2260        // epoch's key must NOT clobber a prior one (the data-loss bug the archive fixes).
2261        let (_tmp, _guard) = init_test_db();
2262        let cid = "f".repeat(64);
2263        let scope = "a".repeat(64);
2264
2265        store_epoch_key(&cid, &scope, 0, &[0xA0u8; 32]).unwrap();
2266        store_epoch_key(&cid, &scope, 1, &[0xA1u8; 32]).unwrap();
2267        store_epoch_key(&cid, &scope, 2, &[0xA2u8; 32]).unwrap();
2268
2269        let held = held_epoch_keys(&cid, &scope).unwrap();
2270        assert_eq!(held.len(), 3, "all three epoch keys retained");
2271        assert_eq!(held[0], (Epoch(0), [0xA0u8; 32]));
2272        assert_eq!(held[1], (Epoch(1), [0xA1u8; 32]));
2273        assert_eq!(held[2], (Epoch(2), [0xA2u8; 32]));
2274
2275        // Point lookup by epoch (what the open path uses to select a decryption key).
2276        assert_eq!(held_epoch_key(&cid, &scope, 1).unwrap(), Some([0xA1u8; 32]));
2277        assert_eq!(held_epoch_key(&cid, &scope, 9).unwrap(), None, "unheld epoch is None");
2278
2279        // Same coordinate REPLACE = fork-resolution committing a winning key (only legit overwrite).
2280        store_epoch_key(&cid, &scope, 1, &[0xBBu8; 32]).unwrap();
2281        assert_eq!(held_epoch_key(&cid, &scope, 1).unwrap(), Some([0xBBu8; 32]));
2282        assert_eq!(held_epoch_keys(&cid, &scope).unwrap().len(), 3, "replace didn't add a row");
2283
2284        // A different scope is isolated (server-root vs a channel share the table, never collide) —
2285        // at both the list AND the point-lookup level.
2286        assert!(held_epoch_keys(&cid, crate::community::SERVER_ROOT_SCOPE_HEX).unwrap().is_empty());
2287        assert_eq!(
2288            held_epoch_key(&cid, crate::community::SERVER_ROOT_SCOPE_HEX, 1).unwrap(),
2289            None,
2290            "epoch 1 under a different scope is not the channel's key"
2291        );
2292    }
2293
2294    #[test]
2295    fn save_community_populates_the_epoch_archive() {
2296        // save_community mirrors the current base + channel keys into the multi-held archive, so the
2297        // foundation is live without any explicit store_epoch_key call by the caller.
2298        let (_tmp, _guard) = init_test_db();
2299        let c = Community::create("HQ", "general", vec![]);
2300        save_community(&c).unwrap();
2301        let cid = c.id.to_hex();
2302
2303        // Base key archived under the server-root sentinel at epoch 0.
2304        assert_eq!(
2305            held_epoch_key(&cid, crate::community::SERVER_ROOT_SCOPE_HEX, 0).unwrap().as_ref(),
2306            Some(c.server_root_key.as_bytes())
2307        );
2308        // The default channel's key archived under its channel id at epoch 0.
2309        let chan = &c.channels[0];
2310        assert_eq!(
2311            held_epoch_key(&cid, &chan.id.to_hex(), 0).unwrap().as_ref(),
2312            Some(chan.key.as_bytes())
2313        );
2314    }
2315
2316    #[test]
2317    fn at_rest_encryption_wraps_keys_and_metadata_on_disk() {
2318        let (_tmp, _guard) = init_test_db();
2319        // Local Encryption ON with a known vault key (the db-test guard serializes, so toggling these
2320        // globals is safe; reset at the end). `others: &[]` — the slice only allocates a vault lane.
2321        crate::state::ENCRYPTION_KEY.set([0x55u8; 32], &[]);
2322        crate::state::set_encryption_enabled(true);
2323
2324        let mut c = Community::create("Secret HQ", "general", vec!["wss://relay.example".into()]);
2325        c.server_root_key = ServerRootKey([0x42u8; 32]);
2326        c.description = Some("top secret".into());
2327        save_community(&c).unwrap();
2328        let cid = c.id.to_hex();
2329        set_community_banlist(&cid, &["deadbeef".repeat(8)], 1).unwrap();
2330
2331        // On disk: secrets are 60-byte ciphertext (12 nonce + 32 + 16 tag), NOT raw 32-byte keys;
2332        // identifying text is hex ciphertext, never the plaintext.
2333        {
2334            let conn = crate::db::get_db_connection_guard_static().unwrap();
2335            let (root_len, name, banlist): (i64, String, String) = conn
2336                .query_row(
2337                    "SELECT length(server_root_key), name, banlist FROM communities WHERE community_id = ?1",
2338                    params![cid],
2339                    |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)),
2340                )
2341                .unwrap();
2342            assert_eq!(root_len, 60, "server_root_key must be ciphertext, not a raw 32-byte key");
2343            assert_ne!(name, "Secret HQ", "name must not be plaintext on disk");
2344            assert!(crate::crypto::looks_encrypted(&name), "name column is ciphertext");
2345            assert!(crate::crypto::looks_encrypted(&banlist), "banlist column is ciphertext");
2346            let key_len: i64 = conn
2347                .query_row(
2348                    "SELECT length(key) FROM community_epoch_keys WHERE community_id = ?1 LIMIT 1",
2349                    params![cid],
2350                    |r| r.get(0),
2351                )
2352                .unwrap();
2353            assert_eq!(key_len, 60, "epoch-archive key must be ciphertext");
2354        }
2355
2356        // In memory: load decrypts everything back to the originals.
2357        let loaded = load_community(&c.id).unwrap().unwrap();
2358        assert_eq!(loaded.name, "Secret HQ");
2359        assert_eq!(loaded.description.as_deref(), Some("top secret"));
2360        assert_eq!(loaded.server_root_key.as_bytes(), &[0x42u8; 32]);
2361        assert_eq!(loaded.relays, vec!["wss://relay.example".to_string()]);
2362        assert_eq!(get_community_banlist(&cid).unwrap(), vec!["deadbeef".repeat(8)]);
2363
2364        crate::state::set_encryption_enabled(false);
2365        crate::state::ENCRYPTION_KEY.clear(&[]);
2366    }
2367
2368    #[test]
2369    fn at_rest_decrypt_tolerates_a_pre_migration_plaintext_row() {
2370        // A row written BEFORE the at-rest pass (raw key + plaintext text) must still read back once
2371        // encryption is on — the 32-vs-60 byte + `looks_encrypted` discriminators handle the mixed DB.
2372        let (_tmp, _guard) = init_test_db();
2373        crate::state::set_encryption_enabled(false);
2374        let mut c = Community::create("Legacy HQ", "general", vec![]);
2375        c.server_root_key = ServerRootKey([0x42u8; 32]);
2376        save_community(&c).unwrap();
2377
2378        crate::state::ENCRYPTION_KEY.set([0x55u8; 32], &[]);
2379        crate::state::set_encryption_enabled(true);
2380        let loaded = load_community(&c.id).unwrap().unwrap();
2381        assert_eq!(loaded.name, "Legacy HQ", "plaintext name reads through");
2382        assert_eq!(loaded.server_root_key.as_bytes(), &[0x42u8; 32], "raw 32-byte key reads through");
2383
2384        crate::state::set_encryption_enabled(false);
2385        crate::state::ENCRYPTION_KEY.clear(&[]);
2386    }
2387
2388    #[test]
2389    fn save_and_load_round_trip() {
2390        let (_tmp, _guard) = init_test_db();
2391        let original = Community::create("Vector HQ", "general", vec!["wss://r.one".into()]);
2392        save_community(&original).unwrap();
2393
2394        let loaded = load_community(&original.id).unwrap().expect("present");
2395        assert_eq!(loaded.id, original.id);
2396        assert_eq!(loaded.name, "Vector HQ");
2397        assert_eq!(loaded.relays, original.relays);
2398        // Secrets survive the round trip byte-for-byte.
2399        assert_eq!(loaded.server_root_key.as_bytes(), original.server_root_key.as_bytes());
2400        // Channel survives with its key, epoch, and name.
2401        assert_eq!(loaded.channels.len(), 1);
2402        assert_eq!(loaded.channels[0].id, original.channels[0].id);
2403        assert_eq!(loaded.channels[0].key.as_bytes(), original.channels[0].key.as_bytes());
2404        assert_eq!(loaded.channels[0].epoch, Epoch(0));
2405        assert_eq!(loaded.channels[0].name, "general");
2406    }
2407
2408    #[test]
2409    fn owner_is_protected_from_the_banlist_a_member_is_not() {
2410        use nostr_sdk::JsonUtil;
2411        let (_tmp, _guard) = init_test_db();
2412        let mut community = Community::create("HQ", "general", vec!["wss://r".into()]);
2413        // Give it a proven owner (index 0).
2414        let owner_id = Keys::new(SecretKey::from_slice(&[7u8; 32]).unwrap());
2415        community.owner_attestation = Some(
2416            crate::community::owner::build_owner_attestation_unsigned(
2417                owner_id.public_key(),
2418                &community.id.to_hex(),
2419            )
2420            .sign_with_keys(&owner_id)
2421            .unwrap()
2422            .as_json(),
2423        );
2424        save_community(&community).unwrap();
2425
2426        // A banlist naming BOTH the owner and a regular member.
2427        let member = Keys::generate();
2428        set_community_banlist(
2429            &community.id.to_hex(),
2430            &[owner_id.public_key().to_hex(), member.public_key().to_hex()],
2431            1,
2432        )
2433        .unwrap();
2434
2435        let loaded = load_community(&community.id).unwrap().unwrap();
2436        let ch = &loaded.channels[0];
2437        // The owner is filtered OUT of the effective banlist (index 0 can't be banned)...
2438        assert!(!ch.banned.contains(&owner_id.public_key()), "owner is never effectively banned");
2439        assert!(ch.protected.contains(&owner_id.public_key()), "owner is in the protected set");
2440        // ...but a regular member's ban stands.
2441        assert!(ch.banned.contains(&member.public_key()), "a member's ban is honored");
2442    }
2443
2444    #[test]
2445    fn loaded_keys_actually_decrypt() {
2446        // The reconstructed keys must be usable: seal with the original channel key,
2447        // open with the loaded one (proves the blob round-trip preserved key bytes).
2448        let (_tmp, _guard) = init_test_db();
2449        let original = Community::create("HQ", "general", vec![]);
2450        save_community(&original).unwrap();
2451        let loaded = load_community(&original.id).unwrap().unwrap();
2452
2453        let author = nostr_sdk::prelude::Keys::generate();
2454        let chan = &original.channels[0];
2455        let sealed = crate::community::envelope::seal_message(
2456            &author, &chan.key, &chan.id, chan.epoch, "persisted!", 1,
2457        )
2458        .unwrap();
2459        let opened = crate::community::envelope::open_message(
2460            &sealed,
2461            &loaded.channels[0].key,
2462            &loaded.channels[0].id,
2463            loaded.channels[0].epoch,
2464        )
2465        .unwrap();
2466        assert_eq!(opened.content, "persisted!");
2467    }
2468
2469    #[test]
2470    fn member_view_round_trips() {
2471        // A joined member-view Community (keyless) persists + reloads with its
2472        // server-root + channel keys intact.
2473        let (_tmp, _guard) = init_test_db();
2474        let member = Community {
2475            id: CommunityId([7u8; 32]),
2476            server_root_key: ServerRootKey([8u8; 32]),
2477            server_root_epoch: Epoch(0),
2478            name: "Joined".into(),
2479            description: None,
2480            icon: None,
2481            banner: None,
2482            relays: vec!["wss://r".into()],
2483            channels: vec![Channel {
2484                id: ChannelId([9u8; 32]),
2485                key: ChannelKey([10u8; 32]),
2486                epoch: Epoch(0),
2487                name: "general".into(),
2488                banned: Vec::new(),
2489                protected: Vec::new(), roster: Default::default(),
2490                epoch_keys: Vec::new(),
2491                dissolved: false,
2492            }],
2493            owner_attestation: None,
2494            dissolved: false,
2495        };
2496        save_community(&member).unwrap();
2497        let loaded = load_community(&member.id).unwrap().expect("present");
2498        assert_eq!(loaded.server_root_key.as_bytes(), &[8u8; 32]);
2499        assert_eq!(loaded.channels[0].key.as_bytes(), &[10u8; 32]);
2500    }
2501
2502    #[test]
2503    fn large_epoch_round_trips_losslessly() {
2504        // Epoch >= 2^63 stored as i64 then reinterpreted as u64 must be exact.
2505        let (_tmp, _guard) = init_test_db();
2506        let mut c = Community::create("HQ", "g", vec![]);
2507        c.channels[0].epoch = Epoch(u64::MAX - 7);
2508        save_community(&c).unwrap();
2509        let loaded = load_community(&c.id).unwrap().unwrap();
2510        assert_eq!(loaded.channels[0].epoch, Epoch(u64::MAX - 7));
2511    }
2512
2513    #[test]
2514    fn malformed_channel_id_row_errors_not_corrupts() {
2515        // A corrupted (short/non-hex) channel_id must error on load, not silently
2516        // reconstruct a wrong-but-self-consistent id.
2517        let (_tmp, _guard) = init_test_db();
2518        let c = Community::create("HQ", "g", vec![]);
2519        save_community(&c).unwrap();
2520        {
2521            let conn = crate::db::get_write_connection_guard_static().unwrap();
2522            conn.execute(
2523                "INSERT OR REPLACE INTO community_channels
2524                    (channel_id, community_id, channel_key, epoch, name, created_at)
2525                 VALUES (?1, ?2, ?3, 0, 'bad', 0)",
2526                rusqlite::params!["zz_not_hex", c.id.to_hex(), &[0u8; 32][..]],
2527            )
2528            .unwrap();
2529        }
2530        assert!(load_community(&c.id).is_err(), "malformed id must error, not corrupt");
2531    }
2532
2533    #[test]
2534    fn message_key_store_take_round_trip() {
2535        let (_tmp, _guard) = init_test_db();
2536        let eph = Keys::generate();
2537        let relays = vec!["wss://r.one".to_string()];
2538        // Keyed by INNER message id; resolves to the OUTER event id + key + relays.
2539        store_message_key("inner_msg_id", "outer_evid", &eph, &relays).unwrap();
2540
2541        let (loaded, outer, r) = take_message_key("inner_msg_id").unwrap().expect("present");
2542        assert_eq!(
2543            loaded.secret_key().as_secret_bytes(),
2544            eph.secret_key().as_secret_bytes()
2545        );
2546        assert_eq!(outer, "outer_evid");
2547        assert_eq!(r, relays);
2548        // `take` is single-use: the row is removed.
2549        assert!(take_message_key("inner_msg_id").unwrap().is_none());
2550    }
2551
2552    #[test]
2553    fn missing_community_is_none() {
2554        let (_tmp, _guard) = init_test_db();
2555        let absent = CommunityId([0x33u8; 32]);
2556        assert!(load_community(&absent).unwrap().is_none());
2557    }
2558
2559    #[test]
2560    fn list_ids_reflects_saved() {
2561        let (_tmp, _guard) = init_test_db();
2562        let a = Community::create("A", "g", vec![]);
2563        let b = Community::create("B", "g", vec![]);
2564        save_community(&a).unwrap();
2565        save_community(&b).unwrap();
2566        let ids = list_community_ids().unwrap();
2567        assert_eq!(ids.len(), 2);
2568        assert!(ids.contains(&a.id) && ids.contains(&b.id));
2569    }
2570
2571    #[test]
2572    fn delete_community_clears_all_local_state() {
2573        let (_tmp, _guard) = init_test_db();
2574        let c = Community::create("HQ", "general", vec!["r1".into()]);
2575        save_community(&c).unwrap();
2576        let cid = c.id.to_hex();
2577        save_public_invite(&"ab".repeat(32), &cid, "url", None, None).unwrap();
2578        save_pending_invite(&"cd".repeat(32), "{}", "npub1x").unwrap();
2579        set_edition_head(&cid, &"a".repeat(64), 3, &[0x11u8; 32]).unwrap();
2580
2581        // The save above archived the base + channel keys; this proves delete clears them too.
2582        assert!(!held_epoch_keys(&cid, crate::community::SERVER_ROOT_SCOPE_HEX).unwrap().is_empty());
2583
2584        delete_community(&cid).unwrap();
2585        assert!(!community_exists(&c.id).unwrap());
2586        assert!(community_id_for_channel(&c.channels[0].id.to_hex()).unwrap().is_none());
2587        assert!(list_public_invites(&cid).unwrap().is_empty());
2588        assert_eq!(get_edition_head(&cid, &"a".repeat(64)).unwrap(), None, "edition heads cleared on delete");
2589        assert!(held_epoch_keys(&cid, crate::community::SERVER_ROOT_SCOPE_HEX).unwrap().is_empty(), "epoch keys cleared on delete");
2590    }
2591
2592    #[test]
2593    fn delete_community_retain_keys_drops_state_but_keeps_epoch_keys() {
2594        // self-removal teardown: drop chat/membership/control state but KEEP the held epoch keys so a
2595        // later self-scrub of own past messages stays possible.
2596        let (_tmp, _guard) = init_test_db();
2597        let c = Community::create("HQ", "general", vec!["r1".into()]);
2598        save_community(&c).unwrap();
2599        let cid = c.id.to_hex();
2600        save_public_invite(&"ab".repeat(32), &cid, "url", None, None).unwrap();
2601        set_edition_head(&cid, &"a".repeat(64), 3, &[0x11u8; 32]).unwrap();
2602
2603        let base_before = held_epoch_keys(&cid, crate::community::SERVER_ROOT_SCOPE_HEX).unwrap();
2604        let chan_before = held_epoch_keys(&cid, &c.channels[0].id.to_hex()).unwrap();
2605        assert!(!base_before.is_empty() && !chan_before.is_empty(), "save archived base + channel keys");
2606
2607        delete_community_retain_keys(&cid).unwrap();
2608
2609        // State is gone.
2610        assert!(!community_exists(&c.id).unwrap());
2611        assert!(community_id_for_channel(&c.channels[0].id.to_hex()).unwrap().is_none());
2612        assert!(list_public_invites(&cid).unwrap().is_empty());
2613        assert_eq!(get_edition_head(&cid, &"a".repeat(64)).unwrap(), None);
2614        // Epoch keys (base + channel, every epoch) survive intact.
2615        assert_eq!(held_epoch_keys(&cid, crate::community::SERVER_ROOT_SCOPE_HEX).unwrap(), base_before,
2616            "base epoch keys retained for self-scrub");
2617        assert_eq!(held_epoch_keys(&cid, &c.channels[0].id.to_hex()).unwrap(), chan_before,
2618            "channel epoch keys retained for self-scrub");
2619    }
2620
2621    #[test]
2622    fn channel_resolves_to_owning_community() {
2623        let (_tmp, _guard) = init_test_db();
2624        let c = Community::create("HQ", "general", vec![]);
2625        save_community(&c).unwrap();
2626        let chan = c.channels[0].id.to_hex();
2627        assert_eq!(community_id_for_channel(&chan).unwrap().as_deref(), Some(c.id.to_hex().as_str()));
2628        assert!(community_id_for_channel(&"ff".repeat(32)).unwrap().is_none());
2629    }
2630
2631    #[test]
2632    fn community_exists_reflects_saved() {
2633        let (_tmp, _guard) = init_test_db();
2634        let c = Community::create("A", "g", vec![]);
2635        assert!(!community_exists(&c.id).unwrap());
2636        save_community(&c).unwrap();
2637        assert!(community_exists(&c.id).unwrap());
2638    }
2639
2640    #[test]
2641    fn pending_invite_first_wins_and_round_trips() {
2642        let (_tmp, _guard) = init_test_db();
2643        let cid = "ab".repeat(32);
2644        // First park inserts; a re-invite for the same id is IGNORED (first-wins, so a
2645        // hostile re-send can't rewrite a parked bundle or re-notify).
2646        assert!(save_pending_invite(&cid, "{\"bundle\":1}", "npub1inviter").unwrap());
2647        assert!(!save_pending_invite(&cid, "{\"bundle\":2}", "npub1other").unwrap());
2648        assert!(pending_invite_exists(&cid).unwrap());
2649
2650        let listed = list_pending_invites().unwrap();
2651        assert_eq!(listed.len(), 1);
2652        assert_eq!(listed[0].community_id, cid);
2653        assert_eq!(listed[0].bundle_json, "{\"bundle\":1}", "original bundle preserved");
2654        assert_eq!(listed[0].inviter_npub, "npub1inviter");
2655
2656        // get is non-destructive; delete then removes it.
2657        assert_eq!(get_pending_invite(&cid).unwrap().as_deref(), Some("{\"bundle\":1}"));
2658        assert!(pending_invite_exists(&cid).unwrap(), "get must not delete");
2659        delete_pending_invite(&cid).unwrap();
2660        assert!(!pending_invite_exists(&cid).unwrap());
2661        assert!(get_pending_invite(&cid).unwrap().is_none());
2662    }
2663
2664    #[test]
2665    fn purge_drops_invites_for_held_communities_only() {
2666        let (_tmp, _guard) = init_test_db();
2667        // A community we hold + a parked invite for it (the cross-device race: invite landed
2668        // before the membership list rehydrated the community).
2669        let held = Community::create("Held", "general", vec![]);
2670        save_community(&held).unwrap();
2671        let held_hex = held.id.to_hex();
2672        save_pending_invite(&held_hex, "{\"bundle\":1}", "npub1inviter").unwrap();
2673        // An invite for a community we do NOT hold must survive the purge.
2674        let stranger = "ab".repeat(32);
2675        save_pending_invite(&stranger, "{\"bundle\":2}", "npub1inviter").unwrap();
2676
2677        let n = purge_pending_invites_for_held_communities().unwrap();
2678        assert_eq!(n, 1, "only the held community's invite is purged");
2679        assert!(!pending_invite_exists(&held_hex).unwrap(), "held → invite gone");
2680        assert!(pending_invite_exists(&stranger).unwrap(), "unknown community → invite kept");
2681    }
2682
2683    #[test]
2684    fn decline_drops_pending_invite() {
2685        let (_tmp, _guard) = init_test_db();
2686        let cid = "cd".repeat(32);
2687        save_pending_invite(&cid, "{}", "npub1x").unwrap();
2688        delete_pending_invite(&cid).unwrap();
2689        assert!(!pending_invite_exists(&cid).unwrap());
2690    }
2691
2692    #[test]
2693    fn pending_invites_are_capped_keeping_the_newest() {
2694        let (_tmp, _guard) = init_test_db();
2695        // 150 distinct invites with strictly increasing received_at (the helper stamps now_secs(),
2696        // so vary the id and rely on insertion order; to make ordering deterministic we bump the
2697        // stored time directly after each insert isn't needed — received_at ties break on id DESC).
2698        // Insert 150; the table must cap at 100.
2699        for i in 0..150u32 {
2700            let cid = format!("{:064x}", i);
2701            save_pending_invite(&cid, "{}", "npub1x").unwrap();
2702        }
2703        let all = list_pending_invites().unwrap();
2704        assert_eq!(all.len(), 100, "table capped at MAX_PENDING_INVITES");
2705        // A spam flood can't grow it past the cap regardless of how many arrive.
2706        for i in 150..400u32 {
2707            let cid = format!("{:064x}", i);
2708            save_pending_invite(&cid, "{}", "npub1x").unwrap();
2709        }
2710        assert_eq!(list_pending_invites().unwrap().len(), 100, "cap holds under flood");
2711    }
2712}