Skip to main content

vector_core/db/
community.rs

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