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