Skip to main content

vector_core/db/
community.rs

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