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    let changed = conn
638        .execute(
639            "INSERT OR IGNORE INTO pending_community_invites
640                (community_id, bundle_json, inviter_npub, received_at)
641             VALUES (?1, ?2, ?3, ?4)",
642            params![community_id, enc_bundle, enc_inviter, now_secs()],
643        )
644        .map_err(|e| format!("save pending invite: {e}"))?;
645    // Only growth can breach the cap. Evict everything past the newest MAX rows
646    // (LIMIT -1 OFFSET cap = "all rows after the first cap"); community_id tie-breaks equal times.
647    if changed > 0 {
648        let _ = conn.execute(
649            "DELETE FROM pending_community_invites
650               WHERE community_id IN (
651                 SELECT community_id FROM pending_community_invites
652                 ORDER BY received_at DESC, community_id DESC
653                 LIMIT -1 OFFSET ?1
654               )",
655            params![MAX_PENDING_INVITES],
656        );
657    }
658    Ok(changed > 0)
659}
660
661/// Drop every parked invite for a community we ALREADY hold — once joined on any device, the
662/// invite must never resurface. Ordering-independent: covers the cross-device case where the
663/// historical gift-wrapped invites are ingested BEFORE the synced membership list rehydrates
664/// those communities (so the ingest-time `community_exists` guard saw nothing yet). Returns the
665/// count purged.
666pub fn purge_pending_invites_for_held_communities() -> Result<usize, String> {
667    let conn = super::get_write_connection_guard_static()?;
668    let n = conn
669        .execute(
670            "DELETE FROM pending_community_invites
671               WHERE community_id IN (SELECT community_id FROM communities)",
672            [],
673        )
674        .map_err(|e| format!("purge held pending invites: {e}"))?;
675    Ok(n)
676}
677
678/// All parked invites, newest first.
679pub fn list_pending_invites() -> Result<Vec<PendingCommunityInvite>, String> {
680    let conn = super::get_db_connection_guard_static()?;
681    let mut stmt = conn
682        .prepare(
683            "SELECT community_id, bundle_json, inviter_npub, received_at
684               FROM pending_community_invites ORDER BY received_at DESC",
685        )
686        .map_err(|e| e.to_string())?;
687    let rows = stmt
688        .query_map([], |r| {
689            Ok(PendingCommunityInvite {
690                community_id: r.get(0)?,
691                bundle_json: dec_txt(&r.get::<_, String>(1)?),
692                inviter_npub: dec_txt(&r.get::<_, String>(2)?),
693                received_at: r.get(3)?,
694            })
695        })
696        .map_err(|e| e.to_string())?;
697    let mut out = Vec::new();
698    for row in rows {
699        out.push(row.map_err(|e| e.to_string())?);
700    }
701    Ok(out)
702}
703
704/// Read a parked invite's bundle WITHOUT removing it. Accept is fallible (caps,
705/// owner/authority collision), so the row must survive a rejected accept — peek here,
706/// then [`delete_pending_invite`] only after the join succeeds.
707pub fn get_pending_invite(community_id: &str) -> Result<Option<String>, String> {
708    let conn = super::get_db_connection_guard_static()?;
709    let raw: Option<String> = conn
710        .query_row(
711            "SELECT bundle_json FROM pending_community_invites WHERE community_id = ?1",
712            params![community_id],
713            |r| r.get::<_, String>(0),
714        )
715        .optional()
716        .map_err(|e| format!("get pending invite: {e}"))?;
717    Ok(raw.map(|s| dec_txt(&s)))
718}
719
720/// Drop a parked invite without joining (the user declined).
721pub fn delete_pending_invite(community_id: &str) -> Result<(), String> {
722    let conn = super::get_write_connection_guard_static()?;
723    conn.execute(
724        "DELETE FROM pending_community_invites WHERE community_id = ?1",
725        params![community_id],
726    )
727    .map_err(|e| format!("delete pending invite: {e}"))?;
728    Ok(())
729}
730
731/// Whether an invite for this id is already parked (inbound dedup).
732pub fn pending_invite_exists(community_id: &str) -> Result<bool, String> {
733    let conn = super::get_db_connection_guard_static()?;
734    let found: Option<i64> = conn
735        .query_row(
736            "SELECT 1 FROM pending_community_invites WHERE community_id = ?1",
737            params![community_id],
738            |r| r.get(0),
739        )
740        .optional()
741        .map_err(|e| format!("pending_invite_exists: {e}"))?;
742    Ok(found.is_some())
743}
744
745/// A minted public-invite link the owner retains (to list + revoke).
746#[derive(Debug, Clone, serde::Serialize)]
747pub struct PublicInviteRecord {
748    /// Hex token (the link's whole secret; lives only in the local account DB).
749    pub token: String,
750    pub community_id: String,
751    pub url: String,
752    pub expires_at: Option<i64>,
753    pub created_at: i64,
754    /// Optional human label set at mint time (e.g. "Twitter", "Discord"). None if unset.
755    pub label: Option<String>,
756    /// Distinct members who joined via this link (by label attribution). 0 if none/unknown.
757    #[serde(default)]
758    pub join_count: u64,
759}
760
761/// Retain a minted public-invite token so the owner can later list + revoke it.
762pub fn save_public_invite(
763    token: &str,
764    community_id: &str,
765    url: &str,
766    expires_at: Option<i64>,
767    label: Option<&str>,
768) -> Result<(), String> {
769    let conn = super::get_write_connection_guard_static()?;
770    // token + url are the link's secret; encrypted, the token PK becomes per-write-unique (random
771    // nonce) so this is effectively an INSERT — fine, mints generate a fresh token each time.
772    let enc_token = enc_txt(token)?;
773    let enc_url = enc_txt(url)?;
774    // Encrypt the label at rest like the url; NULL when no label was set.
775    let enc_label = label.map(enc_txt).transpose()?;
776    conn.execute(
777        "INSERT OR REPLACE INTO community_public_invites
778            (token, community_id, url, expires_at, created_at, label)
779         VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
780        params![enc_token, community_id, enc_url, expires_at, now_secs(), enc_label],
781    )
782    .map_err(|e| format!("save public invite: {e}"))?;
783    Ok(())
784}
785
786/// All minted public-invite links for a Community, newest first.
787pub fn list_public_invites(community_id: &str) -> Result<Vec<PublicInviteRecord>, String> {
788    let conn = super::get_db_connection_guard_static()?;
789    let mut stmt = conn
790        .prepare(
791            "SELECT token, community_id, url, expires_at, created_at, label
792               FROM community_public_invites WHERE community_id = ?1 ORDER BY created_at DESC",
793        )
794        .map_err(|e| e.to_string())?;
795    let rows = stmt
796        .query_map(params![community_id], |r| {
797            Ok(PublicInviteRecord {
798                token: dec_txt(&r.get::<_, String>(0)?),
799                community_id: r.get(1)?,
800                url: dec_txt(&r.get::<_, String>(2)?),
801                expires_at: r.get(3)?,
802                created_at: r.get(4)?,
803                label: r.get::<_, Option<String>>(5)?.map(|s| dec_txt(&s)),
804                join_count: 0,
805            })
806        })
807        .map_err(|e| e.to_string())?;
808    let mut out = Vec::new();
809    for row in rows {
810        out.push(row.map_err(|e| e.to_string())?);
811    }
812    // Fill per-link join counts (distinct joiners via each label, attributed to me).
813    if let Some(me) = crate::state::my_public_key().and_then(|pk| pk.to_bech32().ok()) {
814        if let Ok(counts) = community_invite_join_counts(community_id, &me) {
815            for rec in &mut out {
816                if let Some(l) = rec.label.as_deref() {
817                    rec.join_count = counts.get(l).copied().unwrap_or(0);
818                }
819            }
820        }
821    }
822    Ok(out)
823}
824
825/// Forget a minted public-invite token (after revoking it on relays).
826pub fn delete_public_invite(token: &str) -> Result<(), String> {
827    let conn = super::get_write_connection_guard_static()?;
828    // Stored tokens are encrypted (random nonce), so an equality DELETE can't match — scan,
829    // decrypt, and delete the row whose plaintext token matches (by rowid). Few rows, owner-only.
830    let rows: Vec<(i64, String)> = {
831        let mut stmt = conn
832            .prepare("SELECT rowid, token FROM community_public_invites")
833            .map_err(|e| e.to_string())?;
834        let mapped = stmt
835            .query_map([], |r| Ok((r.get::<_, i64>(0)?, r.get::<_, String>(1)?)))
836            .map_err(|e| e.to_string())?;
837        mapped.filter_map(|r| r.ok()).collect()
838    };
839    for (rowid, stored) in rows {
840        if dec_txt(&stored) == token {
841            conn.execute("DELETE FROM community_public_invites WHERE rowid = ?1", params![rowid])
842                .map_err(|e| format!("delete public invite: {e}"))?;
843        }
844    }
845    Ok(())
846}
847
848/// Remove a Community and all its local state (channels, retained message keys, parked
849/// invites, minted public-invite tokens). Used when the user leaves a Community — there
850/// is no protocol "leave" (membership is key possession), so leaving is purely local:
851/// drop the keys + stop subscribing.
852pub fn delete_community(community_id: &str) -> Result<(), String> {
853    delete_community_inner(community_id, false)
854}
855
856/// self-removal teardown: drop all local community state EXCEPT the held epoch keys
857/// (`community_epoch_keys`). Read access to future epochs is already gone (the post-removal
858/// keys are never delivered); retaining the OLD keys only preserves the ability to author a
859/// `3305` self-delete of one's own past messages, each sealed under the epoch key it was sent
860/// at. Used by every self-removal trigger (voluntary leave, kick of me, ban-rekey exclusion).
861pub fn delete_community_retain_keys(community_id: &str) -> Result<(), String> {
862    delete_community_inner(community_id, true)
863}
864
865fn delete_community_inner(community_id: &str, retain_keys: bool) -> Result<(), String> {
866    let conn = super::get_write_connection_guard_static()?;
867    // Atomic: a crash/error mid-delete must not orphan channel/invite rows under a
868    // now-missing parent (community_id_for_channel would still resolve them).
869    let tx = conn.unchecked_transaction().map_err(|e| format!("delete community tx: {e}"))?;
870    for sql in [
871        Some("DELETE FROM communities WHERE community_id = ?1"),
872        Some("DELETE FROM community_channels WHERE community_id = ?1"),
873        // Multi-held epoch keys (base + per-channel, all epochs). RETAINED on a self-removal so a
874        // later self-scrub of own past messages stays possible; dropped on an explicit delete/re-join reset
875        // (else a re-join inherits stale rotated keys).
876        (!retain_keys).then_some("DELETE FROM community_epoch_keys WHERE community_id = ?1"),
877        Some("DELETE FROM community_public_invites WHERE community_id = ?1"),
878        Some("DELETE FROM community_invite_link_sets WHERE community_id = ?1"),
879        Some("DELETE FROM pending_community_invites WHERE community_id = ?1"),
880        // Per-entity edition heads (keyless model) — else stale refuse-downgrade floors + self_hash
881        // anchors survive a leave/re-join and reject a legitimately reset chain.
882        Some("DELETE FROM community_edition_heads WHERE community_id = ?1"),
883    ]
884    .into_iter()
885    .flatten()
886    {
887        tx.execute(sql, params![community_id])
888            .map_err(|e| format!("delete community: {e}"))?;
889    }
890    tx.commit().map_err(|e| format!("delete community commit: {e}"))?;
891    // `community_message_keys` is INTENTIONALLY left intact: those are our OWN ephemeral signing keys for
892    // NIP-09-deleting our own messages. The right to erase our own content from relays outlives membership
893    // — even after a ban or leave we must keep the ability to purge what we sent — so they survive a
894    // community delete. (Keyed by message_id, no community_id; there is nothing community-scoped to drop.)
895    Ok(())
896}
897
898/// Observed participants: the best-effort member list of a Community, newest-active first.
899/// Membership is NOT authoritative (a lurker who never posts and never announced won't appear).
900/// A member is included when they have real activity — a posted message/reaction/edit, OR a
901/// join presence (kind 3306) — UNLESS that is superseded by a more-recent leave, OR they are
902/// banned. So a "leave" actually removes a member, and a leave-then-rejoin/post re-adds them.
903/// `created_at` is in seconds. Result is capped (anti-flood); see [`COMMUNITY_MEMBER_CAP`].
904pub fn community_member_activity(community_id: &str) -> Result<Vec<(String, u64)>, String> {
905    /// Cap on rendered members — bounds a presence-flood (fresh-identity 3306 spam) from
906    /// growing the list / profile-fetch fan-out without limit.
907    const COMMUNITY_MEMBER_CAP: usize = 500;
908    use std::collections::HashMap;
909
910    let community = match load_community(&CommunityId(hex_id_to_32(community_id)?))? {
911        Some(c) => c,
912        None => return Ok(Vec::new()),
913    };
914    // The proven owner is ALWAYS a member of their own community. Seed them so a freshly-created
915    // community (no message/presence events yet) still shows its creator instead of an empty roster.
916    // `now_secs()` is just a presence baseline; real activity below overwrites it, and the UI re-sorts
917    // by role tier regardless.
918    let owner_b32: Option<String> = community
919        .owner_attestation
920        .as_deref()
921        .and_then(|att| crate::community::owner::verify_owner_attestation(att, community_id))
922        .and_then(|pk| pk.to_bech32().ok());
923
924    // Map each channel's hex id → its integer chat row id (skip channels with no events yet).
925    let mut chat_ints: Vec<i64> = Vec::new();
926    for ch in &community.channels {
927        if let Ok(cid) = super::id_cache::get_chat_id_by_identifier(&ch.id.to_hex()) {
928            chat_ints.push(cid);
929        }
930    }
931
932    // APPLICATION_SPECIFIC (30078) is the kind for presence/system events; everything else in a
933    // community channel is real message activity. Inlined as a constant integer (no injection).
934    let sys = crate::stored_event::event_kind::APPLICATION_SPECIFIC;
935
936    // active_at[npub] = newest real-activity time (any non-presence event), folded with joins below. The
937    // proven owner + roster grant-holders are NOT seeded here — they're re-asserted AFTER the leave/ban
938    // filter (else a stale message would overwrite the seed and a later `left` would wrongly cut a current
939    // admin — the retain-set inversion). See the re-assert block below.
940    let mut active: HashMap<String, u64> = HashMap::new();
941    let mut left: HashMap<String, u64> = HashMap::new();
942    // No channel has any events yet (e.g. fresh community) → skip the activity queries; the owner + roster
943    // are still surfaced by the post-filter re-assert below.
944    if !chat_ints.is_empty() {
945    let conn = super::get_db_connection_guard_static()?;
946    let placeholders = chat_ints.iter().map(|_| "?").collect::<Vec<_>>().join(",");
947
948    {
949        let sql = format!(
950            "SELECT npub, MAX(created_at) FROM events \
951             WHERE chat_id IN ({placeholders}) AND kind != {sys} AND npub IS NOT NULL AND npub != '' \
952             GROUP BY npub"
953        );
954        let mut stmt = conn.prepare(&sql).map_err(|e| e.to_string())?;
955        let rows = stmt
956            .query_map(rusqlite::params_from_iter(chat_ints.iter()), |r| {
957                Ok((r.get::<_, String>(0)?, r.get::<_, i64>(1)?.max(0) as u64))
958            })
959            .map_err(|e| e.to_string())?;
960        for row in rows {
961            let (npub, at) = row.map_err(|e| e.to_string())?;
962            active.insert(npub, at);
963        }
964    }
965
966    // Fold presence: a join (event-type "1") is activity; a leave (event-type "0") may remove.
967    {
968        let sql = format!(
969            "SELECT npub, created_at, tags FROM events \
970             WHERE chat_id IN ({placeholders}) AND kind = {sys} AND npub IS NOT NULL AND npub != ''"
971        );
972        let mut stmt = conn.prepare(&sql).map_err(|e| e.to_string())?;
973        let rows = stmt
974            .query_map(rusqlite::params_from_iter(chat_ints.iter()), |r| {
975                Ok((r.get::<_, String>(0)?, r.get::<_, i64>(1)?.max(0) as u64, r.get::<_, String>(2)?))
976            })
977            .map_err(|e| e.to_string())?;
978        for row in rows {
979            let (npub, at, tags_json) = row.map_err(|e| e.to_string())?;
980            // SystemEventType: 1 = MemberJoined, 0 = MemberLeft (carried in an ["event-type", n] tag).
981            let etype = serde_json::from_str::<Vec<Vec<String>>>(&tags_json)
982                .ok()
983                .and_then(|tags| {
984                    tags.into_iter()
985                        .find(|t| t.first().map(|s| s == "event-type").unwrap_or(false))
986                        .and_then(|t| t.into_iter().nth(1))
987                });
988            match etype.as_deref() {
989                Some("1") => {
990                    let e = active.entry(npub).or_insert(0);
991                    if at > *e { *e = at; }
992                }
993                Some("0") => {
994                    let e = left.entry(npub).or_insert(0);
995                    if at > *e { *e = at; }
996                }
997                _ => {}
998            }
999        }
1000    }
1001    }
1002
1003    // Exclude banned (banlist is hex; events store bech32 — compare on bech32). Denormalized
1004    // identically onto every channel at load, so reading channels[0] is sufficient.
1005    let banned: std::collections::HashSet<String> = community
1006        .channels
1007        .first()
1008        .map(|c| c.banned.iter().filter_map(|pk| pk.to_bech32().ok()).collect())
1009        .unwrap_or_default();
1010
1011    // Member iff active, not banned, and last activity is at-or-after the last leave.
1012    let mut out: Vec<(String, u64)> = active
1013        .into_iter()
1014        .filter(|(npub, at)| !banned.contains(npub) && left.get(npub).map_or(true, |l| at >= l))
1015        .collect();
1016
1017    // RE-ASSERT authorized members AFTER the activity/leave filter: the proven owner + every
1018    // non-empty-grant roster holder is a member regardless of stale activity or a `left` — a privatize/ban
1019    // retain set must NEVER silently shed an authorized member (a leave or an old message must not drop a
1020    // current admin; that read-cut would lock a sitting admin out of their own community). Banned is the
1021    // only exclusion (a ban revokes the role anyway). Stamped `now_secs()` so they sort to the top and
1022    // survive the cap. Computed POST-filter so neither the leave filter nor a stale overwrite can cut them.
1023    {
1024        let mut present: std::collections::HashSet<String> = out.iter().map(|(n, _)| n.clone()).collect();
1025        let mut reassert = |npub: String| {
1026            if !banned.contains(&npub) && present.insert(npub.clone()) {
1027                out.push((npub, now_secs() as u64));
1028            }
1029        };
1030        if let Some(o) = owner_b32 {
1031            reassert(o);
1032        }
1033        if let Ok(roles) = get_community_roles(community_id) {
1034            for g in &roles.grants {
1035                if g.role_ids.is_empty() {
1036                    continue; // an empty grant is a revoked role, not a member
1037                }
1038                if let Some(b32) = PublicKey::from_hex(&g.member).ok().and_then(|pk| pk.to_bech32().ok()) {
1039                    reassert(b32);
1040                }
1041            }
1042        }
1043    }
1044    out.sort_by(|a, b| b.1.cmp(&a.1));
1045    out.truncate(COMMUNITY_MEMBER_CAP);
1046    Ok(out)
1047}
1048
1049/// Per-link join counts for the owner's public invites: `label -> distinct joiners` who joined
1050/// via a link minted by `inviter_npub` (bech32). Reads the `invited-by` / `invited-label` tags on
1051/// MemberJoined system events; distinct by joiner npub so a rejoin isn't double-counted. Labels are
1052/// unique per creator (random fallback ensures it), so (inviter, label) keys a single link.
1053pub fn community_invite_join_counts(
1054    community_id: &str,
1055    inviter_npub: &str,
1056) -> Result<std::collections::HashMap<String, u64>, String> {
1057    use std::collections::{HashMap, HashSet};
1058    let community = match load_community(&CommunityId(hex_id_to_32(community_id)?))? {
1059        Some(c) => c,
1060        None => return Ok(HashMap::new()),
1061    };
1062    let mut chat_ints: Vec<i64> = Vec::new();
1063    for ch in &community.channels {
1064        if let Ok(cid) = super::id_cache::get_chat_id_by_identifier(&ch.id.to_hex()) {
1065            chat_ints.push(cid);
1066        }
1067    }
1068    if chat_ints.is_empty() {
1069        return Ok(HashMap::new());
1070    }
1071    let sys = crate::stored_event::event_kind::APPLICATION_SPECIFIC;
1072    let conn = super::get_db_connection_guard_static()?;
1073    let placeholders = chat_ints.iter().map(|_| "?").collect::<Vec<_>>().join(",");
1074    let sql = format!(
1075        "SELECT npub, tags FROM events \
1076         WHERE chat_id IN ({placeholders}) AND kind = {sys} AND npub IS NOT NULL AND npub != ''"
1077    );
1078    let mut stmt = conn.prepare(&sql).map_err(|e| e.to_string())?;
1079    let rows = stmt
1080        .query_map(rusqlite::params_from_iter(chat_ints.iter()), |r| {
1081            Ok((r.get::<_, String>(0)?, r.get::<_, String>(1)?))
1082        })
1083        .map_err(|e| e.to_string())?;
1084    // label -> set of distinct joiner npubs
1085    let mut per_label: HashMap<String, HashSet<String>> = HashMap::new();
1086    for row in rows {
1087        let (joiner, tags_json) = row.map_err(|e| e.to_string())?;
1088        let tags = match serde_json::from_str::<Vec<Vec<String>>>(&tags_json) {
1089            Ok(t) => t,
1090            Err(_) => continue,
1091        };
1092        let tag_val = |key: &str| -> Option<String> {
1093            tags.iter()
1094                .find(|t| t.first().map(|s| s == key).unwrap_or(false))
1095                .and_then(|t| t.get(1).cloned())
1096        };
1097        // MemberJoined (event-type "1") attributed to THIS owner's link, with a label.
1098        if tag_val("event-type").as_deref() != Some("1") {
1099            continue;
1100        }
1101        if tag_val("invited-by").as_deref() != Some(inviter_npub) {
1102            continue;
1103        }
1104        if let Some(label) = tag_val("invited-label") {
1105            per_label.entry(label).or_default().insert(joiner);
1106        }
1107    }
1108    Ok(per_label.into_iter().map(|(k, v)| (k, v.len() as u64)).collect())
1109}
1110
1111/// Replace a Community's stored banlist (JSON array of hex pubkeys) + the `created_at` (secs) of
1112/// the edition it came from. `at` is the version: the owner's own ban/unban writes its freshly
1113/// built event time, and `fetch_and_apply_banlist` only calls this with a strictly-newer edition,
1114/// so the stored banlist can never roll backwards.
1115pub fn set_community_banlist(community_id: &str, banned_hex: &[String], at: i64) -> Result<(), String> {
1116    let json = enc_txt(&serde_json::to_string(banned_hex).map_err(|e| e.to_string())?)?;
1117    let conn = super::get_write_connection_guard_static()?;
1118    conn.execute(
1119        "UPDATE communities SET banlist = ?1, banlist_at = ?2 WHERE community_id = ?3",
1120        params![json, at, community_id],
1121    )
1122    .map_err(|e| format!("set banlist: {e}"))?;
1123    Ok(())
1124}
1125
1126/// The `created_at` (secs) of the banlist edition currently stored, or 0 if none. The version
1127/// floor the rollback guard compares against.
1128pub fn get_community_banlist_at(community_id: &str) -> Result<i64, String> {
1129    let conn = super::get_db_connection_guard_static()?;
1130    let at: Option<i64> = conn
1131        .query_row(
1132            "SELECT banlist_at FROM communities WHERE community_id = ?1",
1133            params![community_id],
1134            |r| r.get(0),
1135        )
1136        .optional()
1137        .map_err(|e| format!("get banlist_at: {e}"))?;
1138    Ok(at.unwrap_or(0))
1139}
1140
1141/// Replace a Community's cached role graph (the aggregated `CommunityRoles`) + the `created_at`
1142/// (secs) of the newest per-entity edition it was built from. `at` is the version floor: the
1143/// fetch path only calls this with a strictly-newer aggregate, so the role graph can't roll
1144/// backwards (same guard as the banlist).
1145pub fn set_community_roles(
1146    community_id: &str,
1147    roles: &crate::community::roles::CommunityRoles,
1148    at: i64,
1149) -> Result<(), String> {
1150    let json = enc_txt(&serde_json::to_string(roles).map_err(|e| e.to_string())?)?;
1151    let conn = super::get_write_connection_guard_static()?;
1152    conn.execute(
1153        "UPDATE communities SET roles = ?1, roles_at = ?2 WHERE community_id = ?3",
1154        params![json, at, community_id],
1155    )
1156    .map_err(|e| format!("set roles: {e}"))?;
1157    Ok(())
1158}
1159
1160/// A Community's cached role graph. Empty (default) for an unknown community or none stored.
1161pub fn get_community_roles(
1162    community_id: &str,
1163) -> Result<crate::community::roles::CommunityRoles, String> {
1164    let conn = super::get_db_connection_guard_static()?;
1165    let json: Option<String> = conn
1166        .query_row(
1167            "SELECT roles FROM communities WHERE community_id = ?1",
1168            params![community_id],
1169            |r| r.get(0),
1170        )
1171        .optional()
1172        .map_err(|e| format!("get roles: {e}"))?;
1173    Ok(json.and_then(|j| serde_json::from_str(&dec_txt(&j)).ok()).unwrap_or_default())
1174}
1175
1176/// The `created_at` (secs) of the role-graph edition currently stored, or 0 if none.
1177pub fn get_community_roles_at(community_id: &str) -> Result<i64, String> {
1178    let conn = super::get_db_connection_guard_static()?;
1179    let at: Option<i64> = conn
1180        .query_row(
1181            "SELECT roles_at FROM communities WHERE community_id = ?1",
1182            params![community_id],
1183            |r| r.get(0),
1184        )
1185        .optional()
1186        .map_err(|e| format!("get roles_at: {e}"))?;
1187    Ok(at.unwrap_or(0))
1188}
1189
1190/// Record the current head (version + self_hash) of a control entity's edition chain (keyless model).
1191/// The send side reads this to emit the next edition as `version+1` citing `self_hash` as `prev_hash`;
1192/// the fold uses it as the per-entity refuse-downgrade floor + anchor. Upserts per (community, entity).
1193/// `inner_id` is the head edition's deterministic tiebreak key (used only by [`converge_edition_head`]).
1194pub fn set_edition_head(community_id: &str, entity_id: &str, version: u64, self_hash: &[u8; 32]) -> Result<(), String> {
1195    set_edition_head_inner(community_id, entity_id, version, self_hash, None)
1196}
1197
1198/// As [`set_edition_head`], but also records the head edition's `inner_id` (the deterministic tiebreak
1199/// key), so a later same-version convergence can rank against it. A plain advance carries it through.
1200pub fn set_edition_head_with_id(community_id: &str, entity_id: &str, version: u64, self_hash: &[u8; 32], inner_id: &[u8; 32]) -> Result<(), String> {
1201    set_edition_head_inner(community_id, entity_id, version, self_hash, Some(inner_id))
1202}
1203
1204fn set_edition_head_inner(community_id: &str, entity_id: &str, version: u64, self_hash: &[u8; 32], inner_id: Option<&[u8; 32]>) -> Result<(), String> {
1205    let conn = super::get_write_connection_guard_static()?;
1206    // MONOTONIC, EPOCH-PRIMARY: the head IS the refuse-downgrade floor. The recorded `epoch` is
1207    // the community's current server-root epoch (re-founding bumps it + resets versions to 1). A higher
1208    // epoch ALWAYS supersedes (so a re-founding's v1 lands over a held v21); within an epoch, version
1209    // still only advances. So a stale/hostile rollback can lower neither the epoch nor the in-epoch version.
1210    conn.execute(
1211        "INSERT INTO community_edition_heads (community_id, entity_id, version, self_hash, inner_id, epoch)
1212         VALUES (?1, ?2, ?3, ?4, ?5, COALESCE((SELECT server_root_epoch FROM communities WHERE community_id = ?1), 0))
1213         ON CONFLICT(community_id, entity_id) DO UPDATE SET
1214            version = excluded.version,
1215            self_hash = excluded.self_hash,
1216            inner_id = excluded.inner_id,
1217            epoch = excluded.epoch
1218         WHERE excluded.epoch > community_edition_heads.epoch
1219            OR (excluded.epoch = community_edition_heads.epoch AND excluded.version > community_edition_heads.version)",
1220        params![community_id, entity_id, version as i64, self_hash.as_slice(), inner_id.map(|i| i.as_slice())],
1221    )
1222    .map_err(|e| format!("set edition head: {e}"))?;
1223    Ok(())
1224}
1225
1226/// Converge the head to a same-version fork winner (concurrent-edit resolution). Unlike
1227/// [`set_edition_head`] (which only ADVANCES the version), this resolves a fork AT the current version:
1228/// two authorized editors editing concurrently from the same base both produce `version`, and every
1229/// client must adopt the SAME one. The winner is the lower deterministic `inner_id`, so this update
1230/// fires only when the incoming edition ties the stored version AND carries a strictly lower `inner_id`
1231/// — monotonic toward the global minimum, so it can never flip-flop (a relay can't churn the head by
1232/// reordering, and a held row with a NULL `inner_id`, pre-migration, is treated as "always replaceable"
1233/// so it heals to a ranked id). The version-advance path is unchanged and still handled by
1234/// [`set_edition_head_with_id`]; callers run BOTH (advance covers v+1, converge covers a same-v fork).
1235pub fn converge_edition_head(community_id: &str, entity_id: &str, version: u64, self_hash: &[u8; 32], inner_id: &[u8; 32]) -> Result<(), String> {
1236    let conn = super::get_write_connection_guard_static()?;
1237    // Scoped to the CURRENT epoch's head: a fork is resolved within an epoch, never across one (an epoch
1238    // bump is a re-founding, handled by the advance path). `epoch` matches the community's current epoch.
1239    conn.execute(
1240        "UPDATE community_edition_heads
1241            SET self_hash = ?4, inner_id = ?5
1242          WHERE community_id = ?1 AND entity_id = ?2
1243            AND version = ?3
1244            AND epoch = COALESCE((SELECT server_root_epoch FROM communities WHERE community_id = ?1), 0)
1245            AND (inner_id IS NULL OR ?5 < inner_id)",
1246        params![community_id, entity_id, version as i64, self_hash.as_slice(), inner_id.as_slice()],
1247    )
1248    .map_err(|e| format!("converge edition head: {e}"))?;
1249    Ok(())
1250}
1251
1252/// The held head's tiebreak key (`inner_id`), or `None` if unheld or pre-migration (NULL). The consumer
1253/// uses this to decide a same-version convergence exactly as [`converge_edition_head`]'s SQL does (a
1254/// NULL/None held id is "always replaceable") — so it never applies a display edit the head write would
1255/// then refuse.
1256pub fn get_edition_head_inner_id(community_id: &str, entity_id: &str) -> Result<Option<[u8; 32]>, String> {
1257    let conn = super::get_db_connection_guard_static()?;
1258    let row: Option<Option<Vec<u8>>> = conn
1259        .query_row(
1260            "SELECT inner_id FROM community_edition_heads WHERE community_id = ?1 AND entity_id = ?2",
1261            params![community_id, entity_id],
1262            |r| r.get(0),
1263        )
1264        .optional()
1265        .map_err(|e| format!("get edition head inner_id: {e}"))?;
1266    match row.flatten() {
1267        Some(blob) if blob.len() == 32 => {
1268            let mut h = [0u8; 32];
1269            h.copy_from_slice(&blob);
1270            Ok(Some(h))
1271        }
1272        _ => Ok(None),
1273    }
1274}
1275
1276/// The current head `(version, self_hash)` of a control entity's edition chain, or `None` if no
1277/// edition is held yet (so the next edition is the genesis, version 1, no prev_hash).
1278pub fn get_edition_head(community_id: &str, entity_id: &str) -> Result<Option<(u64, [u8; 32])>, String> {
1279    let conn = super::get_db_connection_guard_static()?;
1280    let row: Option<(i64, Vec<u8>)> = conn
1281        .query_row(
1282            "SELECT version, self_hash FROM community_edition_heads WHERE community_id = ?1 AND entity_id = ?2",
1283            params![community_id, entity_id],
1284            |r| Ok((r.get(0)?, r.get(1)?)),
1285        )
1286        .optional()
1287        .map_err(|e| format!("get edition head: {e}"))?;
1288    match row {
1289        Some((v, hash)) if hash.len() == 32 => {
1290            let mut h = [0u8; 32];
1291            h.copy_from_slice(&hash);
1292            Ok(Some((v as u64, h)))
1293        }
1294        _ => Ok(None),
1295    }
1296}
1297
1298/// The set of control-entity ids (hex) this account tracks a head for. A base rotation gates its
1299/// head-advance on re-anchoring covering EVERY one of these (not just a matching count), so a relay
1300/// that withholds one entity's editions while over-serving another's can't slip a thinned control
1301/// plane past the rotator.
1302pub fn edition_head_entity_ids(community_id: &str) -> Result<std::collections::HashSet<String>, String> {
1303    let conn = super::get_db_connection_guard_static()?;
1304    let mut stmt = conn
1305        .prepare("SELECT entity_id FROM community_edition_heads WHERE community_id = ?1")
1306        .map_err(|e| e.to_string())?;
1307    let rows = stmt
1308        .query_map(params![community_id], |r| r.get::<_, String>(0))
1309        .map_err(|e| e.to_string())?;
1310    let mut out = std::collections::HashSet::new();
1311    for row in rows {
1312        out.insert(row.map_err(|e| e.to_string())?);
1313    }
1314    Ok(out)
1315}
1316
1317
1318/// Every tracked control entity's persisted head `(entity_id hex → (version, self_hash))`. This is the
1319/// per-entity refuse-downgrade FLOOR: the fold seeds each entity's chain from its held head, so a
1320/// withholding relay serving editions BELOW what we already hold can't roll an authority chain back
1321/// (e.g. resurrecting a since-revoked admin's old grant). An empty map = a bootstrapping joiner (folds
1322/// from genesis, floor 0).
1323pub fn get_all_edition_heads(community_id: &str) -> Result<std::collections::HashMap<String, (u64, [u8; 32])>, String> {
1324    let conn = super::get_db_connection_guard_static()?;
1325    let mut stmt = conn
1326        .prepare("SELECT entity_id, version, self_hash FROM community_edition_heads WHERE community_id = ?1")
1327        .map_err(|e| e.to_string())?;
1328    let rows = stmt
1329        .query_map(params![community_id], |r| {
1330            Ok((r.get::<_, String>(0)?, r.get::<_, i64>(1)?, r.get::<_, Vec<u8>>(2)?))
1331        })
1332        .map_err(|e| e.to_string())?;
1333    let mut out = std::collections::HashMap::new();
1334    for row in rows {
1335        let (entity, version, hash) = row.map_err(|e| e.to_string())?;
1336        if hash.len() == 32 {
1337            let mut h = [0u8; 32];
1338            h.copy_from_slice(&hash);
1339            out.insert(entity, (version as u64, h));
1340        }
1341    }
1342    Ok(out)
1343}
1344
1345/// Every tracked head as `entity_hex → (epoch, version, self_hash)` — the epoch-primary floor.
1346/// The caller seeds the fold with ONLY the entities at the community's CURRENT epoch (a head recorded
1347/// at a PRIOR epoch belongs to a superseded founding, so its entity folds fresh from the new epoch's v1
1348/// genesis). This is what lets a re-founding's compacted v1 plane land without a version-only downgrade.
1349pub fn get_all_edition_heads_epoched(community_id: &str) -> Result<std::collections::HashMap<String, (u64, u64, [u8; 32])>, String> {
1350    let conn = super::get_db_connection_guard_static()?;
1351    let mut stmt = conn
1352        .prepare("SELECT entity_id, epoch, version, self_hash FROM community_edition_heads WHERE community_id = ?1")
1353        .map_err(|e| e.to_string())?;
1354    let rows = stmt
1355        .query_map(params![community_id], |r| {
1356            Ok((r.get::<_, String>(0)?, r.get::<_, i64>(1)?, r.get::<_, i64>(2)?, r.get::<_, Vec<u8>>(3)?))
1357        })
1358        .map_err(|e| e.to_string())?;
1359    let mut out = std::collections::HashMap::new();
1360    for row in rows {
1361        let (entity, epoch, version, hash) = row.map_err(|e| e.to_string())?;
1362        if hash.len() == 32 {
1363            let mut h = [0u8; 32];
1364            h.copy_from_slice(&hash);
1365            out.insert(entity, (epoch as u64, version as u64, h));
1366        }
1367    }
1368    Ok(out)
1369}
1370
1371/// A Community's current banlist (hex pubkeys). Empty for an unknown community or empty list.
1372pub fn get_community_banlist(community_id: &str) -> Result<Vec<String>, String> {
1373    let conn = super::get_db_connection_guard_static()?;
1374    let json: Option<String> = conn
1375        .query_row(
1376            "SELECT banlist FROM communities WHERE community_id = ?1",
1377            params![community_id],
1378            |r| r.get(0),
1379        )
1380        .optional()
1381        .map_err(|e| format!("get banlist: {e}"))?;
1382    Ok(json.and_then(|j| serde_json::from_str(&dec_txt(&j)).ok()).unwrap_or_default())
1383}
1384
1385/// Replace a Community's cached invite-link registry (active link locators, hex), folded from the
1386/// owner-signed vsk=5 edition. Empty = Private. The version floor lives in `community_edition_heads`
1387/// (the registry's own entity), so this is just the content cache (mirrors `set_community_banlist`).
1388pub fn set_community_invite_registry(community_id: &str, link_locators: &[String]) -> Result<(), String> {
1389    let json = enc_txt(&serde_json::to_string(link_locators).map_err(|e| e.to_string())?)?;
1390    let conn = super::get_write_connection_guard_static()?;
1391    conn.execute(
1392        "UPDATE communities SET invite_registry = ?1 WHERE community_id = ?2",
1393        params![json, community_id],
1394    )
1395    .map_err(|e| format!("set invite registry: {e}"))?;
1396    Ok(())
1397}
1398
1399/// A Community's current invite-link registry (active link locators, hex). Empty for an unknown
1400/// community or a Private one. `is_public` = this is non-empty (computed mode).
1401pub fn get_community_invite_registry(community_id: &str) -> Result<Vec<String>, String> {
1402    let conn = super::get_db_connection_guard_static()?;
1403    let json: Option<String> = conn
1404        .query_row(
1405            "SELECT invite_registry FROM communities WHERE community_id = ?1",
1406            params![community_id],
1407            |r| r.get(0),
1408        )
1409        .optional()
1410        .map_err(|e| format!("get invite registry: {e}"))?;
1411    Ok(json.and_then(|j| serde_json::from_str(&dec_txt(&j)).ok()).unwrap_or_default())
1412}
1413
1414/// A folded per-creator public-invite-link set: the creator's pubkey (hex) and their active link
1415/// locators. Used to surface "X has N active invite links" in the UI.
1416pub struct InviteLinkSetRow {
1417    pub creator_hex: String,
1418    pub locators: Vec<String>,
1419}
1420
1421/// Replace ALL of a Community's per-creator invite-link sets with the freshly-folded set (latest-wins).
1422/// Replacing wholesale (not upserting) drops a creator who has revoked every link, so the per-creator
1423/// view stays in lockstep with the flat registry computed in the same fold.
1424pub fn replace_invite_link_sets(community_id: &str, sets: &[InviteLinkSetRow]) -> Result<(), String> {
1425    let mut conn = super::get_write_connection_guard_static()?;
1426    let tx = conn.transaction().map_err(|e| format!("invite-link-sets tx: {e}"))?;
1427    tx.execute("DELETE FROM community_invite_link_sets WHERE community_id = ?1", params![community_id])
1428        .map_err(|e| format!("clear invite-link-sets: {e}"))?;
1429    for s in sets {
1430        if s.locators.is_empty() {
1431            continue; // a creator with no active links is just absent (count 0)
1432        }
1433        let enc_creator = enc_txt(&s.creator_hex)?;
1434        let enc_locators = enc_txt(&serde_json::to_string(&s.locators).map_err(|e| e.to_string())?)?;
1435        // Plain INSERT: the DELETE above cleared the community's rows and `sets` has distinct creators
1436        // (an encrypted creator can't act as a dedup key anyway — random nonce per write).
1437        tx.execute(
1438            "INSERT INTO community_invite_link_sets (community_id, creator, locators) VALUES (?1, ?2, ?3)",
1439            params![community_id, enc_creator, enc_locators],
1440        )
1441        .map_err(|e| format!("insert invite-link-set: {e}"))?;
1442    }
1443    tx.commit().map_err(|e| format!("commit invite-link-sets: {e}"))?;
1444    Ok(())
1445}
1446
1447/// Upsert ONE creator's invite-link set (optimistic local update after the local user mints/revokes their
1448/// own links, mirroring the flat-registry merge). An empty set removes the row.
1449pub fn upsert_invite_link_set(community_id: &str, creator_hex: &str, locators: &[String]) -> Result<(), String> {
1450    let conn = super::get_write_connection_guard_static()?;
1451    // `creator` is encrypted (random nonce), so locate any existing row by decrypting + matching.
1452    let existing_rowid: Option<i64> = {
1453        let mut stmt = conn
1454            .prepare("SELECT rowid, creator FROM community_invite_link_sets WHERE community_id = ?1")
1455            .map_err(|e| e.to_string())?;
1456        let rows = stmt
1457            .query_map(params![community_id], |r| Ok((r.get::<_, i64>(0)?, r.get::<_, String>(1)?)))
1458            .map_err(|e| e.to_string())?;
1459        let mut found = None;
1460        for row in rows {
1461            let (rowid, stored) = row.map_err(|e| e.to_string())?;
1462            if dec_txt(&stored) == creator_hex {
1463                found = Some(rowid);
1464                break;
1465            }
1466        }
1467        found
1468    };
1469    if locators.is_empty() {
1470        if let Some(rowid) = existing_rowid {
1471            conn.execute("DELETE FROM community_invite_link_sets WHERE rowid = ?1", params![rowid])
1472                .map_err(|e| format!("delete invite-link-set: {e}"))?;
1473        }
1474        return Ok(());
1475    }
1476    let enc_locators = enc_txt(&serde_json::to_string(locators).map_err(|e| e.to_string())?)?;
1477    match existing_rowid {
1478        Some(rowid) => {
1479            conn.execute(
1480                "UPDATE community_invite_link_sets SET locators = ?1 WHERE rowid = ?2",
1481                params![enc_locators, rowid],
1482            )
1483            .map_err(|e| format!("upsert invite-link-set: {e}"))?;
1484        }
1485        None => {
1486            let enc_creator = enc_txt(creator_hex)?;
1487            conn.execute(
1488                "INSERT INTO community_invite_link_sets (community_id, creator, locators) VALUES (?1, ?2, ?3)",
1489                params![community_id, enc_creator, enc_locators],
1490            )
1491            .map_err(|e| format!("upsert invite-link-set: {e}"))?;
1492        }
1493    }
1494    Ok(())
1495}
1496
1497/// Every creator's active invite-link set for a Community (creator hex + locators). Empty for a Private
1498/// community (or one not yet re-folded since this table was added).
1499pub fn get_invite_link_sets(community_id: &str) -> Result<Vec<InviteLinkSetRow>, String> {
1500    let conn = super::get_db_connection_guard_static()?;
1501    let mut stmt = conn
1502        .prepare("SELECT creator, locators FROM community_invite_link_sets WHERE community_id = ?1")
1503        .map_err(|e| format!("prepare invite-link-sets: {e}"))?;
1504    let rows = stmt
1505        .query_map(params![community_id], |r| {
1506            let creator_hex: String = r.get(0)?;
1507            let json: String = r.get(1)?;
1508            Ok((creator_hex, json))
1509        })
1510        .map_err(|e| format!("query invite-link-sets: {e}"))?;
1511    let mut out = Vec::new();
1512    for row in rows {
1513        let (creator_hex, json) = row.map_err(|e| format!("row invite-link-sets: {e}"))?;
1514        let locators: Vec<String> = serde_json::from_str(&dec_txt(&json)).unwrap_or_default();
1515        out.push(InviteLinkSetRow { creator_hex: dec_txt(&creator_hex), locators });
1516    }
1517    Ok(out)
1518}
1519
1520/// Mark (or clear) that a PRIVATE-community ban's base re-seal (read-cut) is OUTSTANDING — set when
1521/// the re-seal is attempted and cleared only when it succeeds, so a transient failure is retried later
1522/// instead of silently leaving a banned member with read access.
1523pub fn set_read_cut_pending(community_id: &str, pending: bool) -> Result<(), String> {
1524    let conn = super::get_write_connection_guard_static()?;
1525    conn.execute(
1526        "UPDATE communities SET read_cut_pending = ?1 WHERE community_id = ?2",
1527        params![pending as i64, community_id],
1528    )
1529    .map_err(|e| format!("set read_cut_pending: {e}"))?;
1530    Ok(())
1531}
1532
1533/// Set the owner-dissolution SEAL on a community — PERMANENT + irreversible (no clear path; there
1534/// is no un-dissolve). Idempotent: re-setting an already-dissolved community is a harmless no-op. Once
1535/// set, the control fold stops advancing and the inbound path drops every subsequent event.
1536pub fn set_community_dissolved(community_id: &str) -> Result<(), String> {
1537    let conn = super::get_write_connection_guard_static()?;
1538    conn.execute(
1539        "UPDATE communities SET dissolved = 1 WHERE community_id = ?1",
1540        params![community_id],
1541    )
1542    .map_err(|e| format!("set dissolved: {e}"))?;
1543    Ok(())
1544}
1545
1546/// Whether a community has been sealed by a folded + owner-verified GroupDissolved tombstone.
1547/// `false` for an unknown community.
1548pub fn get_community_dissolved(community_id: &str) -> Result<bool, String> {
1549    let conn = super::get_db_connection_guard_static()?;
1550    let v: Option<i64> = conn
1551        .query_row(
1552            "SELECT dissolved FROM communities WHERE community_id = ?1",
1553            params![community_id],
1554            |r| r.get(0),
1555        )
1556        .optional()
1557        .map_err(|e| format!("get dissolved: {e}"))?;
1558    Ok(v.unwrap_or(0) != 0)
1559}
1560
1561/// Whether a PRIVATE-community read-cut re-seal is still outstanding (a prior attempt failed). The ban
1562/// flow retries the re-seal whenever this is set. `false` for an unknown community.
1563pub fn get_read_cut_pending(community_id: &str) -> Result<bool, String> {
1564    let conn = super::get_db_connection_guard_static()?;
1565    let v: Option<i64> = conn
1566        .query_row(
1567            "SELECT read_cut_pending FROM communities WHERE community_id = ?1",
1568            params![community_id],
1569            |r| r.get(0),
1570        )
1571        .optional()
1572        .map_err(|e| format!("get read_cut_pending: {e}"))?;
1573    Ok(v.unwrap_or(0) != 0)
1574}
1575
1576/// Set the base epoch a pending read-cut (re-founding) must reach. The re-seal rotates the base only
1577/// while `server_root_epoch < target`, so a retry never double-rotates a base that already advanced. Set
1578/// to `server_root_epoch + 1` on a fresh exclusion delta (ban add / privatize); left untouched on a pure
1579/// resume so the in-flight target is preserved.
1580pub fn set_read_cut_target_epoch(community_id: &str, target: u64) -> Result<(), String> {
1581    let conn = super::get_write_connection_guard_static()?;
1582    conn.execute(
1583        "UPDATE communities SET read_cut_target_epoch = ?1 WHERE community_id = ?2",
1584        params![target as i64, community_id],
1585    )
1586    .map_err(|e| format!("set read_cut_target_epoch: {e}"))?;
1587    Ok(())
1588}
1589
1590/// The base epoch a pending read-cut must reach (see [`set_read_cut_target_epoch`]). `0` for an unknown
1591/// community. Reinterpreted i64->u64 (lossless) for epochs >= 2^63.
1592pub fn get_read_cut_target_epoch(community_id: &str) -> Result<u64, String> {
1593    let conn = super::get_db_connection_guard_static()?;
1594    let v: Option<i64> = conn
1595        .query_row(
1596            "SELECT read_cut_target_epoch FROM communities WHERE community_id = ?1",
1597            params![community_id],
1598            |r| r.get(0),
1599        )
1600        .optional()
1601        .map_err(|e| format!("get read_cut_target_epoch: {e}"))?;
1602    Ok(v.unwrap_or(0) as u64)
1603}
1604
1605/// The base (server-root) epoch a channel was last rekeyed FOR during a read-cut — the per-channel
1606/// progress marker that lets a resumed re-founding skip channels already cut. `0` if unknown.
1607pub fn channel_rekeyed_at_server_epoch(community_id: &str, channel_id: &str) -> Result<u64, String> {
1608    let conn = super::get_db_connection_guard_static()?;
1609    let v: Option<i64> = conn
1610        .query_row(
1611            "SELECT rekeyed_at_server_epoch FROM community_channels WHERE community_id = ?1 AND channel_id = ?2",
1612            params![community_id, channel_id],
1613            |r| r.get(0),
1614        )
1615        .optional()
1616        .map_err(|e| format!("get rekeyed_at_server_epoch: {e}"))?;
1617    Ok(v.unwrap_or(0) as u64)
1618}
1619
1620/// Record that a channel's key has been rotated to cover base epoch `server_epoch` (a read-cut step).
1621/// Best-effort progress marker: written after the channel rekey lands, so a crash before it just re-rotates
1622/// the channel on resume (safe, the rekey is monotonic) rather than skipping a channel that needed cutting.
1623pub fn mark_channel_rekeyed_at_server_epoch(community_id: &str, channel_id: &str, server_epoch: u64) -> Result<(), String> {
1624    let conn = super::get_write_connection_guard_static()?;
1625    conn.execute(
1626        "UPDATE community_channels SET rekeyed_at_server_epoch = ?1 WHERE community_id = ?2 AND channel_id = ?3",
1627        params![server_epoch as i64, community_id, channel_id],
1628    )
1629    .map_err(|e| format!("mark rekeyed_at_server_epoch: {e}"))?;
1630    Ok(())
1631}
1632
1633/// Ids of every locally-stored Community.
1634pub fn list_community_ids() -> Result<Vec<CommunityId>, String> {
1635    let conn = super::get_db_connection_guard_static()?;
1636    let mut stmt = conn
1637        .prepare("SELECT community_id FROM communities ORDER BY created_at")
1638        .map_err(|e| e.to_string())?;
1639    let rows = stmt
1640        .query_map([], |r| r.get::<_, String>(0))
1641        .map_err(|e| e.to_string())?;
1642    let mut ids = Vec::new();
1643    for row in rows {
1644        ids.push(CommunityId(hex_id_to_32(&row.map_err(|e| e.to_string())?)?));
1645    }
1646    Ok(ids)
1647}
1648
1649#[cfg(test)]
1650mod tests {
1651    use super::*;
1652
1653    static TEST_COUNTER: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(0);
1654
1655    /// A unique, syntactically-valid test npub per call (bech32 charset, correct
1656    /// length). Uniqueness isolates each test's account DB so state can't bleed.
1657    fn make_test_npub(n: u32) -> String {
1658        const BECH32: &[u8] = b"qpzry9x8gf2tvdw0s3jn54khce6mua7l";
1659        let mut payload = vec![b'q'; 58];
1660        let mut x = n as u64;
1661        let mut i = 58;
1662        while x > 0 && i > 0 {
1663            i -= 1;
1664            payload[i] = BECH32[(x as usize) % 32];
1665            x /= 32;
1666        }
1667        format!("npub1{}", std::str::from_utf8(&payload).unwrap())
1668    }
1669
1670    fn init_test_db() -> (tempfile::TempDir, std::sync::MutexGuard<'static, ()>) {
1671        let guard = crate::db::DB_TEST_GUARD.lock().unwrap_or_else(|e| e.into_inner());
1672        crate::db::close_database();
1673        let tmp = tempfile::tempdir().unwrap();
1674        let n = TEST_COUNTER.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
1675        let account = make_test_npub(n);
1676        std::fs::create_dir_all(tmp.path().join(&account)).unwrap();
1677        crate::db::set_app_data_dir(tmp.path().to_path_buf());
1678        crate::db::set_current_account(account.clone()).unwrap();
1679        crate::db::init_database(&account).unwrap();
1680        (tmp, guard)
1681    }
1682
1683    #[test]
1684    fn edition_head_round_trips_and_upserts() {
1685        let (_tmp, _guard) = init_test_db();
1686        let cid = "f".repeat(64);
1687        let entity = "a".repeat(64);
1688
1689        // No head yet → None (the next edition is genesis v1).
1690        assert_eq!(get_edition_head(&cid, &entity).unwrap(), None);
1691
1692        // Set v1, read it back exactly.
1693        let h1 = [0x11u8; 32];
1694        set_edition_head(&cid, &entity, 1, &h1).unwrap();
1695        assert_eq!(get_edition_head(&cid, &entity).unwrap(), Some((1, h1)));
1696
1697        // Upsert to v2 — the head advances in place (one row per (community, entity)).
1698        let h2 = [0x22u8; 32];
1699        set_edition_head(&cid, &entity, 2, &h2).unwrap();
1700        assert_eq!(get_edition_head(&cid, &entity).unwrap(), Some((2, h2)));
1701
1702        // MONOTONIC: a lower-or-equal version write is a no-op — the refuse-downgrade floor never
1703        // rolls back, even against a stale or hostile rollback attempt.
1704        set_edition_head(&cid, &entity, 1, &[0xEEu8; 32]).unwrap();
1705        assert_eq!(get_edition_head(&cid, &entity).unwrap(), Some((2, h2)), "rollback to v1 ignored");
1706        set_edition_head(&cid, &entity, 2, &[0xEEu8; 32]).unwrap();
1707        assert_eq!(get_edition_head(&cid, &entity).unwrap(), Some((2, h2)), "equal version is a no-op too");
1708
1709        // A different entity is tracked independently.
1710        let other = "b".repeat(64);
1711        assert_eq!(get_edition_head(&cid, &other).unwrap(), None);
1712    }
1713
1714    #[test]
1715    fn server_root_epoch_round_trips() {
1716        // The base read clock survives save/load (default 0; a rotated value preserved exactly).
1717        let (_tmp, _guard) = init_test_db();
1718        let mut c = Community::create("HQ", "general", vec![]);
1719        save_community(&c).unwrap();
1720        assert_eq!(load_community(&c.id).unwrap().unwrap().server_root_epoch, Epoch(0));
1721
1722        c.server_root_epoch = Epoch(5);
1723        c.server_root_key = ServerRootKey([0x42u8; 32]);
1724        save_community(&c).unwrap();
1725        let loaded = load_community(&c.id).unwrap().unwrap();
1726        assert_eq!(loaded.server_root_epoch, Epoch(5));
1727        assert_eq!(loaded.server_root_key.as_bytes(), &[0x42u8; 32]);
1728    }
1729
1730    #[test]
1731    fn epoch_key_archive_retains_every_epoch() {
1732        // a member who lived through a rotation must keep OLD epoch keys. Storing a new
1733        // epoch's key must NOT clobber a prior one (the data-loss bug the archive fixes).
1734        let (_tmp, _guard) = init_test_db();
1735        let cid = "f".repeat(64);
1736        let scope = "a".repeat(64);
1737
1738        store_epoch_key(&cid, &scope, 0, &[0xA0u8; 32]).unwrap();
1739        store_epoch_key(&cid, &scope, 1, &[0xA1u8; 32]).unwrap();
1740        store_epoch_key(&cid, &scope, 2, &[0xA2u8; 32]).unwrap();
1741
1742        let held = held_epoch_keys(&cid, &scope).unwrap();
1743        assert_eq!(held.len(), 3, "all three epoch keys retained");
1744        assert_eq!(held[0], (Epoch(0), [0xA0u8; 32]));
1745        assert_eq!(held[1], (Epoch(1), [0xA1u8; 32]));
1746        assert_eq!(held[2], (Epoch(2), [0xA2u8; 32]));
1747
1748        // Point lookup by epoch (what the open path uses to select a decryption key).
1749        assert_eq!(held_epoch_key(&cid, &scope, 1).unwrap(), Some([0xA1u8; 32]));
1750        assert_eq!(held_epoch_key(&cid, &scope, 9).unwrap(), None, "unheld epoch is None");
1751
1752        // Same coordinate REPLACE = fork-resolution committing a winning key (only legit overwrite).
1753        store_epoch_key(&cid, &scope, 1, &[0xBBu8; 32]).unwrap();
1754        assert_eq!(held_epoch_key(&cid, &scope, 1).unwrap(), Some([0xBBu8; 32]));
1755        assert_eq!(held_epoch_keys(&cid, &scope).unwrap().len(), 3, "replace didn't add a row");
1756
1757        // A different scope is isolated (server-root vs a channel share the table, never collide) —
1758        // at both the list AND the point-lookup level.
1759        assert!(held_epoch_keys(&cid, crate::community::SERVER_ROOT_SCOPE_HEX).unwrap().is_empty());
1760        assert_eq!(
1761            held_epoch_key(&cid, crate::community::SERVER_ROOT_SCOPE_HEX, 1).unwrap(),
1762            None,
1763            "epoch 1 under a different scope is not the channel's key"
1764        );
1765    }
1766
1767    #[test]
1768    fn save_community_populates_the_epoch_archive() {
1769        // save_community mirrors the current base + channel keys into the multi-held archive, so the
1770        // foundation is live without any explicit store_epoch_key call by the caller.
1771        let (_tmp, _guard) = init_test_db();
1772        let c = Community::create("HQ", "general", vec![]);
1773        save_community(&c).unwrap();
1774        let cid = c.id.to_hex();
1775
1776        // Base key archived under the server-root sentinel at epoch 0.
1777        assert_eq!(
1778            held_epoch_key(&cid, crate::community::SERVER_ROOT_SCOPE_HEX, 0).unwrap().as_ref(),
1779            Some(c.server_root_key.as_bytes())
1780        );
1781        // The default channel's key archived under its channel id at epoch 0.
1782        let chan = &c.channels[0];
1783        assert_eq!(
1784            held_epoch_key(&cid, &chan.id.to_hex(), 0).unwrap().as_ref(),
1785            Some(chan.key.as_bytes())
1786        );
1787    }
1788
1789    #[test]
1790    fn at_rest_encryption_wraps_keys_and_metadata_on_disk() {
1791        let (_tmp, _guard) = init_test_db();
1792        // Local Encryption ON with a known vault key (the db-test guard serializes, so toggling these
1793        // globals is safe; reset at the end). `others: &[]` — the slice only allocates a vault lane.
1794        crate::state::ENCRYPTION_KEY.set([0x55u8; 32], &[]);
1795        crate::state::set_encryption_enabled(true);
1796
1797        let mut c = Community::create("Secret HQ", "general", vec!["wss://relay.example".into()]);
1798        c.server_root_key = ServerRootKey([0x42u8; 32]);
1799        c.description = Some("top secret".into());
1800        save_community(&c).unwrap();
1801        let cid = c.id.to_hex();
1802        set_community_banlist(&cid, &["deadbeef".repeat(8)], 1).unwrap();
1803
1804        // On disk: secrets are 60-byte ciphertext (12 nonce + 32 + 16 tag), NOT raw 32-byte keys;
1805        // identifying text is hex ciphertext, never the plaintext.
1806        {
1807            let conn = crate::db::get_db_connection_guard_static().unwrap();
1808            let (root_len, name, banlist): (i64, String, String) = conn
1809                .query_row(
1810                    "SELECT length(server_root_key), name, banlist FROM communities WHERE community_id = ?1",
1811                    params![cid],
1812                    |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)),
1813                )
1814                .unwrap();
1815            assert_eq!(root_len, 60, "server_root_key must be ciphertext, not a raw 32-byte key");
1816            assert_ne!(name, "Secret HQ", "name must not be plaintext on disk");
1817            assert!(crate::crypto::looks_encrypted(&name), "name column is ciphertext");
1818            assert!(crate::crypto::looks_encrypted(&banlist), "banlist column is ciphertext");
1819            let key_len: i64 = conn
1820                .query_row(
1821                    "SELECT length(key) FROM community_epoch_keys WHERE community_id = ?1 LIMIT 1",
1822                    params![cid],
1823                    |r| r.get(0),
1824                )
1825                .unwrap();
1826            assert_eq!(key_len, 60, "epoch-archive key must be ciphertext");
1827        }
1828
1829        // In memory: load decrypts everything back to the originals.
1830        let loaded = load_community(&c.id).unwrap().unwrap();
1831        assert_eq!(loaded.name, "Secret HQ");
1832        assert_eq!(loaded.description.as_deref(), Some("top secret"));
1833        assert_eq!(loaded.server_root_key.as_bytes(), &[0x42u8; 32]);
1834        assert_eq!(loaded.relays, vec!["wss://relay.example".to_string()]);
1835        assert_eq!(get_community_banlist(&cid).unwrap(), vec!["deadbeef".repeat(8)]);
1836
1837        crate::state::set_encryption_enabled(false);
1838        crate::state::ENCRYPTION_KEY.clear(&[]);
1839    }
1840
1841    #[test]
1842    fn at_rest_decrypt_tolerates_a_pre_migration_plaintext_row() {
1843        // A row written BEFORE the at-rest pass (raw key + plaintext text) must still read back once
1844        // encryption is on — the 32-vs-60 byte + `looks_encrypted` discriminators handle the mixed DB.
1845        let (_tmp, _guard) = init_test_db();
1846        crate::state::set_encryption_enabled(false);
1847        let mut c = Community::create("Legacy HQ", "general", vec![]);
1848        c.server_root_key = ServerRootKey([0x42u8; 32]);
1849        save_community(&c).unwrap();
1850
1851        crate::state::ENCRYPTION_KEY.set([0x55u8; 32], &[]);
1852        crate::state::set_encryption_enabled(true);
1853        let loaded = load_community(&c.id).unwrap().unwrap();
1854        assert_eq!(loaded.name, "Legacy HQ", "plaintext name reads through");
1855        assert_eq!(loaded.server_root_key.as_bytes(), &[0x42u8; 32], "raw 32-byte key reads through");
1856
1857        crate::state::set_encryption_enabled(false);
1858        crate::state::ENCRYPTION_KEY.clear(&[]);
1859    }
1860
1861    #[test]
1862    fn save_and_load_round_trip() {
1863        let (_tmp, _guard) = init_test_db();
1864        let original = Community::create("Vector HQ", "general", vec!["wss://r.one".into()]);
1865        save_community(&original).unwrap();
1866
1867        let loaded = load_community(&original.id).unwrap().expect("present");
1868        assert_eq!(loaded.id, original.id);
1869        assert_eq!(loaded.name, "Vector HQ");
1870        assert_eq!(loaded.relays, original.relays);
1871        // Secrets survive the round trip byte-for-byte.
1872        assert_eq!(loaded.server_root_key.as_bytes(), original.server_root_key.as_bytes());
1873        // Channel survives with its key, epoch, and name.
1874        assert_eq!(loaded.channels.len(), 1);
1875        assert_eq!(loaded.channels[0].id, original.channels[0].id);
1876        assert_eq!(loaded.channels[0].key.as_bytes(), original.channels[0].key.as_bytes());
1877        assert_eq!(loaded.channels[0].epoch, Epoch(0));
1878        assert_eq!(loaded.channels[0].name, "general");
1879    }
1880
1881    #[test]
1882    fn owner_is_protected_from_the_banlist_a_member_is_not() {
1883        use nostr_sdk::JsonUtil;
1884        let (_tmp, _guard) = init_test_db();
1885        let mut community = Community::create("HQ", "general", vec!["wss://r".into()]);
1886        // Give it a proven owner (index 0).
1887        let owner_id = Keys::new(SecretKey::from_slice(&[7u8; 32]).unwrap());
1888        community.owner_attestation = Some(
1889            crate::community::owner::build_owner_attestation_unsigned(
1890                owner_id.public_key(),
1891                &community.id.to_hex(),
1892            )
1893            .sign_with_keys(&owner_id)
1894            .unwrap()
1895            .as_json(),
1896        );
1897        save_community(&community).unwrap();
1898
1899        // A banlist naming BOTH the owner and a regular member.
1900        let member = Keys::generate();
1901        set_community_banlist(
1902            &community.id.to_hex(),
1903            &[owner_id.public_key().to_hex(), member.public_key().to_hex()],
1904            1,
1905        )
1906        .unwrap();
1907
1908        let loaded = load_community(&community.id).unwrap().unwrap();
1909        let ch = &loaded.channels[0];
1910        // The owner is filtered OUT of the effective banlist (index 0 can't be banned)...
1911        assert!(!ch.banned.contains(&owner_id.public_key()), "owner is never effectively banned");
1912        assert!(ch.protected.contains(&owner_id.public_key()), "owner is in the protected set");
1913        // ...but a regular member's ban stands.
1914        assert!(ch.banned.contains(&member.public_key()), "a member's ban is honored");
1915    }
1916
1917    #[test]
1918    fn loaded_keys_actually_decrypt() {
1919        // The reconstructed keys must be usable: seal with the original channel key,
1920        // open with the loaded one (proves the blob round-trip preserved key bytes).
1921        let (_tmp, _guard) = init_test_db();
1922        let original = Community::create("HQ", "general", vec![]);
1923        save_community(&original).unwrap();
1924        let loaded = load_community(&original.id).unwrap().unwrap();
1925
1926        let author = nostr_sdk::prelude::Keys::generate();
1927        let chan = &original.channels[0];
1928        let sealed = crate::community::envelope::seal_message(
1929            &author, &chan.key, &chan.id, chan.epoch, "persisted!", 1,
1930        )
1931        .unwrap();
1932        let opened = crate::community::envelope::open_message(
1933            &sealed,
1934            &loaded.channels[0].key,
1935            &loaded.channels[0].id,
1936            loaded.channels[0].epoch,
1937        )
1938        .unwrap();
1939        assert_eq!(opened.content, "persisted!");
1940    }
1941
1942    #[test]
1943    fn member_view_round_trips() {
1944        // A joined member-view Community (keyless) persists + reloads with its
1945        // server-root + channel keys intact.
1946        let (_tmp, _guard) = init_test_db();
1947        let member = Community {
1948            id: CommunityId([7u8; 32]),
1949            server_root_key: ServerRootKey([8u8; 32]),
1950            server_root_epoch: Epoch(0),
1951            name: "Joined".into(),
1952            description: None,
1953            icon: None,
1954            banner: None,
1955            relays: vec!["wss://r".into()],
1956            channels: vec![Channel {
1957                id: ChannelId([9u8; 32]),
1958                key: ChannelKey([10u8; 32]),
1959                epoch: Epoch(0),
1960                name: "general".into(),
1961                banned: Vec::new(),
1962                protected: Vec::new(), roster: Default::default(),
1963                epoch_keys: Vec::new(),
1964                dissolved: false,
1965            }],
1966            owner_attestation: None,
1967            dissolved: false,
1968        };
1969        save_community(&member).unwrap();
1970        let loaded = load_community(&member.id).unwrap().expect("present");
1971        assert_eq!(loaded.server_root_key.as_bytes(), &[8u8; 32]);
1972        assert_eq!(loaded.channels[0].key.as_bytes(), &[10u8; 32]);
1973    }
1974
1975    #[test]
1976    fn large_epoch_round_trips_losslessly() {
1977        // Epoch >= 2^63 stored as i64 then reinterpreted as u64 must be exact.
1978        let (_tmp, _guard) = init_test_db();
1979        let mut c = Community::create("HQ", "g", vec![]);
1980        c.channels[0].epoch = Epoch(u64::MAX - 7);
1981        save_community(&c).unwrap();
1982        let loaded = load_community(&c.id).unwrap().unwrap();
1983        assert_eq!(loaded.channels[0].epoch, Epoch(u64::MAX - 7));
1984    }
1985
1986    #[test]
1987    fn malformed_channel_id_row_errors_not_corrupts() {
1988        // A corrupted (short/non-hex) channel_id must error on load, not silently
1989        // reconstruct a wrong-but-self-consistent id.
1990        let (_tmp, _guard) = init_test_db();
1991        let c = Community::create("HQ", "g", vec![]);
1992        save_community(&c).unwrap();
1993        {
1994            let conn = crate::db::get_write_connection_guard_static().unwrap();
1995            conn.execute(
1996                "INSERT OR REPLACE INTO community_channels
1997                    (channel_id, community_id, channel_key, epoch, name, created_at)
1998                 VALUES (?1, ?2, ?3, 0, 'bad', 0)",
1999                rusqlite::params!["zz_not_hex", c.id.to_hex(), &[0u8; 32][..]],
2000            )
2001            .unwrap();
2002        }
2003        assert!(load_community(&c.id).is_err(), "malformed id must error, not corrupt");
2004    }
2005
2006    #[test]
2007    fn message_key_store_take_round_trip() {
2008        let (_tmp, _guard) = init_test_db();
2009        let eph = Keys::generate();
2010        let relays = vec!["wss://r.one".to_string()];
2011        // Keyed by INNER message id; resolves to the OUTER event id + key + relays.
2012        store_message_key("inner_msg_id", "outer_evid", &eph, &relays).unwrap();
2013
2014        let (loaded, outer, r) = take_message_key("inner_msg_id").unwrap().expect("present");
2015        assert_eq!(
2016            loaded.secret_key().as_secret_bytes(),
2017            eph.secret_key().as_secret_bytes()
2018        );
2019        assert_eq!(outer, "outer_evid");
2020        assert_eq!(r, relays);
2021        // `take` is single-use: the row is removed.
2022        assert!(take_message_key("inner_msg_id").unwrap().is_none());
2023    }
2024
2025    #[test]
2026    fn missing_community_is_none() {
2027        let (_tmp, _guard) = init_test_db();
2028        let absent = CommunityId([0x33u8; 32]);
2029        assert!(load_community(&absent).unwrap().is_none());
2030    }
2031
2032    #[test]
2033    fn list_ids_reflects_saved() {
2034        let (_tmp, _guard) = init_test_db();
2035        let a = Community::create("A", "g", vec![]);
2036        let b = Community::create("B", "g", vec![]);
2037        save_community(&a).unwrap();
2038        save_community(&b).unwrap();
2039        let ids = list_community_ids().unwrap();
2040        assert_eq!(ids.len(), 2);
2041        assert!(ids.contains(&a.id) && ids.contains(&b.id));
2042    }
2043
2044    #[test]
2045    fn delete_community_clears_all_local_state() {
2046        let (_tmp, _guard) = init_test_db();
2047        let c = Community::create("HQ", "general", vec!["r1".into()]);
2048        save_community(&c).unwrap();
2049        let cid = c.id.to_hex();
2050        save_public_invite(&"ab".repeat(32), &cid, "url", None, None).unwrap();
2051        save_pending_invite(&"cd".repeat(32), "{}", "npub1x").unwrap();
2052        set_edition_head(&cid, &"a".repeat(64), 3, &[0x11u8; 32]).unwrap();
2053
2054        // The save above archived the base + channel keys; this proves delete clears them too.
2055        assert!(!held_epoch_keys(&cid, crate::community::SERVER_ROOT_SCOPE_HEX).unwrap().is_empty());
2056
2057        delete_community(&cid).unwrap();
2058        assert!(!community_exists(&c.id).unwrap());
2059        assert!(community_id_for_channel(&c.channels[0].id.to_hex()).unwrap().is_none());
2060        assert!(list_public_invites(&cid).unwrap().is_empty());
2061        assert_eq!(get_edition_head(&cid, &"a".repeat(64)).unwrap(), None, "edition heads cleared on delete");
2062        assert!(held_epoch_keys(&cid, crate::community::SERVER_ROOT_SCOPE_HEX).unwrap().is_empty(), "epoch keys cleared on delete");
2063    }
2064
2065    #[test]
2066    fn delete_community_retain_keys_drops_state_but_keeps_epoch_keys() {
2067        // self-removal teardown: drop chat/membership/control state but KEEP the held epoch keys so a
2068        // later self-scrub of own past messages stays possible.
2069        let (_tmp, _guard) = init_test_db();
2070        let c = Community::create("HQ", "general", vec!["r1".into()]);
2071        save_community(&c).unwrap();
2072        let cid = c.id.to_hex();
2073        save_public_invite(&"ab".repeat(32), &cid, "url", None, None).unwrap();
2074        set_edition_head(&cid, &"a".repeat(64), 3, &[0x11u8; 32]).unwrap();
2075
2076        let base_before = held_epoch_keys(&cid, crate::community::SERVER_ROOT_SCOPE_HEX).unwrap();
2077        let chan_before = held_epoch_keys(&cid, &c.channels[0].id.to_hex()).unwrap();
2078        assert!(!base_before.is_empty() && !chan_before.is_empty(), "save archived base + channel keys");
2079
2080        delete_community_retain_keys(&cid).unwrap();
2081
2082        // State is gone.
2083        assert!(!community_exists(&c.id).unwrap());
2084        assert!(community_id_for_channel(&c.channels[0].id.to_hex()).unwrap().is_none());
2085        assert!(list_public_invites(&cid).unwrap().is_empty());
2086        assert_eq!(get_edition_head(&cid, &"a".repeat(64)).unwrap(), None);
2087        // Epoch keys (base + channel, every epoch) survive intact.
2088        assert_eq!(held_epoch_keys(&cid, crate::community::SERVER_ROOT_SCOPE_HEX).unwrap(), base_before,
2089            "base epoch keys retained for self-scrub");
2090        assert_eq!(held_epoch_keys(&cid, &c.channels[0].id.to_hex()).unwrap(), chan_before,
2091            "channel epoch keys retained for self-scrub");
2092    }
2093
2094    #[test]
2095    fn channel_resolves_to_owning_community() {
2096        let (_tmp, _guard) = init_test_db();
2097        let c = Community::create("HQ", "general", vec![]);
2098        save_community(&c).unwrap();
2099        let chan = c.channels[0].id.to_hex();
2100        assert_eq!(community_id_for_channel(&chan).unwrap().as_deref(), Some(c.id.to_hex().as_str()));
2101        assert!(community_id_for_channel(&"ff".repeat(32)).unwrap().is_none());
2102    }
2103
2104    #[test]
2105    fn community_exists_reflects_saved() {
2106        let (_tmp, _guard) = init_test_db();
2107        let c = Community::create("A", "g", vec![]);
2108        assert!(!community_exists(&c.id).unwrap());
2109        save_community(&c).unwrap();
2110        assert!(community_exists(&c.id).unwrap());
2111    }
2112
2113    #[test]
2114    fn pending_invite_first_wins_and_round_trips() {
2115        let (_tmp, _guard) = init_test_db();
2116        let cid = "ab".repeat(32);
2117        // First park inserts; a re-invite for the same id is IGNORED (first-wins, so a
2118        // hostile re-send can't rewrite a parked bundle or re-notify).
2119        assert!(save_pending_invite(&cid, "{\"bundle\":1}", "npub1inviter").unwrap());
2120        assert!(!save_pending_invite(&cid, "{\"bundle\":2}", "npub1other").unwrap());
2121        assert!(pending_invite_exists(&cid).unwrap());
2122
2123        let listed = list_pending_invites().unwrap();
2124        assert_eq!(listed.len(), 1);
2125        assert_eq!(listed[0].community_id, cid);
2126        assert_eq!(listed[0].bundle_json, "{\"bundle\":1}", "original bundle preserved");
2127        assert_eq!(listed[0].inviter_npub, "npub1inviter");
2128
2129        // get is non-destructive; delete then removes it.
2130        assert_eq!(get_pending_invite(&cid).unwrap().as_deref(), Some("{\"bundle\":1}"));
2131        assert!(pending_invite_exists(&cid).unwrap(), "get must not delete");
2132        delete_pending_invite(&cid).unwrap();
2133        assert!(!pending_invite_exists(&cid).unwrap());
2134        assert!(get_pending_invite(&cid).unwrap().is_none());
2135    }
2136
2137    #[test]
2138    fn purge_drops_invites_for_held_communities_only() {
2139        let (_tmp, _guard) = init_test_db();
2140        // A community we hold + a parked invite for it (the cross-device race: invite landed
2141        // before the membership list rehydrated the community).
2142        let held = Community::create("Held", "general", vec![]);
2143        save_community(&held).unwrap();
2144        let held_hex = held.id.to_hex();
2145        save_pending_invite(&held_hex, "{\"bundle\":1}", "npub1inviter").unwrap();
2146        // An invite for a community we do NOT hold must survive the purge.
2147        let stranger = "ab".repeat(32);
2148        save_pending_invite(&stranger, "{\"bundle\":2}", "npub1inviter").unwrap();
2149
2150        let n = purge_pending_invites_for_held_communities().unwrap();
2151        assert_eq!(n, 1, "only the held community's invite is purged");
2152        assert!(!pending_invite_exists(&held_hex).unwrap(), "held → invite gone");
2153        assert!(pending_invite_exists(&stranger).unwrap(), "unknown community → invite kept");
2154    }
2155
2156    #[test]
2157    fn decline_drops_pending_invite() {
2158        let (_tmp, _guard) = init_test_db();
2159        let cid = "cd".repeat(32);
2160        save_pending_invite(&cid, "{}", "npub1x").unwrap();
2161        delete_pending_invite(&cid).unwrap();
2162        assert!(!pending_invite_exists(&cid).unwrap());
2163    }
2164
2165    #[test]
2166    fn pending_invites_are_capped_keeping_the_newest() {
2167        let (_tmp, _guard) = init_test_db();
2168        // 150 distinct invites with strictly increasing received_at (the helper stamps now_secs(),
2169        // so vary the id and rely on insertion order; to make ordering deterministic we bump the
2170        // stored time directly after each insert isn't needed — received_at ties break on id DESC).
2171        // Insert 150; the table must cap at 100.
2172        for i in 0..150u32 {
2173            let cid = format!("{:064x}", i);
2174            save_pending_invite(&cid, "{}", "npub1x").unwrap();
2175        }
2176        let all = list_pending_invites().unwrap();
2177        assert_eq!(all.len(), 100, "table capped at MAX_PENDING_INVITES");
2178        // A spam flood can't grow it past the cap regardless of how many arrive.
2179        for i in 150..400u32 {
2180            let cid = format!("{:064x}", i);
2181            save_pending_invite(&cid, "{}", "npub1x").unwrap();
2182        }
2183        assert_eq!(list_pending_invites().unwrap().len(), 100, "cap holds under flood");
2184    }
2185}