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