Skip to main content

vector_core/
lib.rs

1//! Vector Core — the single source of truth for all Vector clients, SDKs, and interfaces.
2//!
3//! This crate contains ALL of Vector's business logic, fully decoupled from Tauri.
4//! It can be used by:
5//! - **src-tauri**: The Tauri desktop/mobile app (thin command shell)
6//! - **vector-cli**: Command-line interface
7//! - **Vector SDK**: Bot and client libraries
8//! - Any future interface (web, embedded, etc.)
9//!
10//! # Architecture
11//!
12//! ```text
13//! ┌─────────────────────────────────────────────┐
14//! │              vector-core                     │
15//! │                                              │
16//! │  types ─ compact ─ state ─ db ─ crypto       │
17//! │  chat ─ profile ─ net ─ hex                  │
18//! │                                              │
19//! │  traits::EventEmitter (UI abstraction)       │
20//! │  VectorCore (high-level API)                 │
21//! └─────────────────────────────────────────────┘
22//!        ▲              ▲              ▲
23//!   src-tauri       vector-cli     Vector SDK
24//! (AppHandle)      (terminal)     (callbacks)
25//! ```
26
27// === Logging (must be first — #[macro_export] macros used by all modules) ===
28#[macro_use]
29mod macros;
30
31// === Foundation ===
32pub mod logging;
33pub mod error;
34pub mod traits;
35
36// Nostr SDK trait imports needed for bech32 operations
37use crate::event_ext::FinalizeUnsignedWithId;
38use nostr_sdk::prelude::{FinalizeEventAsync, ToBech32};
39
40// === Core Types ===
41pub mod event_ext;
42pub mod tags;
43pub mod types;
44pub mod profile;
45pub mod chat;
46pub mod compact;
47
48// === State ===
49pub mod state;
50
51// === Debug Stats ===
52#[cfg(debug_assertions)]
53pub mod stats;
54
55// === Crypto ===
56pub mod crypto;
57
58// === Signer (polymorphic: local vault vs. NIP-46 remote bunker) ===
59pub mod signer;
60
61// === NIP-55 offline signer (on-device Amber over Android IPC) ===
62pub mod nip55;
63
64// === Database ===
65pub mod db;
66/// The "every task carries its account" check, run by this crate's suite and
67/// by every crate that spawns per-account work against vector-core.
68pub mod spawn_audit;
69
70// === Network ===
71pub mod net;
72pub mod negentropy;
73pub mod blossom;
74pub mod blossom_servers;
75pub mod blossom_capabilities;
76pub mod inbox_relays;
77pub mod emoji_packs;
78pub mod emoji_usage;
79pub mod badges;
80pub mod bot_interface;
81pub mod webxdc;
82#[cfg(feature = "tor")]
83pub mod tor;
84
85/// NIP-42 authenticator.
86///
87/// Many Concord/Armada communities live on AUTH-gating relays (Ditto's default
88/// gates kind-1059), where an unauthenticated client silently reads back ZERO
89/// events — a join's control-plane verify then fails closed and every community
90/// fetch comes up empty. Registering this is what unlocks those reads; a relay
91/// that doesn't challenge is unaffected.
92///
93/// The signer is resolved per challenge rather than captured at client
94/// construction, so a bunker that connects later (or an account swap) authks
95/// under the identity that is live *now*.
96#[derive(Debug)]
97pub struct VectorAuthenticator;
98
99impl nostr_sdk::prelude::Authenticator for VectorAuthenticator {
100    fn make_auth_event<'a>(
101        &'a self,
102        relay_url: &'a nostr_sdk::prelude::RelayUrl,
103        challenge: &'a str,
104    ) -> signer::BoxedFuture<'a, std::result::Result<nostr_sdk::prelude::Event, nostr_sdk::prelude::Error>>
105    {
106        Box::pin(async move {
107            let signer =
108                signer::active_signer().map_err(nostr_sdk::prelude::Error::other)?;
109            Ok(
110                nostr_sdk::prelude::ClientAuthentication::new(challenge, relay_url.clone())
111                    .finalize_async(&signer)
112                    .await?,
113            )
114        })
115    }
116}
117
118/// A `ClientBuilder` carrying Vector's client-wide policy: NIP-42 auth plus the
119/// embedded-Tor SOCKS proxy.
120///
121/// Callers should start from this rather than `ClientBuilder::new()` so both come
122/// along automatically.
123///
124/// The proxy is a closure, not a fixed address: nostr resolves it per connection
125/// attempt, so it reads the *current* Tor state. That covers relays added later
126/// in the session, which previously needed the transport re-applied per
127/// `add_relay`.
128pub fn nostr_client_builder() -> nostr_sdk::prelude::ClientBuilder {
129    apply_tor_proxy(
130        nostr_sdk::prelude::ClientBuilder::new()
131            .authenticator(VectorAuthenticator)
132            // The pool's own attempts need the Tor floor too, not just our explicit
133            // `try_connect` calls (0.45 default is 15s — under a circuit build).
134            .connect_timeout(relay_connect_timeout(std::time::Duration::from_secs(15))),
135    )
136}
137
138/// Register a relay with the pool's own auto-reconnect disabled.
139///
140/// Vector drives every reconnect from its reconcile loop, because it needs the
141/// connection lifecycle to be observable and to sequence with health checks and
142/// the Tor transport switch. The pool's retry is invisible to all of that, so two
143/// schedules end up fighting over one socket.
144///
145/// This exists as a helper rather than a per-call `.reconnect(false)` because
146/// `reconnect` is the one relay option `ClientBuilder` cannot default: it lives
147/// only on `RelayOptions`, so every registration site has to opt out by hand, and
148/// most of them silently didn't.
149pub trait ClientRelayExt {
150    /// `Client::add_relay` with `reconnect(false)` already applied.
151    fn add_managed_relay<'client, 'url, U>(
152        &'client self,
153        url: U,
154    ) -> nostr_sdk::prelude::AddRelay<'client, 'url>
155    where
156        U: Into<nostr_sdk::prelude::RelayUrlArg<'url>>;
157}
158
159impl ClientRelayExt for nostr_sdk::prelude::Client {
160    fn add_managed_relay<'client, 'url, U>(
161        &'client self,
162        url: U,
163    ) -> nostr_sdk::prelude::AddRelay<'client, 'url>
164    where
165        U: Into<nostr_sdk::prelude::RelayUrlArg<'url>>,
166    {
167        self.add_relay(url).reconnect(false)
168    }
169}
170
171/// Re-send every live subscription this relay is supposed to carry, after VECTOR
172/// reconnected it.
173///
174/// Vector owns every reconnect (`add_managed_relay` ⇒ `reconnect(false)`), and the
175/// pool re-applies live subs only inside its own retry path — which is exactly the
176/// path that was turned off. So a dropped socket comes back carrying NOTHING, and a
177/// catch-up fetch is not a subscription: the data lands once and then the stream is
178/// silent forever. Only an AUTH-gating relay healed, via its post-challenge re-send.
179///
180/// Driven off the pool's own subscription table rather than a list of known
181/// subscriptions, so a future subscription is covered without touching this. Only
182/// ids the pool already associates with `relay` are re-sent, so a relay-targeted
183/// subscription is never widened onto a relay it deliberately excluded. Same-id
184/// REQs are idempotent.
185pub async fn resubscribe_relay_after_reconnect(
186    client: &nostr_sdk::prelude::Client,
187    relay: &nostr_sdk::prelude::RelayUrl,
188) {
189    for (id, per_relay) in client.subscriptions().await {
190        let Some(filters) = per_relay.get(relay) else { continue };
191        if filters.is_empty() {
192            continue;
193        }
194        let _ = client
195            .subscribe(nostr_sdk::prelude::ReqTarget::single(relay.clone(), filters.clone()))
196            .with_id(id)
197            .await;
198    }
199}
200
201/// Minimum a relay connect attempt gets while Tor is on.
202///
203/// Circuit construction dominates the handshake and routinely runs tens of
204/// seconds, especially on the first connection after the toggle.
205#[cfg(all(feature = "tor", not(target_arch = "wasm32")))]
206const TOR_RELAY_CONNECT_FLOOR: std::time::Duration = std::time::Duration::from_secs(60);
207
208/// Adjust a relay connect budget for the transport actually in use.
209///
210/// Clearnet TCP+TLS settles in well under a second, so the tight per-call budgets
211/// are right there and each caller's intent is preserved. Under Tor those same
212/// budgets expire mid-circuit, and because the health-check and reconcile loops
213/// treat a timeout as "unhealthy" they call `disconnect()` — which terminates the
214/// connection task — then retry, so a relay churns `pending → terminated` forever
215/// and never connects. Raising the floor lets the circuit finish.
216pub fn relay_connect_timeout(clearnet: std::time::Duration) -> std::time::Duration {
217    #[cfg(all(feature = "tor", not(target_arch = "wasm32")))]
218    {
219        if !matches!(tor::transport_state(), tor::TorTransportState::Disabled) {
220            return clearnet.max(TOR_RELAY_CONNECT_FLOOR);
221        }
222    }
223    clearnet
224}
225
226/// Floor for a relay round-trip (request → response) while Tor is active.
227#[cfg(all(feature = "tor", not(target_arch = "wasm32")))]
228const TOR_RELAY_REQUEST_FLOOR: std::time::Duration = std::time::Duration::from_secs(30);
229
230/// Adjust a relay request budget for the transport actually in use.
231///
232/// Companion to [`relay_connect_timeout`] for round trips rather than connections.
233/// A relay that answers a probe in 200ms direct can take many seconds through three
234/// hops, so a clearnet-sized budget reads a healthy relay as dead.
235pub fn relay_request_timeout(clearnet: std::time::Duration) -> std::time::Duration {
236    #[cfg(all(feature = "tor", not(target_arch = "wasm32")))]
237    {
238        if !matches!(tor::transport_state(), tor::TorTransportState::Disabled) {
239            return clearnet.max(TOR_RELAY_REQUEST_FLOOR);
240        }
241    }
242    clearnet
243}
244
245/// Apply the Tor proxy policy to any `ClientBuilder`.
246///
247/// Separate from [`nostr_client_builder`] because a client that authenticates as
248/// something other than the user (the Concord stream-auth plane key) still needs
249/// the same transport: without it the plane fetch connects direct and ties the
250/// user's IP to community membership.
251pub fn apply_tor_proxy(
252    builder: nostr_sdk::prelude::ClientBuilder,
253) -> nostr_sdk::prelude::ClientBuilder {
254    #[cfg(all(feature = "tor", not(target_arch = "wasm32")))]
255    let builder = builder.proxy(nostr_sdk::prelude::Proxy::custom(|_url| tor_proxy_target()));
256    builder
257}
258
259/// Resolve the proxy every connection attempt must use, for the transport in use.
260///
261/// Named rather than inlined into the `Proxy::custom` closure so the failsafe is
262/// testable: returning `None` here means "connect direct", so the only leak-safe
263/// answer while Tor is the chosen transport but not yet up is the blackhole.
264#[cfg(all(feature = "tor", not(target_arch = "wasm32")))]
265fn tor_proxy_target() -> Option<std::net::SocketAddr> {
266    match tor::transport_state() {
267        tor::TorTransportState::Active(addr) => Some(addr),
268        // Tor failsafe: route to a blackhole so a relay socket can't come up
269        // direct while Tor is mid-bootstrap.
270        tor::TorTransportState::RequiredButInactive => Some(tor::blackhole_proxy_addr()),
271        tor::TorTransportState::Disabled => None,
272    }
273}
274
275/// Sign an `EventBuilder` with the session signer.
276///
277/// Stands in for 0.44's `Client::sign_event_builder`, which went away when the
278/// client stopped owning a signer.
279pub async fn sign_builder(
280    builder: nostr_sdk::prelude::EventBuilder,
281) -> std::result::Result<nostr_sdk::prelude::Event, String> {
282    let signer = signer::active_signer()?;
283    builder
284        .finalize_async(&signer)
285        .await
286        .map_err(|e| e.to_string())
287}
288
289/// Sign an `EventBuilder` with the session signer and publish it.
290///
291/// Stands in for 0.44's `Client::send_event_builder`.
292pub async fn sign_and_send(
293    client: &nostr_sdk::prelude::Client,
294    builder: nostr_sdk::prelude::EventBuilder,
295) -> std::result::Result<nostr_sdk::prelude::SendEventOutput, String> {
296    let event = sign_builder(builder).await?;
297    client
298        .send_event(&event)
299        .await
300        .map_err(|e| e.to_string())
301}
302
303/// Seal, wrap and publish a rumor to `receiver`.
304///
305/// Stands in for 0.44's `Client::gift_wrap` / `gift_wrap_to`, which went away
306/// with the client's signer. An empty `relays` publishes pool-wide, matching
307/// `gift_wrap`; a non-empty one targets those relays, matching `gift_wrap_to`.
308pub async fn send_gift_wrap<'u, I, U, T>(
309    client: &nostr_sdk::prelude::Client,
310    relays: I,
311    receiver: &nostr_sdk::prelude::PublicKey,
312    rumor: nostr_sdk::prelude::UnsignedEvent,
313    extra_tags: T,
314) -> std::result::Result<nostr_sdk::prelude::SendEventOutput, String>
315where
316    I: IntoIterator<Item = U>,
317    U: Into<nostr_sdk::prelude::RelayUrlArg<'u>>,
318    T: IntoIterator<Item = nostr_sdk::prelude::Tag>,
319{
320    let signer = signer::active_signer()?;
321    let wrap = nostr_sdk::prelude::GiftWrapBuilder::new(*receiver, rumor)
322        .extra_tags(extra_tags)
323        .finalize_async(&signer)
324        .await
325        .map_err(|e| e.to_string())?;
326    let targets: Vec<nostr_sdk::prelude::RelayUrlArg<'u>> =
327        relays.into_iter().map(Into::into).collect();
328    if targets.is_empty() {
329        client.send_event(&wrap).await.map_err(|e| e.to_string())
330    } else {
331        client
332            .send_event(&wrap)
333            .to(targets)
334            .await
335            .map_err(|e| e.to_string())
336    }
337}
338
339/// Capabilities for a Community / "external" relay: GOSSIP only.
340///
341/// GOSSIP is read/write-capable when TARGETED — `can_read()` is
342/// `READ|GOSSIP|DISCOVERY` and `can_write()` is `WRITE|GOSSIP`, so per-relay
343/// targeted ops pass. But pool-wide ops select READ-only / WRITE-only relays, so
344/// the DM/giftwrap subscription and the user's outbox skip GOSSIP relays — the
345/// user's own traffic never touches relays they don't own.
346///
347/// No PING counterpart any more: 0.45 demoted PING from a capability flag to a
348/// per-relay option (`AddRelay::ping`) that already defaults to true, with
349/// `sleep_when_idle` defaulting to false. The 24/7 keepalive this used to buy is
350/// now the default, so it doesn't belong in the capability set.
351pub fn community_relay_capabilities() -> nostr_sdk::prelude::RelayCapabilities {
352    nostr_sdk::prelude::RelayCapabilities::GOSSIP
353}
354
355/// Relay options for a Discovery Relay (see `state::DISCOVERY_RELAYS`): the same
356/// GOSSIP|PING targeted-only isolation as Community relays — reachable via
357/// `fetch_events_from` / `send_event_to`, invisible to pool-wide DM/profile ops.
358/// An overlap with a user relay keeps the user's READ+WRITE flags (`add_relay`
359/// no-ops on an already-pooled url).
360pub fn discovery_relay_capabilities() -> nostr_sdk::prelude::RelayCapabilities {
361    community_relay_capabilities()
362}
363
364// === Event Storage ===
365pub mod stored_event;
366
367// === Rumor Processing ===
368pub mod rumor;
369
370// === Messaging ===
371pub mod sending;
372
373// === Per-DM Wallpapers ===
374pub mod pinned_chats;
375pub mod synced_prefs;
376pub mod wallpaper;
377
378// === Message Deletion (NIP-09 against retained gift-wraps) ===
379pub mod deletion;
380pub mod self_destruct;
381
382// === SIMD Operations ===
383pub mod simd;
384
385// === Community protocol (GROUP_PROTOCOL.md) ===
386pub mod community;
387
388// === Event Handler ===
389pub mod event_handler;
390
391// === Re-exports for convenience ===
392pub use types::{Message, Attachment, Reaction, EditEntry, ImageMetadata, SiteMetadata, LoginResult, AttachmentFile, mention, extract_mentions};
393pub use profile::{Profile, ProfileFlags, SlimProfile, Status};
394pub use chat::{Chat, ChatType, ChatMetadata, SerializableChat};
395pub use compact::{CompactMessage, CompactMessageVec, NpubInterner};
396pub use state::{
397    ChatState, MY_SECRET_KEY, STATE, ENCRYPTION_KEY,
398    nostr_client, my_public_key, has_active_session,
399    set_nostr_client, set_my_public_key,
400    take_nostr_client, clear_my_public_key,
401    set_pending_bunker_setup, pending_bunker_setup, clear_pending_bunker_setup,
402    set_pending_nip55_setup, pending_nip55_setup, clear_pending_nip55_setup,
403};
404pub use crypto::{GuardedKey, GuardedSigner};
405pub use signer::{
406    SignerKind, signer_kind, set_signer_kind, is_bunker, is_keyless,
407    BUNKER_SIGNER, bunker_signer, set_bunker_signer, take_bunker_signer,
408    build_bunker_signer, prewarm_bunker, drain_bunker_state,
409    parse_bunker_remote_pubkey, parse_bunker_relays,
410    BunkerConnectionState, bunker_state, set_bunker_state,
411    VectorAuthUrlHandler, attempt_bunker_login, WatchedBunkerSigner,
412    vector_metadata, build_nostrconnect_uri, build_nostrconnect_session,
413    VECTOR_APP_NAME, VECTOR_APP_URL, VECTOR_APP_ICON,
414};
415pub use nip55::{
416    Nip55Backend, Nip55Error, Nip55ResolverOutcome, Nip55Signer, Nip55State,
417    set_nip55_backend, nip55_backend, nip55_state, set_nip55_state, drain_nip55_state,
418    nip55_is_installed, nip55_pair, nip55_perms_json,
419    VECTOR_NIP55_SIGN_KINDS, VECTOR_NIP55_ENCRYPT_TYPES,
420};
421pub use error::{VectorError, Result};
422pub use traits::{EventEmitter, NoOpEmitter, set_event_emitter, emit_event};
423pub use db::{set_app_data_dir, get_app_data_dir};
424pub use sending::{SendCallback, NoOpSendCallback, SendConfig, SendResult};
425pub use deletion::{delete_own_dm, DeleteOutcome};
426pub use stored_event::{StoredEvent, StoredEventBuilder, SystemEventType};
427pub use rumor::{RumorEvent, RumorContext, ConversationType, RumorProcessingResult, process_rumor};
428pub use profile::{SyncPriority, ProfileSyncHandler, NoOpProfileSyncHandler};
429pub use event_handler::{InboundEventHandler, NoOpEventHandler, PreparedEvent, process_event};
430
431use std::path::PathBuf;
432use std::sync::Arc;
433
434// ============================================================================
435// VectorCore — High-level API
436// ============================================================================
437
438/// Configuration for initializing VectorCore.
439pub struct CoreConfig {
440    /// Path to the app data directory (e.g., ~/.local/share/io.vectorapp/data/)
441    pub data_dir: PathBuf,
442    /// Optional event emitter for UI integration
443    pub event_emitter: Option<Box<dyn EventEmitter>>,
444}
445
446/// The main entry point for Vector Core.
447///
448/// Provides a high-level API for all Vector operations. Internally uses
449/// global state (same pattern as the Tauri backend) for compatibility.
450///
451/// ```no_run
452/// use vector_core::{VectorCore, CoreConfig};
453/// use std::path::PathBuf;
454///
455/// # async fn example() -> vector_core::Result<()> {
456/// let core = VectorCore::init(CoreConfig {
457///     data_dir: PathBuf::from("/tmp/vector-data"),
458///     event_emitter: None,
459/// })?;
460///
461/// // Login with nsec
462/// let result = core.login("nsec1...", None).await?;
463/// println!("Logged in as {}", result.npub);
464/// # Ok(())
465/// # }
466/// ```
467#[derive(Clone, Copy)]
468pub struct VectorCore;
469
470/// What one catch-up page cost and what it yielded.
471///
472/// `fetched` is the relay's answer — zero means there is genuinely nothing
473/// further back, which is the only sound "reached the start" signal.
474/// `new_messages` counts what this client had never seen, which is what a UI
475/// reports; a page of entirely-known history is 0 new with a non-empty fetch.
476#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
477pub struct BackfillCount {
478    pub fetched: usize,
479    pub new_messages: usize,
480}
481
482impl VectorCore {
483    /// Initialize Vector Core with the given configuration.
484    pub fn init(config: CoreConfig) -> Result<Self> {
485        // Set data directory
486        db::set_app_data_dir(config.data_dir);
487
488        // Set event emitter (or no-op)
489        if let Some(emitter) = config.event_emitter {
490            traits::set_event_emitter(emitter);
491        }
492
493        // Install rustls ring provider
494        let _ = rustls::crypto::ring::default_provider().install_default();
495
496        Ok(VectorCore)
497    }
498
499    /// Get all available accounts.
500    pub fn accounts(&self) -> Result<Vec<String>> {
501        db::get_accounts().map_err(VectorError::from)
502    }
503
504    /// Login with an nsec key or mnemonic seed phrase.
505    pub async fn login(&self, key: &str, password: Option<&str>) -> Result<LoginResult> {
506        use nostr_sdk::prelude::*;
507
508        // Parse the key
509        let keys = if key.starts_with("nsec1") {
510            let secret = SecretKey::from_bech32(key)
511                .map_err(|e| VectorError::Nostr(format!("Invalid nsec: {}", e)))?;
512            Keys::new(secret)
513        } else {
514            // Treat as mnemonic (NIP-06: derive from BIP-39 seed)
515            Keys::from_mnemonic(key, None)
516                .map_err(|e| VectorError::Nostr(format!("Key derivation failed: {}", e)))?
517        };
518
519        let public_key = keys.public_key();
520        let npub = public_key.to_bech32()
521            .map_err(|e| VectorError::Nostr(format!("Failed to encode npub: {}", e)))?;
522
523        // Store in GuardedKey vault (pass other vaults to protect during decoy writes)
524        let secret_bytes = keys.secret_key().to_secret_bytes();
525        state::MY_SECRET_KEY.set(secret_bytes, &[&state::ENCRYPTION_KEY]);
526        state::set_my_public_key(public_key);
527
528        // Initialize database for this account
529        db::set_current_account(npub.clone())?;
530        db::init_database(&npub)?;
531
532        // Store nsec for encryption setup
533        {
534            let nsec = keys.secret_key().to_bech32()
535                .map_err(|e| VectorError::Nostr(format!("Failed to encode nsec: {}", e)))?;
536            *state::PENDING_NSEC.lock().unwrap() = Some(nsec.clone());
537
538            // NEVER clobber an existing encrypted key with the plaintext nsec. An account with encryption
539            // enabled keeps its key encrypted-at-rest (PIN-derived); overwriting it with the raw nsec — e.g.
540            // a no-password headless/diagnostic login (the concord CLI) — would leave the GUI deriving the
541            // right key from the correct PIN but trying to decrypt a value that's no longer ciphertext, i.e.
542            // "incorrect pin" with the real key effectively lost. MY_SECRET_KEY is already set in-memory above,
543            // so login works regardless; only persist the raw key when there's no encrypted key to protect.
544            let existing_encrypted = db::get_pkey().ok().flatten().is_some_and(|v| !v.starts_with("nsec1"));
545            if !(state::resolve_encryption_enabled_from_db() && existing_encrypted) {
546                db::set_pkey(&nsec)?;
547            }
548        }
549
550        // Use the canonical resolver so this high-level API agrees with
551        // crypto::is_encryption_enabled and the Android bg-sync probe.
552        let has_encryption = state::resolve_encryption_enabled_from_db();
553
554        if has_encryption {
555            if let Some(pwd) = password {
556                let key = crate::crypto::hash_pass(pwd).await;
557                state::ENCRYPTION_KEY.set(key, &[&state::MY_SECRET_KEY]);
558            }
559        }
560        // Seed the atomic unconditionally — `is_encryption_enabled_fast()`
561        // must agree with the DB regardless of branch.
562        state::init_encryption_enabled();
563
564        // Build Nostr client — tor-aware options so a headless consumer with
565        // the Tor pref ON proxies (or blackholes) instead of dialing direct.
566        let client = crate::nostr_client_builder()
567            // Relay health monitor — powers the reconnect-driven catch-up in `listen()`.
568            .monitor(Monitor::new(1024))
569            .build();
570
571        // Add trusted relays
572        for relay in state::TRUSTED_RELAYS {
573            client.add_managed_relay(*relay).await.ok();
574        }
575
576        // Connect
577        client.connect().await;
578
579        let _ = { state::set_nostr_client(client); Ok::<(), ()>(()) };
580
581        Ok(LoginResult { npub, has_encryption })
582    }
583
584    /// Generate a fresh random account secret key (bech32 nsec). Lets a headless client spin up a
585    /// brand-new identity (`add_account` with no key) without depending on nostr-sdk directly.
586    pub fn generate_nsec(&self) -> Result<String> {
587        use nostr_sdk::prelude::*;
588        Keys::generate().secret_key().to_bech32()
589            .map_err(|e| VectorError::Nostr(format!("Failed to encode nsec: {}", e)))
590    }
591
592    /// Send a NIP-17 gift-wrapped text DM using the full pipeline. Retries a
593    /// transient publish miss (headless preset, 3 attempts) so an SDK/CLI bot rides
594    /// out a relay blip instead of silently dropping the message on the first miss;
595    /// `self_send: false` keeps it a plain send (no inbox self-copy).
596    pub async fn send_dm(&self, to_npub: &str, content: &str) -> Result<sending::SendResult> {
597        let config = SendConfig { self_send: false, ..SendConfig::headless() };
598        sending::send_dm(to_npub, content, None, &config, Arc::new(NoOpSendCallback)).await
599            .map_err(|e| VectorError::Other(e))
600    }
601
602    /// Send a DM as a threaded reply to `replied_to` (an existing message's event id).
603    pub async fn send_dm_reply(&self, to_npub: &str, replied_to: &str, content: &str) -> Result<sending::SendResult> {
604        let config = SendConfig { self_send: false, ..SendConfig::headless() };
605        sending::send_dm(to_npub, content, Some(replied_to), &config, Arc::new(NoOpSendCallback)).await
606            .map_err(|e| VectorError::Other(e))
607    }
608
609    /// Download a received attachment and decrypt it to plaintext bytes. Fetches the encrypted blob
610    /// from its Blossom URL (SSRF/Tor-aware client, size-capped) and AES-decrypts with the
611    /// attachment's embedded key + nonce. Walks the primary URL then any BUD-04 `fallback`
612    /// mirrors (same ciphertext on other hosts) until one serves. Prefer
613    /// [`download_attachment_from`](Self::download_attachment_from) when the message author is
614    /// known — it adds the BUD-03 hash-swap over the author's advertised servers.
615    pub async fn download_attachment(&self, attachment: &Attachment) -> Result<Vec<u8>> {
616        self.download_attachment_from(attachment, None).await
617    }
618
619    /// [`download_attachment`](Self::download_attachment) with the full source walk: primary URL →
620    /// embedded `fallback` mirrors → BUD-03 hash-swap (the same content-address on each of the
621    /// author's kind-10063 servers). `author_npub` is the message author (your own npub for your
622    /// own messages); `None` skips the hash-swap stage.
623    pub async fn download_attachment_from(
624        &self,
625        attachment: &Attachment,
626        author_npub: Option<&str>,
627    ) -> Result<Vec<u8>> {
628        use futures_util::StreamExt;
629        const MAX_DOWNLOAD: usize = 256 * 1024 * 1024;
630        if attachment.url.is_empty() {
631            return Err(VectorError::Other("attachment has no URL".into()));
632        }
633        let client = crate::net::build_http_client(std::time::Duration::from_secs(120)).map_err(VectorError::Other)?;
634        let mut last_err = String::from("download failed");
635        let mut candidates: Vec<String> = vec![attachment.url.clone()];
636        candidates.extend(attachment.fallback_urls.iter().cloned());
637        let mut hash_swap_tried = false;
638        let mut i = 0;
639        'sources: while i < candidates.len() {
640            let url = candidates[i].clone();
641            i += 1;
642            // One-time last resort once every embedded source has failed: the author's advertised
643            // servers may hold the blob under the same content-address.
644            let extend_with_swap = |candidates: &mut Vec<String>, servers: &[String]| {
645                let extra = crate::blossom::hash_swap_candidates(&attachment.url, servers);
646                for c in extra {
647                    if !candidates.contains(&c) {
648                        candidates.push(c);
649                    }
650                }
651            };
652            macro_rules! next_source {
653                () => {{
654                    log_net_fail!("[Download] source failed ({}): {}", url, last_err);
655                    if i == candidates.len() && !hash_swap_tried {
656                        hash_swap_tried = true;
657                        let servers = crate::blossom_servers::author_swap_servers(author_npub, false).await;
658                        extend_with_swap(&mut candidates, &servers);
659                    }
660                    continue 'sources;
661                }};
662            }
663            // SSRF guard: URLs are attacker-controlled (off an inbound message). build_http_client
664            // only validates redirect HOPS, not the initial request — so validate each source here
665            // (matches the native download path). With Tor off this is the only egress guard.
666            if let Err(e) = crate::net::validate_url_not_private(&url) {
667                last_err = e.to_string();
668                next_source!();
669            }
670            let resp = match client.get(&url).send().await {
671                Ok(r) => r,
672                Err(e) => {
673                    last_err = format!("download: {e}");
674                    next_source!();
675                }
676            };
677            if !resp.status().is_success() {
678                last_err = format!("download failed: HTTP {}", resp.status());
679                next_source!();
680            }
681            // Stream with a cap so a hostile/oversized blob can't OOM the process. The cap is
682            // permanent — every mirror serves the same blob, so don't bother trying the next.
683            let mut encrypted: Vec<u8> = Vec::with_capacity(
684                resp.content_length().map(|l| (l as usize).min(MAX_DOWNLOAD)).unwrap_or(64 * 1024),
685            );
686            let mut stream = resp.bytes_stream();
687            while let Some(chunk) = stream.next().await {
688                let chunk = match chunk {
689                    Ok(c) => c,
690                    Err(e) => {
691                        last_err = format!("read body: {e}");
692                        next_source!();
693                    }
694                };
695                if encrypted.len() + chunk.len() > MAX_DOWNLOAD {
696                    return Err(VectorError::Other("attachment exceeds 256 MiB cap".into()));
697                }
698                encrypted.extend_from_slice(&chunk);
699            }
700            match crate::crypto::decrypt_data(&encrypted, &attachment.key, &attachment.nonce) {
701                Ok(plain) => {
702                    if i > 1 {
703                        log_net_info!("[Download] fallback source {}/{} served {}", i, candidates.len(), url);
704                    }
705                    return Ok(plain);
706                }
707                Err(e) => {
708                    // A host serving wrong bytes under the right URL must not
709                    // veto sources still holding the real ciphertext.
710                    last_err = format!("decrypt: {e}");
711                    next_source!();
712                }
713            }
714        }
715        log_net_fail!("[Download] all {} source(s) failed for {}: {}", candidates.len(), attachment.url, last_err);
716        Err(VectorError::Other(last_err))
717    }
718
719    /// Send a NIP-17 gift-wrapped file attachment DM.
720    pub async fn send_file(&self, to_npub: &str, file_path: &str) -> Result<sending::SendResult> {
721        let path = std::path::Path::new(file_path);
722        let bytes = std::fs::read(path)
723            .map_err(|e| VectorError::Io(e))?;
724        let filename = path.file_name()
725            .and_then(|n| n.to_str())
726            .unwrap_or("file");
727        let extension = path.extension()
728            .and_then(|e| e.to_str())
729            .unwrap_or("bin");
730
731        sending::send_file_dm(
732            to_npub,
733            std::sync::Arc::new(bytes),
734            filename,
735            extension,
736            None,
737            &SendConfig::default(),
738            Arc::new(NoOpSendCallback),
739        ).await.map_err(|e| VectorError::Other(e))
740    }
741
742    /// The id of our own reaction to `message_id` with `emoji`, if we already have
743    /// one. A reaction set is keyed by (author, emoji), so a repeat is something
744    /// every receiver collapses anyway: refusing it here spends no publish, no
745    /// gift-wrap and no relay round-trip on an event that changes nothing.
746    async fn own_reaction_id(message_id: &str, emoji: &str) -> Option<String> {
747        use nostr_sdk::prelude::ToBech32;
748        let me = state::my_public_key()?.to_bech32().ok()?;
749        let st = state::STATE.lock().await;
750        let (_, message) = st.find_message(message_id)?;
751        message
752            .reactions
753            .iter()
754            .find(|r| r.author_id == me && r.emoji == emoji)
755            .map(|r| r.id.clone())
756    }
757
758    /// Send a NIP-25 reaction to a DM message. `emoji_url` carries the NIP-30
759    /// image URL when reacting with a custom-pack emoji (content stays
760    /// `:shortcode:`). Returns the reaction's rumor id. Local echo + persistence
761    /// are best-effort — the gift-wrap send is the source of truth.
762    pub async fn send_reaction(
763        &self,
764        to_npub: &str,
765        reference_id: &str,
766        emoji: &str,
767        emoji_url: Option<&str>,
768    ) -> Result<String> {
769        use nostr_sdk::prelude::*;
770
771        let client = state::nostr_client().ok_or(VectorError::Other("Not connected".into()))?;
772        let my_public_key = state::my_public_key().ok_or(VectorError::Other("Not logged in".into()))?;
773
774        // Already ours: hand back the reaction we hold rather than minting a second.
775        if let Some(existing) = Self::own_reaction_id(reference_id, emoji).await {
776            return Ok(existing);
777        }
778
779        // Group ceiling + per-tier fresh-reaction allowance (joins always pass)
780        badges::check_new_reaction_allowance(reference_id, emoji)
781            .await
782            .map_err(VectorError::Other)?;
783
784        let reference_event = EventId::from_hex(reference_id)
785            .map_err(|e| VectorError::Nostr(e.to_string()))?;
786        let receiver_pubkey = PublicKey::from_bech32(to_npub)
787            .map_err(|e| VectorError::Nostr(e.to_string()))?;
788
789        // NIP-30 custom-emoji tag — only when content is `:shortcode:` and a URL is present.
790        let custom_emoji_tag = emoji_url.and_then(|url| {
791            if !emoji.starts_with(':') || !emoji.ends_with(':') || emoji.len() < 3 || url.is_empty() {
792                return None;
793            }
794            let shortcode = &emoji[1..emoji.len() - 1];
795            if shortcode.is_empty() { return None; }
796            Some(Tag::custom("emoji", [shortcode.to_string(), url.to_string()]))
797        });
798
799        let reaction_target = nostr_sdk::prelude::nip25::ReactionTarget {
800            event_id: reference_event,
801            public_key: receiver_pubkey,
802            coordinate: None,
803            kind: Some(Kind::PrivateDirectMessage),
804            relay_hint: None,
805        };
806        let mut builder =
807            nostr_sdk::prelude::nip25::ReactionBuilder::new(reaction_target, emoji)
808                .into_event_builder();
809        if let Some(tag) = custom_emoji_tag {
810            builder = builder.tag(tag);
811        }
812        let rumor = builder.finalize_unsigned_with_id(my_public_key);
813        let inner_rumor_id = rumor.id;
814        let rumor_id = inner_rumor_id.ok_or(VectorError::Other("Failed to get rumor ID".into()))?.to_hex();
815
816        // Retain the recipient wrap's ephemeral key + targeted relays so the
817        // reaction can later be revoked with a NIP-09 relay nuke (mirrors the
818        // DM message send path). Without retention the reaction is undeletable.
819        let outcome = inbox_relays::send_gift_wrap_retained(&client, &receiver_pubkey, rumor.clone(), [])
820            .await.map_err(VectorError::Other)?;
821        if !outcome.output.success.is_empty() {
822            if let Some(rid) = inner_rumor_id {
823                if let Err(e) = db::nip17_keys::store_wrap_key(
824                    &outcome.wrap_event_id, &rid, &receiver_pubkey,
825                    db::nip17_keys::WrapRole::Recipient,
826                    &outcome.wrap_secret, &outcome.targeted_relays,
827                ) {
828                    crate::log_warn!("[Reaction] failed to persist wrap key: {}", e);
829                }
830            }
831        }
832
833        // Self-wrap for multi-device recovery + retain its key too, so another
834        // device (or this one) can later revoke. Bail on account swap.
835        let self_wrap_client = client.clone();
836        db::spawn_bound(async move {
837            if let Ok(self_outcome) = inbox_relays::send_gift_wrap_retained(
838                &self_wrap_client, &my_public_key, rumor, [],
839            ).await {
840                if !self_outcome.output.success.is_empty() {
841                    if let Some(rid) = inner_rumor_id {
842                        let _ = db::nip17_keys::store_wrap_key(
843                            &self_outcome.wrap_event_id, &rid, &my_public_key,
844                            db::nip17_keys::WrapRole::SelfSend,
845                            &self_outcome.wrap_secret, &self_outcome.targeted_relays,
846                        );
847                    }
848                }
849            }
850        });
851
852        // Best-effort optimistic local echo + persistence.
853        let reaction = Reaction {
854            id: rumor_id.clone(),
855            reference_id: reference_id.to_string(),
856            author_id: my_public_key.to_bech32().unwrap_or_else(|_| my_public_key.to_hex()),
857            emoji: emoji.to_string(),
858            emoji_url: emoji_url.map(|s| s.to_string()),
859        };
860        let msg_for_save = {
861            let mut st = state::STATE.lock().await;
862            match st.add_reaction_to_message(reference_id, reaction) {
863                Some((cid, true)) => st.find_message(reference_id).map(|(_, m)| (cid, m)),
864                _ => None,
865            }
866        };
867        if let Some((cid, mut msg)) = msg_for_save {
868            let _ = db::events::save_message(&cid, &msg).await;
869            traits::emit_message_update(&cid, reference_id, &mut msg).await;
870        }
871
872        Ok(rumor_id)
873    }
874
875    /// Send an ephemeral typing indicator to a DM recipient. Fire-and-forget
876    /// with a 30-second NIP-40 expiry so relays purge it quickly.
877    pub async fn send_typing(&self, to_npub: &str) -> Result<()> {
878        use nostr_sdk::prelude::*;
879
880        let client = state::nostr_client().ok_or(VectorError::Other("Not connected".into()))?;
881        let my_public_key = state::my_public_key().ok_or(VectorError::Other("Not logged in".into()))?;
882        let pubkey = PublicKey::from_bech32(to_npub).map_err(|e| VectorError::Nostr(e.to_string()))?;
883
884        let expiry = Timestamp::from_secs(Timestamp::now().as_secs() + 30);
885        let rumor = EventBuilder::new(Kind::ApplicationSpecificData, "typing")
886            .tag(Tag::public_key(pubkey))
887            .tag(Tag::custom("d", vec!["vector"]))
888            .tag(Tag::expiration(expiry))
889            .finalize_unsigned_with_id(my_public_key);
890
891        // Client no longer wraps: build the wrap, then publish it to the target relays.
892        let signer = signer::active_signer().map_err(VectorError::Other)?;
893        let wrap = nostr_sdk::prelude::GiftWrapBuilder::new(pubkey, rumor.clone())
894            .extra_tags([Tag::expiration(expiry)])
895            .finalize_async(&signer)
896            .await
897            .map_err(|e| VectorError::Nostr(e.to_string()))?;
898        client
899            .send_event(&wrap)
900            .to(state::active_trusted_relays().await)
901            .await
902            .map_err(|e| VectorError::Nostr(e.to_string()))?;
903        Ok(())
904    }
905
906    /// Edit a DM you previously sent (kind-16 edit) with an optimistic local
907    /// echo. Returns the edit event id. Persistence is best-effort and only
908    /// happens when the chat already exists locally.
909    pub async fn edit_dm(&self, to_npub: &str, message_id: &str, new_content: &str) -> Result<String> {
910        crate::db::scoped(async move {
911            use nostr_sdk::prelude::*;
912
913            let client = state::nostr_client().ok_or(VectorError::Other("Not connected".into()))?;
914            let my_public_key = state::my_public_key().ok_or(VectorError::Other("Not logged in".into()))?;
915            let my_npub = my_public_key.to_bech32().map_err(|e| VectorError::Nostr(e.to_string()))?;
916            let receiver_pubkey = PublicKey::from_bech32(to_npub).map_err(|e| VectorError::Nostr(e.to_string()))?;
917            let reference_event = EventId::from_hex(message_id).map_err(|e| VectorError::Nostr(e.to_string()))?;
918
919            // NIP-30: resolve `:shortcode:` so the edit carries emoji image tags.
920            let emoji_tags = emoji_packs::resolve_outbound_emoji_tags(new_content);
921
922            let mut builder = EventBuilder::new(
923                Kind::from_u16(stored_event::event_kind::MESSAGE_EDIT),
924                new_content,
925            ).tag(Tag::event(reference_event));
926            for et in &emoji_tags {
927                builder = builder.tag(Tag::custom(
928                    "emoji",
929                    [et.shortcode.clone(), et.url.clone()],
930                ));
931            }
932            let rumor = builder.finalize_unsigned_with_id(my_public_key);
933            let edit_id = rumor.id.ok_or(VectorError::Other("Failed to get edit rumor ID".into()))?.to_hex();
934            let edit_ts_ms = rumor.created_at.as_secs() * 1000;
935
936            // Optimistic local echo + best-effort persistence.
937            let msg_for_emit = {
938                let mut st = state::STATE.lock().await;
939                st.update_message_in_chat(to_npub, message_id, |msg| {
940                    msg.apply_edit(new_content.to_string(), edit_ts_ms, emoji_tags.clone());
941                    msg.preview_metadata = None;
942                })
943            };
944            if let Some(mut msg) = msg_for_emit {
945                traits::emit_message_update(to_npub, message_id, &mut msg).await;
946                if let Ok(db_chat_id) = db::id_cache::get_chat_id_by_identifier(to_npub) {
947                    let _ = db::events::save_edit_event(
948                        &edit_id, message_id, new_content, &emoji_tags, db_chat_id, None, &my_npub,
949                    ).await;
950                }
951            }
952
953            inbox_relays::send_gift_wrap(&client, &receiver_pubkey, rumor.clone(), [])
954                .await.map_err(VectorError::Other)?;
955
956            let self_wrap_client = client.clone();
957            let self_wrap_session = crate::db::current_session();
958            db::spawn_bound(async move {
959                if !self_wrap_session.is_live() { return; }
960                let Ok(signer) = signer::active_signer() else { return };
961                if let Ok(wrap) = nostr_sdk::prelude::GiftWrapBuilder::new(my_public_key, rumor)
962                    .finalize_async(&signer)
963                    .await
964                {
965                    let _ = self_wrap_client.send_event(&wrap).await;
966                }
967            });
968
969            Ok(edit_id)
970        })
971        .await
972    }
973
974    /// Delete a DM you sent (NIP-09 over the retained gift-wrap keys).
975    pub async fn delete_dm(&self, message_id: &str) -> Result<deletion::DeleteOutcome> {
976        use nostr_sdk::prelude::*;
977        let rumor_id = EventId::from_hex(message_id).map_err(|e| VectorError::Nostr(e.to_string()))?;
978        deletion::delete_own_dm(&rumor_id).await.map_err(VectorError::Other)
979    }
980
981    /// Get chats from the in-memory state.
982    pub async fn get_chats(&self) -> Vec<SerializableChat> {
983        let state = state::STATE.lock().await;
984        state.chats.iter()
985            .map(|c| c.to_serializable_with_last_n(1, &state.interner))
986            .collect()
987    }
988
989    /// Get messages for a chat (paginated).
990    pub async fn get_messages(&self, chat_id: &str, limit: usize, offset: usize) -> Vec<Message> {
991        let state = state::STATE.lock().await;
992        if let Some(chat) = state.get_chat(chat_id) {
993            let msgs = chat.get_all_messages(&state.interner);
994            let start = offset.min(msgs.len());
995            let end = (offset + limit).min(msgs.len());
996            msgs[start..end].to_vec()
997        } else {
998            Vec::new()
999        }
1000    }
1001
1002    /// One message by id, with the chat it was said in. Reads STATE first, then
1003    /// the store for a row that has paged out of the loaded window.
1004    ///
1005    /// This is what a moderation citation needs: it names a message id and
1006    /// nothing else, so the room has to come back with it. `None` is an
1007    /// ordinary answer — a citation outlives its message (a delete, a hide, an
1008    /// expiry, history never synced), and callers must read absence as absence
1009    /// rather than as a fault.
1010    pub async fn get_message(&self, message_id: &str) -> Option<(String, Message)> {
1011        {
1012            let state = state::STATE.lock().await;
1013            if let Some((chat, msg)) = state.find_message(message_id) {
1014                return Some((chat.id.clone(), msg));
1015            }
1016        }
1017        db::events::get_message_by_id(message_id).await.ok().flatten()
1018    }
1019
1020    /// Every message carrying an attachment with this content hash, newest
1021    /// first. `Attachment.id` IS that hash, and so is a citation's
1022    /// `content_hash`, so a classifier verdict about a blob resolves straight
1023    /// back to what carried it.
1024    ///
1025    /// A list, not one answer: one blob rides many messages — a forward, a
1026    /// re-post, or forty accounts posting the same image, which is exactly the
1027    /// case worth being able to see whole.
1028    pub async fn get_messages_with_attachment(&self, content_hash: &str) -> Vec<(String, Message)> {
1029        let Ok(ids) = db::attachments::events_with_attachment_hash(content_hash) else { return Vec::new() };
1030        let mut out = Vec::with_capacity(ids.len());
1031        for id in ids {
1032            if let Some(found) = self.get_message(&id).await {
1033                out.push(found);
1034            }
1035        }
1036        out
1037    }
1038
1039    /// Cursor-paged local read: up to `limit` messages strictly before the
1040    /// `(at_ms, id)` cursor, chronological. `None` returns the newest `limit`.
1041    ///
1042    /// The cursor is compared BY VALUE, never resolved to a stored row, so it
1043    /// keeps working after its message is gone (NIP-09 delete, moderation hide,
1044    /// a self-destruct timer) — a persisted cursor must not wedge the walk. The
1045    /// id tiebreaks inside a same-millisecond burst, giving a total order that
1046    /// can't skip or re-serve across pages. Local only: sync older history in
1047    /// from relays first.
1048    pub async fn get_messages_before(
1049        &self,
1050        chat_id: &str,
1051        before: Option<(u64, &str)>,
1052        limit: usize,
1053    ) -> Vec<Message> {
1054        let state = state::STATE.lock().await;
1055        let Some(chat) = state.get_chat(chat_id) else {
1056            return Vec::new();
1057        };
1058        let mut msgs = chat.get_all_messages(&state.interner);
1059        if let Some((at, id)) = before {
1060            msgs.retain(|m| (m.at, m.id.as_str()) < (at, id));
1061        }
1062        msgs.sort_by(|a, b| (a.at, a.id.as_str()).cmp(&(b.at, b.id.as_str())));
1063        if msgs.len() > limit {
1064            msgs.drain(..msgs.len() - limit);
1065        }
1066        msgs
1067    }
1068
1069    /// Get a profile by npub.
1070    pub async fn get_profile(&self, npub: &str) -> Option<SlimProfile> {
1071        let state = state::STATE.lock().await;
1072        state.get_profile(npub)
1073            .map(|p| SlimProfile::from_profile(p, &state.interner))
1074    }
1075
1076    /// Fetch a profile's metadata and status from relays.
1077    pub async fn load_profile(&self, npub: &str) -> bool {
1078        profile::sync::load_profile(npub.to_string(), &NoOpProfileSyncHandler).await
1079    }
1080
1081    /// Update the current user's profile metadata and broadcast to relays.
1082    pub async fn update_profile(&self, name: &str, avatar: &str, banner: &str, about: &str) -> bool {
1083        profile::sync::update_profile(
1084            name.to_string(), avatar.to_string(), banner.to_string(), about.to_string(),
1085            &NoOpProfileSyncHandler,
1086        ).await
1087    }
1088
1089    /// Like [`update_profile`](Self::update_profile) but marks the profile as a bot (`bot: true` in
1090    /// the metadata). The SDK uses this for every bot; build human clients on `update_profile`.
1091    pub async fn update_bot_profile(&self, name: &str, avatar: &str, banner: &str, about: &str) -> bool {
1092        profile::sync::update_bot_profile(
1093            name.to_string(), avatar.to_string(), banner.to_string(), about.to_string(),
1094            &NoOpProfileSyncHandler,
1095        ).await
1096    }
1097
1098    /// Update the current user's status and broadcast to relays.
1099    pub async fn update_status(&self, status: &str) -> bool {
1100        profile::sync::update_status(status.to_string()).await
1101    }
1102
1103    /// Upload an image file to Blossom **unencrypted** and return its public URL — for avatars,
1104    /// banners, and other images other clients must fetch directly. (The opposite of
1105    /// [`send_file`](Self::send_file)'s encrypted attachments.) Pass the URL to [`update_profile`].
1106    ///
1107    /// [`update_profile`]: Self::update_profile
1108    pub async fn upload_public_image(&self, file_path: &str) -> Result<String> {
1109        let path = std::path::Path::new(file_path);
1110        let bytes = std::fs::read(path).map_err(VectorError::Io)?;
1111        if bytes.is_empty() {
1112            return Err(VectorError::Other("Empty image file".into()));
1113        }
1114        let extension = path.extension().and_then(|e| e.to_str()).unwrap_or("bin").to_lowercase();
1115        let mime = crate::crypto::mime_from_extension(&extension);
1116        let _client = state::nostr_client().ok_or_else(|| VectorError::Other("Not logged in".into()))?;
1117        let signer = crate::signer::active_signer()
1118            .map_err(|e| VectorError::Other(format!("Signer unavailable: {e}")))?;
1119        let servers = crate::blossom_servers::compute_enabled_servers();
1120        if servers.is_empty() {
1121            return Err(VectorError::Other("No Blossom servers configured".into()));
1122        }
1123        // Avatars/banners run larger than emojis (up to ~1MB), so give a more generous
1124        // 20s idle window before treating a silent server as dead and failing over.
1125        crate::blossom::upload_blob_with_failover(
1126            signer,
1127            servers,
1128            std::sync::Arc::new(bytes),
1129            Some(mime),
1130            Some(std::time::Duration::from_secs(20)),
1131        )
1132        .await
1133        .map_err(VectorError::Other)
1134    }
1135
1136    /// Block a user by npub.
1137    pub async fn block_user(&self, npub: &str) -> bool {
1138        profile::sync::block_user(npub.to_string(), &NoOpProfileSyncHandler).await
1139    }
1140
1141    /// Unblock a user by npub.
1142    pub async fn unblock_user(&self, npub: &str) -> bool {
1143        profile::sync::unblock_user(npub.to_string(), &NoOpProfileSyncHandler).await
1144    }
1145
1146    /// Set a nickname for a profile.
1147    pub async fn set_nickname(&self, npub: &str, nickname: &str) -> bool {
1148        profile::sync::set_nickname(npub.to_string(), nickname.to_string(), &NoOpProfileSyncHandler).await
1149    }
1150
1151    /// Get all blocked profiles.
1152    pub async fn get_blocked_users(&self) -> Vec<SlimProfile> {
1153        profile::sync::get_blocked_users().await
1154    }
1155
1156    /// Queue a profile for background sync.
1157    pub fn queue_profile_sync(&self, npub: &str, priority: SyncPriority) {
1158        profile::sync::queue_profile_sync(npub.to_string(), priority, false);
1159    }
1160
1161    /// Get the current user's npub.
1162    pub fn my_npub(&self) -> Option<String> {
1163        state::my_public_key()
1164            .and_then(|pk| ToBech32::to_bech32(&pk).ok())
1165    }
1166
1167    // === Communities (headless) ===
1168    // The GUI's Tauri commands carry optimistic-echo + emit machinery a headless client
1169    // doesn't need; these are the lean equivalents over the same `community::service` layer,
1170    // so a CLI / agent can join, read, post, and sync a Community.
1171
1172    /// List every Community held locally (owned or joined), each with its channels.
1173    pub async fn list_communities(&self) -> Vec<serde_json::Value> {
1174        use crate::community::ConcordProtocol;
1175        let ids = crate::db::community::list_community_ids().unwrap_or_default();
1176        let mut out = Vec::new();
1177        for id in ids {
1178            // Dual-stack: dispatch each held community by its stored protocol.
1179            match crate::db::community::community_protocol(&id).ok().flatten() {
1180                Some(ConcordProtocol::V2) => {
1181                    if let Ok(Some(c)) = crate::db::community::load_community_v2(&id) {
1182                        let me = state::my_public_key();
1183                        let is_owner = me.is_some_and(|m| c.owner().is_ok_and(|o| o == m));
1184                        out.push(serde_json::json!({
1185                            "community_id": crate::simd::hex::bytes_to_hex_32(&c.identity.community_id.0),
1186                            "version": 2,
1187                            "name": c.name,
1188                            "description": c.description,
1189                            "is_owner": is_owner,
1190                            // A dissolved community is SEALED: the row survives (history
1191                            // is never auto-deleted) but no write is ever accepted again.
1192                            // Without this a bot cannot tell it from a live one and
1193                            // retries sends into a tombstone forever.
1194                            "dissolved": c.dissolved,
1195                            // `readable` is the field a bot needs and could never
1196                            // compute: a private channel we've been told about but
1197                            // hold no key for is enumerable-but-unreadable, which
1198                            // is what distinguishes "not a member" from "member
1199                            // awaiting the key vend".
1200                            "channels": c.channels.iter()
1201                                .map(|ch| serde_json::json!({
1202                                    "channel_id": crate::simd::hex::bytes_to_hex_32(&ch.id.0),
1203                                    "name": ch.name,
1204                                    "private": ch.private,
1205                                    "readable": !(ch.private && ch.key.is_none()),
1206                                    "epoch": ch.epoch.0,
1207                                }))
1208                                .collect::<Vec<_>>(),
1209                        }));
1210                    }
1211                }
1212                _ => {
1213                    if let Ok(Some(c)) = crate::db::community::load_community(&id) {
1214                        out.push(serde_json::json!({
1215                            "community_id": c.id.to_hex(),
1216                            "version": 1,
1217                            "name": c.name,
1218                            "description": c.description,
1219                            "is_owner": crate::community::service::is_proven_owner(&c),
1220                            "dissolved": c.dissolved,
1221                            "channels": c.channels.iter()
1222                                .map(|ch| serde_json::json!({ "channel_id": ch.id.to_hex(), "name": ch.name }))
1223                                .collect::<Vec<_>>(),
1224                        }));
1225                    }
1226                }
1227            }
1228        }
1229        out
1230    }
1231
1232    /// Create a fresh **Concord v2** community owned by the local identity (the
1233    /// SDK's default; the GUI's `create_community` stays v1 during the migration
1234    /// window). Mints the self-certifying id + genesis, persists, publishes, and
1235    /// registers each channel as a chat. Returns a `version: 2` JSON summary.
1236    pub async fn create_community_v2(&self, name: &str) -> Result<serde_json::Value> {
1237        use crate::community::{v2::service as v2, transport::LiveTransport};
1238        let relays: Vec<String> = crate::state::active_trusted_relays()
1239            .await
1240            .iter()
1241            .map(|s| s.to_string())
1242            .collect();
1243        if relays.is_empty() {
1244            return Err(VectorError::Other("no relays available to host the Community".into()));
1245        }
1246        let session = crate::db::current_session();
1247        let transport = LiveTransport::with_timeout(std::time::Duration::from_secs(12));
1248        let community = v2::create_community(&transport, name, relays, None)
1249            .await
1250            .map_err(VectorError::Other)?;
1251        self.register_v2_chats(&community, &session).await;
1252        // Start streaming this community's planes right away.
1253        if let Some(client) = state::nostr_client() {
1254            crate::community::v2::realtime::refresh_subscription(&client).await;
1255        }
1256        Ok(Self::v2_summary(&community))
1257    }
1258
1259    /// If `channel_id` belongs to a locally-held **v2** community, its
1260    /// `CommunityId`; `Ok(None)` for a v1 channel or unknown. The routing key for
1261    /// every dual-stack message op — a DB read error PROPAGATES (fail-closed)
1262    /// instead of silently routing a v2 channel down the v1 path on a transient
1263    /// failure.
1264    fn v2_community_for_channel(&self, channel_id: &str) -> Result<Option<crate::community::CommunityId>> {
1265        use crate::community::ConcordProtocol;
1266        let Some(cid_hex) = crate::db::community::community_id_for_channel(channel_id).map_err(VectorError::Other)? else {
1267            return Ok(None);
1268        };
1269        let cid = crate::community::CommunityId(crate::simd::hex::hex_to_bytes_32(&cid_hex));
1270        Ok(match crate::db::community::community_protocol(&cid).map_err(VectorError::Other)? {
1271            Some(ConcordProtocol::V2) => Some(cid),
1272            _ => None,
1273        })
1274    }
1275
1276    /// The `version: 2` JSON summary the SDK/facade hands back for a v2 community.
1277    fn v2_summary(community: &crate::community::v2::community::CommunityV2) -> serde_json::Value {
1278        let me = state::my_public_key();
1279        let is_owner = me.is_some_and(|m| community.owner().is_ok_and(|o| o == m));
1280        serde_json::json!({
1281            "community_id": crate::simd::hex::bytes_to_hex_32(&community.identity.community_id.0),
1282            "version": 2,
1283            "name": community.name,
1284            "description": community.description,
1285            "is_owner": is_owner,
1286            // The primary channel id, so the frontend can stamp every grafted chat row
1287            // the way `register_v2_chats` stamps the persisted ones. Without it each
1288            // channel claimed primary via the render fallback and a multi-channel
1289            // community drew one list row per channel until the next full reload.
1290            "primary_channel": community.primary_channel().map(|c| crate::simd::hex::bytes_to_hex_32(&c.id.0)),
1291            "channels": community.channels.iter()
1292                .map(|c| serde_json::json!({ "channel_id": crate::simd::hex::bytes_to_hex_32(&c.id.0), "name": c.name, "private": c.private }))
1293                .collect::<Vec<_>>(),
1294        })
1295    }
1296
1297    /// Register each of a v2 community's channels as a chat row (so it surfaces in
1298    /// the chat list / `communities()`), mirroring the v1 create path. `session`
1299    /// is captured by the caller BEFORE its network I/O, so this STATE write is
1300    /// skipped if the account swapped mid-flight (else we'd write A's community
1301    /// into B's in-memory chats).
1302    pub async fn register_v2_chats(&self, community: &crate::community::v2::community::CommunityV2, __session: &std::sync::Arc<crate::db::Session>) {
1303        register_v2_chats_inner(community).await
1304    }
1305}
1306
1307/// Free-function body of [`VectorCore::register_v2_chats`] — also the migration finalize's
1308/// chat stamp (it runs from a spawned task with no facade handle; only globals are touched).
1309pub(crate) async fn register_v2_chats_inner(community: &crate::community::v2::community::CommunityV2) {
1310    crate::db::scoped(async move {
1311        let owner_npub = community.owner().ok().and_then(|p| ToBech32::to_bech32(&p).ok());
1312            let me = state::my_public_key();
1313            let is_owner = me.is_some_and(|m| community.owner().is_ok_and(|o| o == m));
1314            let id_hex = crate::simd::hex::bytes_to_hex_32(&community.identity.community_id.0);
1315            // The chat list shows ONE row per community — the primary channel under the
1316            // community's metadata (v1-group parity; multi-channel UI is a later cut).
1317            let Some(primary) = community.primary_channel() else { return };
1318            let primary_hex = crate::simd::hex::bytes_to_hex_32(&primary.id.0);
1319            // Every channel gets a real chat row carrying its own name plus the community's
1320            // primary id. The chat list still shows ONE row per community (it renders only the
1321            // primary), but the sibling rows are now addressable, which is what lets the UI
1322            // reach a multi-channel community's other channels.
1323            let slims = {
1324                let mut st = state::STATE.lock().await;
1325                let mut slims = Vec::new();
1326                for ch in &community.channels {
1327                    let ch_hex = crate::simd::hex::bytes_to_hex_32(&ch.id.0);
1328                    st.upsert_community_chat(
1329                        &ch_hex,
1330                        &community.name,
1331                        community.description.as_deref().unwrap_or(""),
1332                        &id_hex,
1333                        is_owner,
1334                        community.icon.is_some(),
1335                        owner_npub.as_deref(),
1336                        Some(community.created_at_ms),
1337                        community.dissolved,
1338                        crate::community::ConcordProtocol::V2,
1339                        &ch.name,
1340                        &primary_hex,
1341                    );
1342                    if let Some(chat) = st.chats.iter().find(|c| c.id == ch_hex) {
1343                        slims.push(crate::db::chats::SlimChatDB::from_chat(chat, &st.interner));
1344                    }
1345                }
1346                slims
1347            };
1348            // Persist the rows so a fresh boot reloads each channel's name/metadata
1349            for slim in &slims {
1350                let _ = crate::db::chats::save_slim_chat(slim);
1351            }
1352    })
1353    .await
1354}
1355
1356/// How long a community's raid verdict stays warm. Long enough that opening the panel
1357/// right after seeing its badge is instant, short enough that a wave arriving now shows
1358/// up without the moderator reloading anything.
1359const RAID_REPORT_TTL_SECS: u64 = 90;
1360
1361/// Per-account: the verdict is derived from this account's database and its roster.
1362struct RaidReportCache;
1363
1364#[allow(clippy::type_complexity)]
1365struct PolicyReportCache;
1366
1367/// The engine's console report, memoised like the assessor's was: evaluating
1368/// decrypts a four-thousand-message window and clusters every author, so a
1369/// header badge asking on every render would be a real cost.
1370fn policy_report_cache(
1371) -> std::sync::Arc<std::sync::Mutex<std::collections::HashMap<String, (u64, std::sync::Arc<serde_json::Value>)>>> {
1372    crate::db::current_session().scoped::<PolicyReportCache, _>()
1373}
1374
1375fn raid_report_cache(
1376) -> std::sync::Arc<std::sync::Mutex<std::collections::HashMap<String, (u64, std::sync::Arc<crate::community::raid::RaidReport>)>>> {
1377    crate::db::current_session().scoped::<RaidReportCache, _>()
1378}
1379
1380impl VectorCore {
1381    /// Join a Community from a public invite URL (`vectorapp.io/invite#...`). Fetches the
1382    /// token-encrypted bundle, persists the member-view Community, and registers its channels
1383    /// as chats. Returns a JSON summary.
1384    pub async fn join_community(&self, invite_url: &str) -> Result<serde_json::Value> {
1385        use crate::community::{public_invite, service, transport::LiveTransport};
1386        // Dual-stack: a v2 link is `…/invite/<naddr>#<fragment>` (a naddr in the
1387        // path); a v1 link is `…/invite#<base64url>` (fragment only). Try the v2
1388        // parser first — it only succeeds on the v2 shape — then fall through to v1.
1389        if crate::community::v2::invite::parse_invite_link(invite_url).is_ok() {
1390            let session = crate::db::current_session();
1391            let transport = LiveTransport::with_timeout(std::time::Duration::from_secs(12));
1392            let community = crate::community::v2::service::accept_public_link(&transport, invite_url)
1393                .await
1394                .map_err(VectorError::Other)?;
1395            self.register_v2_chats(&community, &session).await;
1396            if let Some(client) = state::nostr_client() {
1397                crate::community::v2::realtime::refresh_subscription(&client).await;
1398            }
1399            // Seed the membership store post-join. With a live listen the follow
1400            // worker does it (and SURFACES the folded joins as presence lines —
1401            // the joiner sees the room's history, own join included); headless
1402            // callers seed directly (membership only, no feed to surface).
1403            if crate::community::v2::realtime::follow_worker_running() {
1404                crate::community::v2::realtime::enqueue_follow(community.id());
1405            } else {
1406                let seed_community = community.clone();
1407                db::spawn_bound(async move {
1408                    let transport = LiveTransport::with_timeout(std::time::Duration::from_secs(20));
1409                    if matches!(
1410                        crate::community::v2::service::sync_guestbook(&transport, &seed_community).await,
1411                        Ok(fresh) if !fresh.is_empty()
1412                    ) {
1413                        let cid_hex = crate::simd::hex::bytes_to_hex_32(&seed_community.id().0);
1414                        emit_event("community_refreshed", &serde_json::json!({ "community_id": cid_hex }));
1415                    }
1416                });
1417            }
1418            return Ok(Self::v2_summary(&community));
1419        }
1420        let (relays, token) = public_invite::parse_invite_url(invite_url)
1421            .map_err(|e| VectorError::Other(e.to_string()))?;
1422        let transport = LiveTransport::with_timeout(std::time::Duration::from_secs(12));
1423        let bundle = service::fetch_public_invite(&transport, &relays, &token)
1424            .await
1425            .map_err(VectorError::Other)?;
1426        let now = std::time::SystemTime::now()
1427            .duration_since(std::time::UNIX_EPOCH)
1428            .map(|d| d.as_secs())
1429            .unwrap_or(0);
1430        // Post-timelock door: a FRESH v1 join needs a migration carrier (the v2 on-ramp)
1431        // or it is refused. Decode-only view — nothing persists unless the gate passes.
1432        let probe_view = crate::community::invite::accept_invite(&bundle.join).map_err(VectorError::Other)?;
1433        crate::community::migration::gate_fresh_v1_join(&transport, &probe_view, now)
1434            .await
1435            .map_err(VectorError::Other)?;
1436        let community = service::accept_public_invite(&bundle, now).map_err(VectorError::Other)?;
1437        // Attribute our join presence to the link we used (creator + label) so the owner's per-link
1438        // counter ticks. Mirrors the desktop public-join path.
1439        let attribution = bundle.creator_npub.clone().map(|by| (by, bundle.label.clone()));
1440        self.finalize_member_join(community, &transport, attribution).await
1441    }
1442
1443    /// List the parked private invites (giftwrapped) awaiting acceptance. Each entry is the
1444    /// community id, its name (from the stored bundle), and the inviter's npub.
1445    pub fn list_pending_invites(&self) -> Result<Vec<serde_json::Value>> {
1446        let rows = crate::db::community::list_pending_invites().map_err(VectorError::Other)?;
1447        Ok(rows.iter().map(|p| {
1448            // A v2 bundle carries owner_salt/community_root and self-certifies its
1449            // owner; a successful (validating) v2 parse means the modern protocol.
1450            if let Ok(v2) = crate::community::v2::invite::CommunityInvite::from_bundle_json(&p.bundle_json) {
1451                serde_json::json!({
1452                    "community_id": p.community_id,
1453                    "name": v2.name,
1454                    "inviter_npub": p.inviter_npub,
1455                    "version": 2,
1456                })
1457            } else {
1458                let name = crate::community::invite::CommunityInvite::from_json(&p.bundle_json)
1459                    .ok().map(|i| i.name).unwrap_or_default();
1460                serde_json::json!({
1461                    "community_id": p.community_id,
1462                    "name": name,
1463                    "inviter_npub": p.inviter_npub,
1464                    "version": 1,
1465                })
1466            }
1467        }).collect())
1468    }
1469
1470    /// Accept a PARKED private invite by community id: rebuild the member-view Community from the stored
1471    /// bundle, finalize the join exactly like a public link, then drop the pending row. Mirrors the
1472    /// desktop's consent-then-join for an invite delivered over a gift wrap.
1473    pub async fn accept_pending_invite(&self, community_id: &str) -> Result<serde_json::Value> {
1474        crate::db::scoped(async move {
1475            use crate::community::transport::LiveTransport;
1476            let bundle_json = crate::db::community::get_pending_invite(community_id)
1477                .map_err(VectorError::Other)?
1478                .ok_or_else(|| VectorError::Other(format!("no pending invite for {community_id}")))?;
1479            let transport = LiveTransport::with_timeout(std::time::Duration::from_secs(12));
1480
1481            // Dual-stack: a validating v2 bundle parse means a v2 Direct Invite.
1482            if crate::community::v2::invite::CommunityInvite::from_bundle_json(&bundle_json).is_ok() {
1483                let session = crate::db::current_session();
1484                // The inviter's hex (parked at receive) attributes the Guestbook Join.
1485                let inviter = crate::db::community::list_pending_invites()
1486                    .ok()
1487                    .and_then(|rows| rows.into_iter().find(|p| p.community_id == community_id).map(|p| p.inviter_npub));
1488                // On failure the parked row is LEFT INTACT for retry — we must NOT auto-delete
1489                // on a verify failure: the multi-relay transport launders an unreachable-relay
1490                // error into an empty fetch, which yields the same "could not verify" as a
1491                // forged root (and a control-plane flood does too), so an auto-delete would
1492                // erase a GENUINE invite on a transient blip or an attacker's flood. A
1493                // pre-planted forged-root bundle (deferred protocol residual) is instead
1494                // cleared by the user declining it.
1495                //
1496                // ONE exception: a verified dissolution is a verdict, not a maybe — an
1497                // owner-signed grave has no undo (§9), so that invite can never succeed
1498                // and is retired everywhere instead of parking a permanent error loop.
1499                let community = match crate::community::v2::service::accept_parked_invite(&transport, &bundle_json, inviter.as_deref()).await {
1500                    Ok(c) => c,
1501                    Err(e) if e == crate::community::v2::service::ERR_DISSOLVED => {
1502                        let relays = crate::community::v2::invite::CommunityInvite::from_bundle_json(&bundle_json)
1503                            .map(|i| i.relays)
1504                            .unwrap_or_default();
1505                        crate::community::v2::service::retire_dead_invite(&transport, community_id, &relays).await;
1506                        return Err(VectorError::Other("this community has been dissolved — the invite was removed".into()));
1507                    }
1508                    Err(e) => return Err(VectorError::Other(e)),
1509                };
1510                self.register_v2_chats(&community, &session).await;
1511                if let Some(client) = state::nostr_client() {
1512                    crate::community::v2::realtime::refresh_subscription(&client).await;
1513                }
1514                crate::community::v2::realtime::enqueue_follow(community.id());
1515                let _ = crate::db::community::delete_pending_invite(community_id);
1516                return Ok(Self::v2_summary(&community));
1517            }
1518
1519            // v1 route.
1520            use crate::community::invite::{accept_invite, CommunityInvite};
1521            let invite = CommunityInvite::from_json(&bundle_json).map_err(VectorError::Other)?;
1522            let community = accept_invite(&invite).map_err(VectorError::Other)?;
1523            // Post-timelock door: a FRESH v1 join needs a migration carrier (the v2 on-ramp) or it
1524            // is refused — before finalize persists anything. The migrated fence inside
1525            // finalize_member_join still wins for held communities.
1526            let now = std::time::SystemTime::now()
1527                .duration_since(std::time::UNIX_EPOCH)
1528                .map(|d| d.as_secs())
1529                .unwrap_or(0);
1530            crate::community::migration::gate_fresh_v1_join(&transport, &community, now)
1531                .await
1532                .map_err(VectorError::Other)?;
1533            // Private invites carry no public-link label; the inviter attribution metric is link-only.
1534            let summary = self.finalize_member_join(community, &transport, None).await?;
1535            let _ = crate::db::community::delete_pending_invite(community_id);
1536            Ok(summary)
1537        })
1538        .await
1539    }
1540
1541    /// Shared finalization for joining a Community as a member — public link OR accepted private invite.
1542    /// Walks any base rekey, folds the LATEST control plane (so the joiner sees current metadata, not
1543    /// the bundle's genesis snapshot), refuses if banned, registers the channels as chats, and announces
1544    /// presence. Returns the JSON summary.
1545    pub(crate) async fn finalize_member_join<T: crate::community::transport::Transport + ?Sized>(
1546        &self,
1547        community: crate::community::Community,
1548        transport: &T,
1549        attribution: Option<(String, Option<String>)>,
1550    ) -> Result<serde_json::Value> {
1551        use crate::community::service;
1552        // Migration fence: if this v1 community already flipped to v2, a stale parked
1553        // invite or a lingering link must NOT re-run `save_community` — its blind UPSERT would
1554        // re-parent the stitched channel rows back to v1 with v1 keys (the catastrophic mixed
1555        // state). Short-circuit to "already upgraded"; the rows stay v2-owned. This is
1556        // `migrated_to`-aware (not a blind dissolved gate) precisely so a FRESH joiner redeeming
1557        // a still-live link stays on the open on-ramp path below.
1558        if let Ok(Some(v2)) = crate::db::community::get_migrated_to(&community.id.to_hex()) {
1559            return Ok(serde_json::json!({
1560                "community_id": v2,
1561                "version": 2,
1562                "migrated": true,
1563            }));
1564        }
1565        // Persist the member-view row up front: the catch-up, the control fold, and chat registration all
1566        // read it back from the DB. A private bundle (unlike a public one with a preview) arrives with no
1567        // display metadata, so nothing else would have saved it. UPSERT — re-saving a public join is a no-op.
1568        crate::db::community::save_community(&community).map_err(VectorError::Other)?;
1569        // The bundle's root can predate a base rotation, so walk any rekey first (no-op if none) — then
1570        // re-load so the control fold + registration happen at the CURRENT epoch.
1571        if let Ok(c) = service::catch_up_server_root(transport, &community).await {
1572            if c.removed {
1573                let _ = crate::db::community::delete_community(&community.id.to_hex());
1574                return Err(VectorError::Other("you have been removed from this community".into()));
1575            }
1576        }
1577        let community = crate::db::community::load_community(&community.id)
1578            .map_err(VectorError::Other)?
1579            .unwrap_or(community);
1580        // Fold the LATEST control plane before we register anything — the joiner should see the current
1581        // name/description/roster/mode immediately, not a stale snapshot. Banlist first: an honest client
1582        // REFUSES to join if this npub is banned (and the just-saved community is torn back down).
1583        let _ = service::fetch_and_apply_control(transport, &community).await;
1584        if service::am_i_banned(&community) {
1585            let _ = crate::db::community::delete_community(&community.id.to_hex());
1586            return Err(VectorError::Other("you are banned from this community".into()));
1587        }
1588        // Re-load so the chat we register + the summary we return carry the freshly-folded latest metadata.
1589        let community = crate::db::community::load_community(&community.id)
1590            .map_err(VectorError::Other)?
1591            .unwrap_or(community);
1592        let owner_npub = community
1593            .owner_attestation
1594            .as_ref()
1595            .and_then(|att| crate::community::owner::verify_owner_attestation(att, &community.id.to_hex()))
1596            .and_then(|pk| ToBech32::to_bech32(&pk).ok());
1597        {
1598            let created_at_ms = crate::db::community::community_created_at_ms(&community.id);
1599            let primary_hex = community.channels.first().map(|c| c.id.to_hex()).unwrap_or_default();
1600            let mut st = state::STATE.lock().await;
1601            for ch in &community.channels {
1602                st.upsert_community_chat(
1603                    &ch.id.to_hex(),
1604                    &community.name,
1605                    community.description.as_deref().unwrap_or(""),
1606                    &community.id.to_hex(),
1607                    crate::community::service::is_proven_owner(&community),
1608                    community.icon.is_some(),
1609                    owner_npub.as_deref(),
1610                    created_at_ms,
1611                    community.dissolved,
1612                    crate::community::ConcordProtocol::V1,
1613                    &ch.name,
1614                    &primary_hex,
1615                );
1616            }
1617        }
1618        // Best-effort join announcement (kind 3306) into the primary channel so honest peers
1619        // see us in their member list even before we post. Failure must not fail the join.
1620        if let Some(primary) = community.channels.first() {
1621            let _ = service::publish_presence(transport, &community, primary, true, attribution).await;
1622        }
1623        Ok(serde_json::json!({
1624            "community_id": community.id.to_hex(),
1625            "version": 1,
1626            "name": community.name,
1627            "channels": community.channels.iter()
1628                .map(|c| serde_json::json!({ "channel_id": c.id.to_hex(), "name": c.name }))
1629                .collect::<Vec<_>>(),
1630        }))
1631    }
1632
1633
1634    // ── Channels (CORD-03) ───────────────────────────────────────────────────
1635
1636    /// Resolve a v2 community by id, or explain why it isn't one.
1637    fn v2_community(community_id: &str) -> Result<crate::community::v2::community::CommunityV2> {
1638        use crate::community::CommunityId;
1639        if community_id.len() != 64 {
1640            return Err(VectorError::Other("malformed community id".into()));
1641        }
1642        let cid = CommunityId(crate::simd::hex::hex_to_bytes_32(community_id));
1643        match crate::db::community::community_protocol(&cid).ok().flatten() {
1644            Some(crate::community::ConcordProtocol::V2) => crate::db::community::load_community_v2(&cid)
1645                .map_err(VectorError::Other)?
1646                .ok_or_else(|| VectorError::Other("v2 community not found".into())),
1647            Some(_) => Err(VectorError::Other(
1648                "channel management is Concord v2 only — this community still uses the legacy protocol".into(),
1649            )),
1650            None => Err(VectorError::Other("community not found".into())),
1651        }
1652    }
1653
1654    fn channel_id_of(channel_id: &str) -> Result<crate::community::ChannelId> {
1655        crate::simd::hex::hex_to_bytes_32_checked(channel_id)
1656            .map(crate::community::ChannelId)
1657            .ok_or_else(|| VectorError::Other("malformed channel id".into()))
1658    }
1659
1660    /// Create a channel. A private one mints its own key plus the channel-scoped
1661    /// access Role that is its access list (CORD-03/04); grant that role with
1662    /// [`grant_channel_access`](Self::grant_channel_access) to let someone read it.
1663    /// Returns the new channel id (32-byte hex).
1664    pub async fn create_channel(&self, community_id: &str, name: &str, private: bool) -> Result<String> {
1665        use crate::community::{v2::service, transport::LiveTransport};
1666        let community = Self::v2_community(community_id)?;
1667        let transport = LiveTransport::with_timeout(std::time::Duration::from_secs(12));
1668        let id = if private {
1669            service::create_private_channel(&transport, &community, name).await
1670        } else {
1671            service::create_public_channel(&transport, &community, name).await
1672        }
1673        .map_err(VectorError::Other)?;
1674        // Subscribe the new channel's chat plane now — waiting on the round-trip of
1675        // our own vsk-2 edition would leave the creator deaf to first replies.
1676        if let Some(client) = state::nostr_client() {
1677            crate::community::v2::realtime::refresh_subscription(&client).await;
1678        }
1679        Ok(crate::simd::hex::bytes_to_hex_32(&id.0))
1680    }
1681
1682    /// Rename a channel (a versioned edit of the same entity, so its history and
1683    /// id survive). Other folded fields are carried through untouched.
1684    pub async fn rename_channel(&self, community_id: &str, channel_id: &str, name: &str) -> Result<()> {
1685        use crate::community::{v2::service, transport::LiveTransport};
1686        let community = Self::v2_community(community_id)?;
1687        let id = Self::channel_id_of(channel_id)?;
1688        let mut meta = community
1689            .channel(&id)
1690            .ok_or_else(|| VectorError::Other("unknown channel".into()))?
1691            .metadata();
1692        meta.name = name.to_string();
1693        let transport = LiveTransport::with_timeout(std::time::Duration::from_secs(12));
1694        service::edit_channel_metadata(&transport, &community, &id, &meta)
1695            .await
1696            .map_err(VectorError::Other)
1697    }
1698
1699    /// Tombstone a channel. Deletion is terminal and the id is never reused;
1700    /// history stays readable to anyone who already holds its keys.
1701    pub async fn delete_channel(&self, community_id: &str, channel_id: &str) -> Result<()> {
1702        use crate::community::{v2::service, transport::LiveTransport};
1703        let community = Self::v2_community(community_id)?;
1704        let id = Self::channel_id_of(channel_id)?;
1705        let name = community.channel(&id).map(|c| c.name.clone()).unwrap_or_default();
1706        let transport = LiveTransport::with_timeout(std::time::Duration::from_secs(12));
1707        service::delete_channel(&transport, &community, &id, &name)
1708            .await
1709            .map_err(VectorError::Other)
1710    }
1711
1712    /// Grant `npub` read access to a private channel: adds the channel's access
1713    /// role and vends the key to them (CORD-03 "delivered on grant").
1714    pub async fn grant_channel_access(&self, community_id: &str, channel_id: &str, npub: &str) -> Result<()> {
1715        use crate::community::{v2::service, transport::LiveTransport};
1716        let community = Self::v2_community(community_id)?;
1717        let id = Self::channel_id_of(channel_id)?;
1718        let member = nostr_sdk::prelude::PublicKey::parse(npub)
1719            .map_err(|e| VectorError::Other(format!("bad npub: {e}")))?;
1720        let transport = LiveTransport::with_timeout(std::time::Duration::from_secs(12));
1721        service::grant_channel_access(&transport, &community, &id, &member)
1722            .await
1723            .map_err(VectorError::Other)
1724    }
1725
1726    /// Revoke `npub`'s read access: drops the access role and rekeys the channel
1727    /// so the removal actually severs them (CORD-06). They keep whatever history
1728    /// they already read — a rekey protects the future, never the past.
1729    pub async fn revoke_channel_access(&self, community_id: &str, channel_id: &str, npub: &str) -> Result<()> {
1730        use crate::community::{v2::service, transport::LiveTransport};
1731        let community = Self::v2_community(community_id)?;
1732        let id = Self::channel_id_of(channel_id)?;
1733        let member = nostr_sdk::prelude::PublicKey::parse(npub)
1734            .map_err(|e| VectorError::Other(format!("bad npub: {e}")))?;
1735        let transport = LiveTransport::with_timeout(std::time::Duration::from_secs(12));
1736        service::revoke_channel_access(&transport, &community, &id, &member)
1737            .await
1738            .map_err(VectorError::Other)
1739    }
1740
1741    /// Who may read a private channel: the channel-scoped Roles that are its
1742    /// access list (CORD-04 §2) and the members holding them.
1743    ///
1744    /// Sync read off the folded roster. `members` is exactly the access-role
1745    /// holders; `owner` is reported alongside because the owner is entitled
1746    /// whether or not they hold one (a channel's creator is granted the role, so
1747    /// an owner-created channel lists them in both).
1748    /// Whether `npub` may READ `channel_id` here.
1749    ///
1750    /// A PUBLIC channel is readable by the community; a PRIVATE one only by the
1751    /// holders of one of its channel-scoped access roles (CORD-03), plus the
1752    /// owner. Community-level power is NOT the question and must not be
1753    /// substituted for it: BAN says what someone may do to people, never which
1754    /// rooms they may see, and an access role commonly carries no powers at all.
1755    ///
1756    /// The reason this exists: a moderation report quotes what was said. Sending
1757    /// one built from a private channel to a moderator who holds no key there
1758    /// would launder the content out of the room, using the bot as the bypass —
1759    /// so anything that forwards evidence asks this first.
1760    ///
1761    /// Fails closed. A banned member reads nothing, and an unknown channel is an
1762    /// error rather than a permissive default.
1763    pub fn member_can_read_channel(&self, community_id: &str, channel_id: &str, npub: &str) -> Result<bool> {
1764        use nostr_sdk::prelude::PublicKey;
1765        let community = Self::v2_community(community_id)?;
1766        let id = Self::channel_id_of(channel_id)?;
1767        let ch = community.channel(&id).ok_or_else(|| VectorError::Other("unknown channel".into()))?;
1768        let who = PublicKey::parse(npub).map_err(|_| VectorError::Other(format!("invalid npub: {npub}")))?.to_hex();
1769        let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
1770        if crate::db::community::get_community_banlist(&cid_hex).unwrap_or_default().contains(&who) {
1771            return Ok(false);
1772        }
1773        if !ch.private {
1774            return Ok(true);
1775        }
1776        if community.owner().map(|o| o.to_hex()) == Ok(who.clone()) {
1777            return Ok(true);
1778        }
1779        let roster = crate::db::community::get_community_roles(&cid_hex).map_err(VectorError::Other)?;
1780        let access_ids = roster.channel_role_ids(&crate::simd::hex::bytes_to_hex_32(&id.0));
1781        Ok(roster
1782            .grants
1783            .iter()
1784            .filter(|g| g.member == who)
1785            .any(|g| g.role_ids.iter().any(|rid| access_ids.contains(rid))))
1786    }
1787
1788    pub fn channel_access(&self, community_id: &str, channel_id: &str) -> Result<serde_json::Value> {
1789        use nostr_sdk::prelude::{PublicKey, ToBech32};
1790        let community = Self::v2_community(community_id)?;
1791        let id = Self::channel_id_of(channel_id)?;
1792        let ch = community
1793            .channel(&id)
1794            .ok_or_else(|| VectorError::Other("unknown channel".into()))?;
1795        // Normalised id, not the caller's string: an uppercase-hex argument loads
1796        // the community but would miss the roster row and silently report nobody.
1797        let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
1798        let roster = crate::db::community::get_community_roles(&cid_hex).map_err(VectorError::Other)?;
1799        let banned = crate::db::community::get_community_banlist(&cid_hex).unwrap_or_default();
1800        let chan_hex = crate::simd::hex::bytes_to_hex_32(&id.0);
1801        let access_ids = roster.channel_role_ids(&chan_hex);
1802        let roles: Vec<serde_json::Value> = roster
1803            .channel_roles(&chan_hex)
1804            .into_iter()
1805            .map(|r| serde_json::json!({ "role_id": r.role_id, "name": r.name }))
1806            .collect();
1807        let members: Vec<String> = roster
1808            .grants
1809            .iter()
1810            .filter(|g| !banned.contains(&g.member))
1811            .filter(|g| g.role_ids.iter().any(|rid| access_ids.contains(rid)))
1812            .filter_map(|g| PublicKey::from_hex(&g.member).ok().and_then(|pk| pk.to_bech32().ok()))
1813            .collect();
1814        Ok(serde_json::json!({
1815            "channel_id": chan_hex,
1816            "private": ch.private,
1817            "readable": !(ch.private && ch.key.is_none()),
1818            "owner": community.owner().ok().and_then(|o| o.to_bech32().ok()),
1819            "roles": roles,
1820            "members": members,
1821        }))
1822    }
1823
1824    /// Mint a public invite link for a Community this identity owns. Returns the shareable URL.
1825    /// `expires_at_ms` is an ABSOLUTE unix timestamp in MILLISECONDS (v2's native unit and the
1826    /// `InviteEntry` wire's); the v1 path takes seconds, so it is converted here rather than at
1827    /// each call site. `label` is the attribution bucket shown as "joined via <label>".
1828    pub async fn create_public_invite(
1829        &self,
1830        community_id: &str,
1831        expires_at_ms: Option<u64>,
1832        label: Option<String>,
1833    ) -> Result<String> {
1834        use crate::community::{service, transport::LiveTransport, CommunityId};
1835        if community_id.len() != 64 {
1836            return Err(VectorError::Other("malformed community id".into()));
1837        }
1838        let cid = CommunityId(crate::simd::hex::hex_to_bytes_32(community_id));
1839        // Dual-stack: mint a v2 link for a v2 community (naddr#fragment).
1840        if let Some(Some(crate::community::ConcordProtocol::V2)) =
1841            crate::db::community::community_protocol(&cid).ok()
1842        {
1843            let community = crate::db::community::load_community_v2(&cid)
1844                .map_err(VectorError::Other)?
1845                .ok_or_else(|| VectorError::Other("v2 community not found".into()))?;
1846            let transport = LiveTransport::with_timeout(std::time::Duration::from_secs(12));
1847            // v2 `build_invite_url` appends its own `/invite/<naddr>`, so pass the
1848            // bare domain (strip the `/invite` the v1 constant carries).
1849            let base = crate::community::public_invite::INVITE_URL_BASE.trim_end_matches("/invite");
1850            let minted =
1851                crate::community::v2::service::mint_public_link(&transport, &community, base, expires_at_ms, label)
1852                    .await
1853                    .map_err(VectorError::Other)?;
1854            return Ok(minted.url);
1855        }
1856        let community = crate::db::community::load_community(&CommunityId(
1857            crate::simd::hex::hex_to_bytes_32(community_id),
1858        ))
1859        .map_err(VectorError::Other)?
1860        .ok_or_else(|| VectorError::Other("community not found".into()))?;
1861        let transport = LiveTransport::with_timeout(std::time::Duration::from_secs(12));
1862        let expires_at_secs = expires_at_ms.map(|ms| ms / 1000);
1863        let (_token, url) = service::create_public_invite(&transport, &community, expires_at_secs, label)
1864            .await
1865            .map_err(VectorError::Other)?;
1866        Ok(url)
1867    }
1868
1869    /// Send a PRIVATE invite: gift-wrap this Community's invite bundle directly to an npub over a NIP-17
1870    /// DM (the same transport as a regular DM). The invitee parks it pending consent (accept_pending_invite).
1871    /// Requires CREATE_INVITE; a banned npub can't be re-invited. Returns the wrap's event id + relays.
1872    pub async fn invite_to_community(&self, community_id: &str, invitee_npub: &str) -> Result<serde_json::Value> {
1873        crate::db::scoped(async move {
1874            use crate::community::{service, CommunityId};
1875            use crate::sending::{send_rumor_dm, NoOpSendCallback, SendCallback, SendConfig};
1876
1877            let my_pk = crate::state::my_public_key()
1878                .ok_or_else(|| VectorError::Other("Public key not set".into()))?;
1879
1880            if community_id.len() != 64 {
1881                return Err(VectorError::Other("malformed community id".into()));
1882            }
1883            let cid = CommunityId(crate::simd::hex::hex_to_bytes_32(community_id));
1884            // Dual-stack: a v2 community sends a Direct Invite (3313 giftwrap).
1885            // DELIBERATELY ungated, unlike v1's CREATE_INVITE + banlist pre-check: a
1886            // Direct Invite is an ungateable key handoff (CORD-05 §6 — "any keyholder
1887            // can whisper keys"), so any member may extend one; the real access cut is
1888            // the rekey, not a permission on inviting.
1889            if let Some(Some(crate::community::ConcordProtocol::V2)) =
1890                crate::db::community::community_protocol(&cid).ok()
1891            {
1892                let recipient = nostr_sdk::prelude::PublicKey::parse(invitee_npub)
1893                    .map_err(|e| VectorError::Other(format!("bad invitee npub: {e}")))?;
1894                let client = crate::state::nostr_client().ok_or_else(|| VectorError::Other("Not connected".into()))?;
1895                // Gift-wrap the 3313 Direct-Invite rumor (the bundle JSON) to the RECIPIENT'S
1896                // inbox relays (kind-10050) — a not-yet-member sees it on their DM sub;
1897                // the community relays wouldn't reach them. `#k=3313` per CORD-05 §6.
1898                //
1899                // Load + snapshot UNDER the rotation lock: a bundle minted while a Ban's
1900                // refound is mid-rotation carries the root being buried, and its joiner
1901                // lands on a dead epoch only to self-evict on the rekey exclusion.
1902                let bundle = {
1903                    let lock = crate::community::v2::realtime::follow_lock(&cid);
1904                    let _rotation = lock.lock().await;
1905                    let community = crate::db::community::load_community_v2(&cid)
1906                        .map_err(VectorError::Other)?
1907                        .ok_or_else(|| VectorError::Other("v2 community not found".into()))?;
1908                    crate::community::v2::service::bundle_of(
1909                        &community,
1910                        crate::community::v2::service::BundleAudience::Member(recipient),
1911                        Some(my_pk),
1912                        None,
1913                        None,
1914                    )
1915                };
1916                let bundle_json = serde_json::to_string(&bundle).map_err(|e| VectorError::Other(e.to_string()))?;
1917                // Same 24h NIP-40 expiry as v1 (invite::DIRECT_INVITE_EXPIRY_SECS): a bundle is
1918                // live key material for a community that keeps rotating, so it must not linger.
1919                let expires_at = nostr_sdk::prelude::Timestamp::now().as_secs()
1920                    + crate::community::invite::DIRECT_INVITE_EXPIRY_SECS;
1921                let expiry_tag = nostr_sdk::prelude::Tag::expiration(nostr_sdk::prelude::Timestamp::from_secs(expires_at));
1922                let rumor = nostr_sdk::prelude::EventBuilder::new(
1923                    nostr_sdk::prelude::Kind::Custom(crate::community::v2::kind::DIRECT_INVITE),
1924                    bundle_json,
1925                )
1926                .tag(expiry_tag.clone())
1927                .finalize_unsigned_with_id(my_pk);
1928                let k_tag = nostr_sdk::prelude::Tag::custom(
1929                    "k",
1930                    [crate::community::v2::kind::DIRECT_INVITE.to_string()],
1931                );
1932                crate::inbox_relays::send_gift_wrap(&client, &recipient, rumor, [k_tag, expiry_tag])
1933                    .await
1934                    .map_err(VectorError::Other)?;
1935                return Ok(serde_json::json!({ "invited": invitee_npub, "version": 2 }));
1936            }
1937            let community = crate::db::community::load_community(&CommunityId(
1938                crate::simd::hex::hex_to_bytes_32(community_id),
1939            ))
1940            .map_err(VectorError::Other)?
1941            .ok_or_else(|| VectorError::Other("community not found".into()))?;
1942
1943            if !service::caller_has_permission(&community, crate::community::roles::Permissions::CREATE_INVITE) {
1944                return Err(VectorError::Other("You need the create-invite permission to invite someone".into()));
1945            }
1946            let invitee_hex = nostr_sdk::prelude::PublicKey::parse(invitee_npub)
1947                .map_err(|_| VectorError::Other("invalid npub".into()))?
1948                .to_hex();
1949            if crate::db::community::get_community_banlist(community_id)
1950                .map_err(VectorError::Other)?
1951                .iter()
1952                .any(|b| b == &invitee_hex)
1953            {
1954                return Err(VectorError::Other("That member is banned from this community and can't be invited".into()));
1955            }
1956
1957
1958            let now = nostr_sdk::prelude::Timestamp::now().as_secs();
1959            let rumor = crate::community::invite::build_invite_rumor(&community, my_pk, now)
1960                .map_err(VectorError::Other)?;
1961            let pending_id = format!("community-invite-{}", community_id);
1962            // self_send=false: the owner already holds the Community; the inbound guard would drop the echo.
1963            let config = SendConfig { self_send: false, ..SendConfig::gui() };
1964            let callback: Arc<dyn SendCallback> = Arc::new(NoOpSendCallback);
1965
1966            let result = send_rumor_dm(invitee_npub, &pending_id, rumor, &config, callback)
1967                .await
1968                .map_err(VectorError::Other)?;
1969
1970            Ok(serde_json::json!({
1971                "community_id": community_id,
1972                "invitee": invitee_npub,
1973                "wrap_event_id": result.event_id,
1974            }))
1975        })
1976        .await
1977    }
1978
1979    /// The public invite links this account minted for a Community (to list + revoke). Each carries
1980    /// the hex `token` (the link secret) needed by [`Self::revoke_public_invite`]. A local read for
1981    /// both protocols — links minted on this device (a v2 mint also syncs the cross-device 13303
1982    /// record; v2 `join_count` is not yet tracked and is always 0).
1983    pub fn list_public_invites(&self, community_id: &str) -> Result<Vec<crate::db::community::PublicInviteRecord>> {
1984        crate::db::community::list_public_invites(community_id).map_err(VectorError::Other)
1985    }
1986
1987    /// Revoke a public invite link by its hex token. Retiring the LAST active link flips the Community to
1988    /// Private, which re-founds (rotates the base key + every channel key) to cut link-joined lurkers.
1989    /// Idempotent: a token this account doesn't hold is a no-op. Needs a local key when the revoke triggers
1990    /// the privatize rekey (a bunker account can't rotate).
1991    pub async fn revoke_public_invite(&self, community_id: &str, token: &str) -> Result<()> {
1992        use crate::community::{service, transport::LiveTransport, CommunityId};
1993        if community_id.len() != 64 {
1994            return Err(VectorError::Other("malformed community id".into()));
1995        }
1996        let cid = CommunityId(crate::simd::hex::hex_to_bytes_32(community_id));
1997        let transport = LiveTransport::with_timeout(std::time::Duration::from_secs(20));
1998        // Dual-stack: a v2 link is retired by its 16-byte token hex (re-post the
1999        // coordinate as a tombstone + tombstone the 13303 entry + refresh the Registry).
2000        if let Some(Some(crate::community::ConcordProtocol::V2)) = crate::db::community::community_protocol(&cid).ok() {
2001            let community = crate::db::community::load_community_v2(&cid)
2002                .map_err(VectorError::Other)?
2003                .ok_or_else(|| VectorError::Other("v2 community not found".into()))?;
2004            return crate::community::v2::service::revoke_public_link(&transport, &community, token)
2005                .await
2006                .map_err(VectorError::Other);
2007        }
2008        let token_bytes = crate::simd::hex::hex_to_bytes_32(token);
2009        let community = crate::db::community::load_community(&cid)
2010            .map_err(VectorError::Other)?
2011            .ok_or_else(|| VectorError::Other("community not found".into()))?;
2012        service::revoke_public_invite(&transport, &community, &token_bytes)
2013            .await
2014            .map_err(VectorError::Other)
2015    }
2016
2017    /// Post a text message to a Community channel. Returns the message id (the inner id).
2018    pub async fn send_community_message(
2019        &self,
2020        channel_id: &str,
2021        content: &str,
2022        replied_to: Option<&str>,
2023    ) -> Result<String> {
2024        self.send_community_message_expiring(channel_id, content, replied_to, None).await
2025    }
2026
2027    /// Post a message that deletes itself after `expires_in_secs`.
2028    ///
2029    /// A NIP-40 `expiration` tag on the rumor, which is what relays drop on and
2030    /// every member's client purges against — the same mechanism as the
2031    /// per-channel Self-Destruct Timer, decided per message instead of per
2032    /// channel. For a bot posting a public moderation notice: the warning is
2033    /// seen, then it stops being a permanent monument to somebody's worst day.
2034    ///
2035    /// v2 only. A v1 community has no expiry, and posting a permanent message
2036    /// where a vanishing one was asked for is the kind of surprise that ends up
2037    /// in a screenshot, so it refuses rather than silently keeping it forever.
2038    pub async fn send_community_message_expiring(
2039        &self,
2040        channel_id: &str,
2041        content: &str,
2042        replied_to: Option<&str>,
2043        expires_in_secs: Option<u64>,
2044    ) -> Result<String> {
2045        use crate::community::{envelope, inbound, service, transport::LiveTransport};
2046        // Dual-stack: route by the owning community's stored protocol.
2047        if let Some(id) = self.v2_community_for_channel(channel_id)? {
2048            let community = crate::db::community::load_community_v2(&id)
2049                .map_err(VectorError::Other)?
2050                .ok_or_else(|| VectorError::Other("v2 community not found".into()))?;
2051            let ch = crate::community::ChannelId(crate::simd::hex::hex_to_bytes_32(channel_id));
2052            let transport = LiveTransport::with_timeout(std::time::Duration::from_secs(12));
2053            // The NIP-C7 q tag's author slot is a SHOULD — best-effort from the
2054            // held message, empty (= unknown) when the parent isn't in memory.
2055            let reply = match replied_to.filter(|r| !r.is_empty()) {
2056                Some(parent_id) => {
2057                    let author_hex = {
2058                        let st = state::STATE.lock().await;
2059                        st.find_message(parent_id)
2060                            .and_then(|(_, m)| m.npub.as_deref().and_then(|n| nostr_sdk::prelude::PublicKey::parse(n).ok()))
2061                            .map(|pk| pk.to_hex())
2062                            .unwrap_or_default()
2063                    };
2064                    Some((parent_id.to_string(), author_hex))
2065                }
2066                None => None,
2067            };
2068            let reply_ref = reply.as_ref().map(|(id, author)| (id.as_str(), author.as_str()));
2069            // NIP-30: resolve `:shortcode:` against subscribed packs so the rumor
2070            // carries `["emoji", ...]` pairs — parity with the v1 inner event.
2071            let emoji_owned = crate::emoji_packs::resolve_outbound_emoji_tags(content);
2072            let emoji_pairs: Vec<(&str, &str)> = emoji_owned.iter().map(|t| (t.shortcode.as_str(), t.url.as_str())).collect();
2073            let mut extra_tags = Vec::new();
2074            if let Some(secs) = expires_in_secs.filter(|s| *s > 0) {
2075                let at = std::time::SystemTime::now()
2076                    .duration_since(std::time::UNIX_EPOCH)
2077                    .map(|d| d.as_secs())
2078                    .unwrap_or(0)
2079                    .saturating_add(secs);
2080                extra_tags.push(nostr_sdk::prelude::Tag::expiration(nostr_sdk::prelude::Timestamp::from_secs(at)));
2081            }
2082            return crate::community::v2::service::send_chat_message(&transport, &community, &ch, content, reply_ref, &emoji_pairs, extra_tags)
2083                .await
2084                .map_err(VectorError::Other);
2085        }
2086        if expires_in_secs.is_some_and(|s| s > 0) {
2087            return Err(VectorError::Other(
2088                "this Community predates self-destructing messages; the message was not sent".into(),
2089            ));
2090        }
2091        let (community, channel) = self.resolve_channel(channel_id)?;
2092        Self::ensure_v1_writable(&community)?;
2093        let author_pk = state::my_public_key().ok_or_else(|| VectorError::Other("Not logged in".into()))?;
2094        let reply = replied_to.filter(|r| !r.is_empty());
2095        let ms = std::time::SystemTime::now()
2096            .duration_since(std::time::UNIX_EPOCH)
2097            .map(|d| d.as_millis() as u64)
2098            .unwrap_or(0);
2099        let unsigned = envelope::build_inner_typed(
2100            author_pk,
2101            &channel.id,
2102            channel.epoch,
2103            crate::stored_event::event_kind::COMMUNITY_MESSAGE,
2104            content,
2105            ms,
2106            reply,
2107            &[],
2108        );
2109        let message_id = unsigned.id.ok_or_else(|| VectorError::Other("inner event has no id".into()))?.to_hex();
2110        let _client = state::nostr_client().ok_or_else(|| VectorError::Other("Not logged in".into()))?;
2111        let signer = crate::signer::active_signer().map_err(|e| VectorError::Other(format!("Signer unavailable: {e}")))?;
2112        let inner = unsigned.finalize_async(&signer).await.map_err(|e| VectorError::Other(format!("sign: {e}")))?;
2113        let transport = LiveTransport::with_timeout(std::time::Duration::from_secs(12));
2114        let outer = service::send_signed_message(&transport, &community, &channel, &inner)
2115            .await
2116            .map_err(VectorError::Other)?;
2117        // Local echo so get_messages reflects the send (the relay echo dedups on inner id).
2118        let echoed = {
2119            let mut st = state::STATE.lock().await;
2120            inbound::process_incoming(&mut st, &outer, &channel, &author_pk)
2121        };
2122        if let Some(inbound::IncomingEvent::NewMessage(msg)) = echoed {
2123            let _ = crate::db::events::save_message(channel_id, &msg).await;
2124        }
2125        Ok(message_id)
2126    }
2127
2128    /// Send a file to a Community channel as an encrypted attachment. Returns the message id.
2129    /// Mirrors the DM file pipeline (encrypt → Blossom upload → NIP-92 `imeta`) but publishes
2130    /// over the community transport.
2131    pub async fn send_community_file(&self, channel_id: &str, file_path: &str) -> Result<String> {
2132        crate::db::scoped(async move {
2133            use crate::community::{attachments, envelope, inbound, service, transport::LiveTransport};
2134            let path = std::path::Path::new(file_path);
2135            let bytes = std::fs::read(path).map_err(VectorError::Io)?;
2136            if bytes.is_empty() {
2137                return Err(VectorError::Other("Empty file".into()));
2138            }
2139            let filename = path.file_name().and_then(|n| n.to_str()).unwrap_or("file").to_string();
2140            let extension = path.extension().and_then(|e| e.to_str()).unwrap_or("bin").to_lowercase();
2141
2142            // Snapshot the session BEFORE the upload: the destination below is resolved
2143            // from THIS account's DB, and the upload can outlive an account swap.
2144            // Dual-stack: resolve the destination BEFORE the upload so a bad channel
2145            // fails fast (never spend an upload on an unroutable send).
2146            let v2_target = match self.v2_community_for_channel(channel_id)? {
2147                Some(id) => Some(
2148                    crate::db::community::load_community_v2(&id)
2149                        .map_err(VectorError::Other)?
2150                        .ok_or_else(|| VectorError::Other("v2 community not found".into()))?,
2151                ),
2152                None => None,
2153            };
2154            let v1_target = match v2_target {
2155                Some(_) => None,
2156                None => Some(self.resolve_channel(channel_id)?),
2157            };
2158            // Same fail-fast rationale as the routing check above: a sealed community
2159            // (CORD-02 §9) accepts nothing, so refuse before the encrypt + upload rather
2160            // than burning a Blossom round-trip on a send that can never land. v2's own
2161            // gate is inside the send, which is too late to save the upload.
2162            match (&v2_target, &v1_target) {
2163                (Some(c), _) => {
2164                    let cid = crate::simd::hex::bytes_to_hex_32(&c.id().0);
2165                    if crate::db::community::get_community_dissolved(&cid).unwrap_or(false) {
2166                        return Err(VectorError::Other("this community has been dissolved".into()));
2167                    }
2168                }
2169                (None, Some((c, _))) => Self::ensure_v1_writable(c)?,
2170                _ => {}
2171            }
2172            let author_pk = state::my_public_key().ok_or_else(|| VectorError::Other("Not logged in".into()))?;
2173
2174            let file_hash = crate::crypto::sha256_hex(&bytes);
2175            let mime = crate::crypto::mime_from_extension(&extension);
2176            let img_meta = crate::crypto::generate_image_metadata(&bytes);
2177
2178            // Save the plaintext locally (hash-keyed) so the sender previews it instantly.
2179            let download_dir = crate::db::get_download_dir();
2180            let _ = std::fs::create_dir_all(&download_dir);
2181            let local_name = if filename.is_empty() { format!("{}.{}", &file_hash, extension) } else { filename.clone() };
2182            let local_path = crate::crypto::resolve_unique_filename(&download_dir, &local_name);
2183            let _ = std::fs::write(&local_path, &bytes);
2184
2185            // Encrypt → upload to Blossom (signer reused for the envelope below).
2186            let params = crate::crypto::generate_encryption_params();
2187            let encrypted = crate::crypto::encrypt_data(&bytes, &params)?;
2188            let encrypted_size = encrypted.len() as u64;
2189
2190            let _client = state::nostr_client().ok_or_else(|| VectorError::Other("Not logged in".into()))?;
2191            let signer = crate::signer::active_signer().map_err(|e| VectorError::Other(format!("Signer unavailable: {e}")))?;
2192            let servers = crate::blossom_servers::compute_enabled_servers();
2193            if servers.is_empty() {
2194                return Err(VectorError::Other("No Blossom servers configured".into()));
2195            }
2196            let noop_progress: crate::blossom::ProgressCallback = std::sync::Arc::new(|_, _| Ok(()));
2197            let url = crate::blossom::upload_blob_with_progress_and_failover(
2198                signer.clone(),
2199                servers,
2200                std::sync::Arc::new(encrypted),
2201                Some(mime),
2202                /* is_encrypted */ true,
2203                noop_progress,
2204                Some(3),
2205                Some(std::time::Duration::from_secs(2)),
2206                None,
2207            ).await.map_err(VectorError::Other)?;
2208
2209            let attachment = crate::types::Attachment {
2210                id: file_hash.clone(),
2211                key: params.key.clone(),
2212                nonce: params.nonce.clone(),
2213                extension: extension.clone(),
2214                name: filename.clone(),
2215                url,
2216                path: local_path.to_string_lossy().to_string(),
2217                size: encrypted_size,
2218                img_meta,
2219                downloading: false,
2220                downloaded: true,
2221                ..Default::default()
2222            };
2223            let imeta = vec![attachments::attachment_to_imeta(&attachment)];
2224
2225            // v2: the imeta rides the kind-9 rumor verbatim (NIP-92), content empty.
2226            if let Some(community) = v2_target {
2227                let ch = crate::community::ChannelId(crate::simd::hex::hex_to_bytes_32(channel_id));
2228                let transport = LiveTransport::with_timeout(std::time::Duration::from_secs(30));
2229                return crate::community::v2::service::send_chat_message(&transport, &community, &ch, "", None, &[], imeta)
2230                    .await
2231                    .map_err(VectorError::Other);
2232            }
2233            let (community, channel) = v1_target.expect("v1 target resolved when no v2 community matched");
2234            let ms = std::time::SystemTime::now()
2235                .duration_since(std::time::UNIX_EPOCH)
2236                .map(|d| d.as_millis() as u64)
2237                .unwrap_or(0);
2238            let unsigned = envelope::build_inner_full(
2239                author_pk, &channel.id, channel.epoch,
2240                stored_event::event_kind::COMMUNITY_MESSAGE, "", ms, None, &[], &imeta,
2241            );
2242            let message_id = unsigned.id.ok_or_else(|| VectorError::Other("inner event has no id".into()))?.to_hex();
2243            let inner = unsigned.finalize_async(&signer).await.map_err(|e| VectorError::Other(format!("sign: {e}")))?;
2244            let transport = LiveTransport::with_timeout(std::time::Duration::from_secs(30));
2245            let outer = service::send_signed_message(&transport, &community, &channel, &inner)
2246                .await.map_err(VectorError::Other)?;
2247            // Local echo so get_messages reflects the send.
2248            let echoed = {
2249                let mut st = state::STATE.lock().await;
2250                inbound::process_incoming(&mut st, &outer, &channel, &author_pk)
2251            };
2252            if let Some(inbound::IncomingEvent::NewMessage(m)) = echoed {
2253                let _ = crate::db::events::save_message(channel_id, &m).await;
2254            }
2255            Ok(message_id)
2256        })
2257        .await
2258    }
2259
2260    /// Send an ephemeral typing indicator to a Community channel.
2261    pub async fn send_community_typing(&self, channel_id: &str) -> Result<()> {
2262        use crate::community::{service, transport::LiveTransport};
2263        if let Some(id) = self.v2_community_for_channel(channel_id)? {
2264            let community = crate::db::community::load_community_v2(&id)
2265                .map_err(VectorError::Other)?
2266                .ok_or_else(|| VectorError::Other("v2 community not found".into()))?;
2267            let ch = crate::community::ChannelId(crate::simd::hex::hex_to_bytes_32(channel_id));
2268            let transport = LiveTransport::with_timeout(std::time::Duration::from_secs(8));
2269            return crate::community::v2::service::send_typing(&transport, &community, &ch)
2270                .await
2271                .map_err(VectorError::Other);
2272        }
2273        let (community, channel) = self.resolve_channel(channel_id)?;
2274        Self::ensure_v1_writable(&community)?;
2275        let transport = LiveTransport::with_timeout(std::time::Duration::from_secs(8));
2276        service::publish_typing_signal(&transport, &community, &channel)
2277            .await
2278            .map_err(VectorError::Other)
2279    }
2280
2281    /// React to a Community message. `emoji_url` carries the NIP-30 image URL for a custom
2282    /// `:shortcode:` reaction (parity with DMs).
2283    pub async fn send_community_reaction(
2284        &self,
2285        channel_id: &str,
2286        message_id: &str,
2287        emoji: &str,
2288        emoji_url: Option<&str>,
2289    ) -> Result<()> {
2290        crate::db::scoped(async move {
2291            // Already ours — nothing to publish; the set can't hold it twice.
2292            if Self::own_reaction_id(message_id, emoji).await.is_some() {
2293                return Ok(());
2294            }
2295            let emoji_tags: Vec<crate::types::EmojiTag> = match emoji_url {
2296                Some(url) if emoji.starts_with(':') && emoji.ends_with(':') && emoji.len() >= 3 && !url.is_empty() => {
2297                    vec![crate::types::EmojiTag { shortcode: emoji[1..emoji.len() - 1].to_string(), url: url.to_string() }]
2298                }
2299                _ => Vec::new(),
2300            };
2301            if let Some(id) = self.v2_community_for_channel(channel_id)? {
2302                let community = crate::db::community::load_community_v2(&id)
2303                    .map_err(VectorError::Other)?
2304                    .ok_or_else(|| VectorError::Other("v2 community not found".into()))?;
2305                let ch = crate::community::ChannelId(crate::simd::hex::hex_to_bytes_32(channel_id));
2306                let transport = crate::community::transport::LiveTransport::with_timeout(std::time::Duration::from_secs(12));
2307                // NIP-25 names the reacted-to author (a required `p`). STATE first, then
2308                // the persisted row (v2 history + the send echo live in the shared events
2309                // store, so this almost always resolves locally); the channel-page fetch
2310                // is the last resort for a target this device never saw.
2311                let held = {
2312                    let st = state::STATE.lock().await;
2313                    st.find_message(message_id)
2314                        .and_then(|(_, m)| m.npub.as_deref().and_then(|n| nostr_sdk::prelude::PublicKey::parse(n).ok()))
2315                };
2316                let held = held.or_else(|| {
2317                    crate::db::events::event_author(message_id)
2318                        .ok()
2319                        .flatten()
2320                        .and_then(|n| nostr_sdk::prelude::PublicKey::parse(&n).ok())
2321                });
2322                let target_author = match held {
2323                    Some(pk) => pk,
2324                    None => crate::community::v2::service::fetch_channel(&transport, &community, &ch, 500)
2325                        .await
2326                        .map_err(VectorError::Other)?
2327                        .iter()
2328                        .find(|f| f.event.opened().rumor_id.to_hex() == message_id)
2329                        .map(|f| f.event.opened().author)
2330                        .ok_or_else(|| VectorError::Other("reacted-to message not found".into()))?,
2331                };
2332                // The author lookup straddled awaits against THIS account's community.
2333                let pair = emoji_tags.first().map(|t| (t.shortcode.as_str(), t.url.as_str()));
2334                // The NIP-25 `k` names the target's rumor kind. Stored rows don't keep
2335                // wire-kind fidelity yet, so a reaction to a received kind-1111 thread
2336                // reply claims `9` — Armada's fold ignores reaction `k`, and exact
2337                // threading lands with the thread-aware GUI.
2338                return crate::community::v2::service::send_reaction(
2339                    &transport, &community, &ch, message_id, &target_author.to_hex(), crate::community::v2::kind::MESSAGE, emoji, pair,
2340                )
2341                .await
2342                .map(|_| ())
2343                .map_err(VectorError::Other);
2344            }
2345            self.publish_community_control(
2346                channel_id, stored_event::event_kind::COMMUNITY_REACTION, emoji, message_id, &emoji_tags,
2347            ).await
2348        })
2349        .await
2350    }
2351
2352    /// Edit one of your own Community messages.
2353    pub async fn edit_community_message(&self, channel_id: &str, message_id: &str, new_content: &str) -> Result<()> {
2354        let emoji_tags = emoji_packs::resolve_outbound_emoji_tags(new_content);
2355        if let Some(id) = self.v2_community_for_channel(channel_id)? {
2356            let community = crate::db::community::load_community_v2(&id)
2357                .map_err(VectorError::Other)?
2358                .ok_or_else(|| VectorError::Other("v2 community not found".into()))?;
2359            let ch = crate::community::ChannelId(crate::simd::hex::hex_to_bytes_32(channel_id));
2360            let transport = crate::community::transport::LiveTransport::with_timeout(std::time::Duration::from_secs(12));
2361            return crate::community::v2::service::send_edit(&transport, &community, &ch, message_id, new_content)
2362                .await
2363                .map(|_| ())
2364                .map_err(VectorError::Other);
2365        }
2366        self.publish_community_control(
2367            channel_id, stored_event::event_kind::COMMUNITY_EDIT, new_content, message_id, &emoji_tags,
2368        ).await
2369    }
2370
2371    /// Delete one of your own Community messages, resolving its channel from local
2372    /// state (the GUI path). A headless v2 consumer holds no local history — use
2373    /// [`Self::delete_community_message_in`] with the channel id instead.
2374    pub async fn delete_community_message(&self, message_id: &str) -> Result<()> {
2375        let channel_id = {
2376            let st = state::STATE.lock().await;
2377            match st.find_message(message_id) {
2378                Some((chat, _)) => chat.id.clone(),
2379                None => return Err(VectorError::Other("message not found (already deleted?)".into())),
2380            }
2381        };
2382        self.delete_community_message_in(&channel_id, message_id).await
2383    }
2384
2385    /// Delete one of your own Community messages in `channel_id`: a NIP-09 relay nuke when the
2386    /// per-message key is held (v1) or the in-plane kind-5 (v2), plus a cooperative tombstone so
2387    /// peers hide it, plus best-effort attachment cleanup.
2388    pub async fn delete_community_message_in(&self, channel_id: &str, message_id: &str) -> Result<()> {
2389        use crate::community::{service, transport::LiveTransport};
2390        let transport = LiveTransport::with_timeout(std::time::Duration::from_secs(12));
2391
2392        // Attachment URLs come from local state when held (a headless v2 consumer
2393        // has none — blob cleanup is then the receiving peers' concern, not ours).
2394        let attachment_urls: Vec<String> = {
2395            let st = state::STATE.lock().await;
2396            st.find_message(message_id)
2397                .map(|(_, msg)| msg.attachments.iter().flat_map(|a| a.all_urls().map(str::to_string)).collect())
2398                .unwrap_or_default()
2399        };
2400
2401        if let Some(id) = self.v2_community_for_channel(channel_id)? {
2402            // v2: the cooperative in-plane kind-5 (the wrap-ciphertext scrub needs
2403            // the ephemeral wrap key, not retained in this cut).
2404            let community = crate::db::community::load_community_v2(&id)
2405                .map_err(VectorError::Other)?
2406                .ok_or_else(|| VectorError::Other("v2 community not found".into()))?;
2407            let ch = crate::community::ChannelId(crate::simd::hex::hex_to_bytes_32(&channel_id));
2408            crate::community::v2::service::send_delete(
2409                &transport, &community, &ch, message_id, crate::community::v2::kind::MESSAGE,
2410            )
2411            .await
2412            .map_err(VectorError::Other)?;
2413        } else {
2414            // Layer 1 — relay nuke against the retained per-message key (best-effort).
2415            if crate::db::community::get_message_key(message_id).map(|k| k.is_some()).unwrap_or(false) {
2416                let _ = service::delete_message(&transport, message_id).await;
2417            }
2418            // Layer 2 — cooperative tombstone so peers hide it.
2419            self.publish_community_control(
2420                &channel_id, stored_event::event_kind::COMMUNITY_DELETE, "", message_id, &[],
2421            ).await?;
2422        }
2423        // Layer 3 — best-effort attachment blob delete.
2424        if !attachment_urls.is_empty() {
2425            if let Some(_client) = state::nostr_client() {
2426                if let Ok(signer) = crate::signer::active_signer() {
2427                    crate::blossom::delete_blobs_best_effort(signer, attachment_urls);
2428                }
2429            }
2430        }
2431        let removed_chat = {
2432            let mut st = state::STATE.lock().await;
2433            st.remove_message(message_id).map(|(cid, _)| cid)
2434        };
2435        let _ = crate::db::events::delete_event(message_id).await;
2436        traits::emit_event_json("message_removed", serde_json::json!({
2437            "id": message_id, "chat_id": removed_chat.as_deref().unwrap_or(&channel_id), "reason": "deleted",
2438        }));
2439        Ok(())
2440    }
2441
2442    /// Moderation-hide someone ELSE's community message under `MANAGE_MESSAGES`
2443    /// (CORD-04 §3/§5). Protocol-agnostic: v2 seals the same kind-5 its authors
2444    /// use, v1 publishes its 3305 tombstone; both re-derive the actor's authority
2445    /// from the signed inner against the folded Roster, so this is an authority
2446    /// claim peers verify, never a local suppression.
2447    pub async fn hide_community_message(&self, channel_id: &str, message_id: &str) -> Result<()> {
2448        use crate::community::transport::LiveTransport;
2449        let transport = LiveTransport::with_timeout(std::time::Duration::from_secs(12));
2450
2451        // You can only moderate a message you can see: the author resolves from
2452        // STATE, then the store for a row that has paged out of the window.
2453        let author_npub = {
2454            let st = state::STATE.lock().await;
2455            st.find_message(message_id).and_then(|(_, m)| m.npub)
2456        };
2457        let author_npub = match author_npub {
2458            Some(n) => n,
2459            None => crate::db::events::event_author(message_id)
2460                .ok()
2461                .flatten()
2462                .ok_or_else(|| VectorError::Other("can't resolve the target message's author".into()))?,
2463        };
2464        let author = nostr_sdk::prelude::PublicKey::parse(&author_npub)
2465            .map_err(|_| VectorError::Other("target message has an unreadable author".into()))?;
2466
2467        if let Some(id) = self.v2_community_for_channel(channel_id)? {
2468            let community = crate::db::community::load_community_v2(&id)
2469                .map_err(VectorError::Other)?
2470                .ok_or_else(|| VectorError::Other("v2 community not found".into()))?;
2471            let ch = crate::community::ChannelId(crate::simd::hex::hex_to_bytes_32(channel_id));
2472            crate::community::v2::service::moderation_delete(
2473                &transport, &community, &ch, message_id, crate::community::v2::kind::MESSAGE, &author,
2474            )
2475            .await
2476            .map_err(VectorError::Other)?;
2477        } else {
2478            let cid = crate::db::community::community_id_for_channel(channel_id)
2479                .map_err(VectorError::Other)?
2480                .ok_or_else(|| VectorError::Other("unknown community channel".into()))?;
2481            let community = crate::db::community::load_community(&crate::community::CommunityId(
2482                crate::simd::hex::hex_to_bytes_32(&cid),
2483            ))
2484            .map_err(VectorError::Other)?
2485            .ok_or_else(|| VectorError::Other("community not found".into()))?;
2486            let channel = community
2487                .channels
2488                .iter()
2489                .find(|c| c.id.to_hex() == channel_id)
2490                .cloned()
2491                .ok_or_else(|| VectorError::Other("channel not found in community".into()))?;
2492            crate::community::service::publish_owner_hide(&transport, &community, &channel, message_id)
2493                .await
2494                .map_err(VectorError::Other)?;
2495        }
2496
2497        let removed_chat = {
2498            let mut st = state::STATE.lock().await;
2499            st.remove_message(message_id).map(|(cid, _)| cid)
2500        };
2501        let _ = crate::db::events::delete_event(message_id).await;
2502        traits::emit_event_json("message_removed", serde_json::json!({
2503            "id": message_id, "chat_id": removed_chat.as_deref().unwrap_or(channel_id), "reason": "hidden",
2504        }));
2505        Ok(())
2506    }
2507
2508    /// Shared community control-event publish (reaction / edit / delete tombstone): build the
2509    /// inner-typed envelope, sign, send over the community transport, then locally echo + persist + emit.
2510    async fn publish_community_control(
2511        &self,
2512        channel_id: &str,
2513        kind: u16,
2514        content: &str,
2515        target: &str,
2516        emoji_tags: &[crate::types::EmojiTag],
2517    ) -> Result<()> {
2518        use crate::community::{envelope, inbound, service, transport::LiveTransport};
2519        let (community, channel) = self.resolve_channel(channel_id)?;
2520        Self::ensure_v1_writable(&community)?;
2521        let author_pk = state::my_public_key().ok_or_else(|| VectorError::Other("Not logged in".into()))?;
2522        let ms = std::time::SystemTime::now()
2523            .duration_since(std::time::UNIX_EPOCH)
2524            .map(|d| d.as_millis() as u64)
2525            .unwrap_or(0);
2526        let unsigned = envelope::build_inner_typed(
2527            author_pk, &channel.id, channel.epoch, kind, content, ms, Some(target), emoji_tags,
2528        );
2529        let _client = state::nostr_client().ok_or_else(|| VectorError::Other("Not logged in".into()))?;
2530        let signer = crate::signer::active_signer().map_err(|e| VectorError::Other(format!("Signer unavailable: {e}")))?;
2531        let inner = unsigned.finalize_async(&signer).await.map_err(|e| VectorError::Other(format!("sign: {e}")))?;
2532        let transport = LiveTransport::with_timeout(std::time::Duration::from_secs(12));
2533        let outer = service::send_signed_message(&transport, &community, &channel, &inner)
2534            .await.map_err(VectorError::Other)?;
2535        // Local echo + persist + emit (relay echo dedups on inner id). A swap during the
2536        // publish must not echo account A's control event into account B.
2537        let outcome = {
2538            let mut st = state::STATE.lock().await;
2539            inbound::process_incoming(&mut st, &outer, &channel, &author_pk)
2540        };
2541        if let Some(inbound::IncomingEvent::Updated { target_id, mut message, edit_event }) = outcome {
2542            if let Some(ev) = edit_event {
2543                let mut ev = (*ev).clone();
2544                if let Ok(cid) = crate::db::id_cache::get_chat_id_by_identifier(channel_id) { ev.chat_id = cid; }
2545                let _ = crate::db::events::save_event(&ev).await;
2546            } else {
2547                let _ = crate::db::events::save_message(channel_id, &message).await;
2548            }
2549            traits::emit_message_update(channel_id, &target_id, &mut message).await;
2550        }
2551        Ok(())
2552    }
2553
2554    /// Catch a Community channel up from relays. v1: fetch + ingest the latest page of messages,
2555    /// reactions, edits, and deletes, returning how many were brand-new. v2: consensus catch-up
2556    /// only (rekeys + control refold) — chat history delivers over the live handler bridge, so the
2557    /// count is always 0. Returns `(new_message_count, warnings)`; `warnings` are NON-FATAL errors
2558    /// hit during the sync (catch-up, control fold, read-cut resume) — surfaced rather than
2559    /// swallowed so a headless caller is never blind to "the sync ran but a re-founding couldn't
2560    /// be resumed."
2561    pub async fn sync_community_channel(&self, channel_id: &str, limit: usize) -> Result<(usize, Vec<String>)> {
2562        self.sync_community_channel_before(channel_id, limit, None).await
2563    }
2564
2565    /// One page of a v2 channel catch-up: what the relay served, and how much of it
2566    /// this client had never seen.
2567    pub async fn sync_community_channel_page(
2568        &self,
2569        channel_id: &str,
2570        limit: usize,
2571        before_secs: Option<u64>,
2572        since_secs: Option<u64>,
2573    ) -> Result<(BackfillCount, Vec<String>)> {
2574        if let Some(id) = self.v2_community_for_channel(channel_id)? {
2575            let warnings = if community::v2::realtime::follow_worker_running() {
2576                community::v2::realtime::enqueue_follow(&id);
2577                Vec::new()
2578            } else {
2579                Self::v2_inline_follow(&id).await
2580            };
2581            let count = Self::v2_backfill_channel_counted(
2582                &id, channel_id, limit, 8, since_secs, before_secs,
2583                crate::community::transport::Evidence::Fast, 12,
2584            ).await;
2585            return Ok((count, warnings));
2586        }
2587        // v1 has its own paging path; report the new-count and leave `fetched`
2588        // meaningless rather than inventing a number a caller might trust.
2589        let (new_messages, warnings) = self.sync_community_channel(channel_id, limit).await?;
2590        Ok((BackfillCount { fetched: new_messages, new_messages }, warnings))
2591    }
2592
2593    /// [`sync_community_channel`](Self::sync_community_channel) with a back-paging
2594    /// cursor: `before_secs` bounds the fetch to messages at or older than it.
2595    ///
2596    /// The cursor is what reaches a v2 channel's PRE-ROTATION history. An
2597    /// uncursored catch-up reads the live epoch only (rotations move the
2598    /// conversation, so that is where it is); each epoch keeps its own plane
2599    /// address, and a bounded fetch spans all of them at once because messages are
2600    /// selected by time, not by epoch.
2601    pub async fn sync_community_channel_before(
2602        &self,
2603        channel_id: &str,
2604        limit: usize,
2605        before_secs: Option<u64>,
2606    ) -> Result<(usize, Vec<String>)> {
2607        use crate::community::{send, service, transport::LiveTransport};
2608        let my_pk = state::my_public_key().ok_or_else(|| VectorError::Other("Not logged in".into()))?;
2609        // v2: consensus catch-up (rekeys then control refold) + chat backfill. With a
2610        // running listen() the coalescing worker owns the follow (never run inline beside
2611        // it — two concurrent follows can whole-row clobber); headless, walk it inline.
2612        // The chat page is fetched + persisted either way, so get_messages backfills.
2613        if let Some(id) = self.v2_community_for_channel(channel_id)? {
2614            let warnings = if community::v2::realtime::follow_worker_running() {
2615                community::v2::realtime::enqueue_follow(&id);
2616                Vec::new()
2617            } else {
2618                Self::v2_inline_follow(&id).await
2619            };
2620            // Deepest catch-up walk: pages × page-size bounds one reconnect's fetch.
2621            // Chat plane = fetch ASAP. NOTE: fetch_plane does not consult the
2622            // evidence tier yet (#370) — until it does, the transport-seconds
2623            // bound is the effective limit; the declared Fast records intent.
2624            let new = Self::v2_backfill_channel(
2625                &id, channel_id, limit, 8, None, before_secs,
2626                crate::community::transport::Evidence::Fast, 12,
2627            ).await;
2628            return Ok((new, warnings));
2629        }
2630        let (community, _) = self.resolve_channel(channel_id)?;
2631        let transport = LiveTransport::with_timeout(std::time::Duration::from_secs(12));
2632        let mut warnings: Vec<String> = Vec::new();
2633
2634        // FIRST: walk any base (server-root) rotation — a privatize / private-ban rekey advances the
2635        // epoch and re-anchors the control plane under the NEW root, so we must follow it BEFORE reading
2636        // control/messages or we'd look at stale-epoch pseudonyms and silently fall off. No-op (one cheap
2637        // probe) when there's been no rotation. Re-resolve after: the base epoch + root may have advanced.
2638        // An AUTHORIZED base rotation that excluded us (private ban / read-cut) is a removal: erase local
2639        // community data, exactly like an observed banlist/kick. This is the catch-all for a cut member who
2640        // can no longer decrypt the new control plane to read the banlist the normal way (`am_i_banned`).
2641        match service::catch_up_server_root(&transport, &community).await {
2642            Ok(c) if c.removed => {
2643                // ban-rekey exclusion is a self-removal → retain the held epoch keys for later self-scrub.
2644                let _ = crate::db::community::delete_community_retain_keys(&community.id.to_hex());
2645                return Ok((0, warnings));
2646            }
2647            Ok(_) => {}
2648            Err(e) => warnings.push(format!("base catch-up failed: {e}")),
2649        }
2650        let (community, _) = self.resolve_channel(channel_id)?;
2651
2652        // Headless clients have no realtime control-plane subscription, so fold the latest control editions
2653        // here (the desktop does the same on its own latest-page sync). Banlist FIRST: a ban that landed on
2654        // us self-removes like a kick (drop keys + local data, no rejoin). Then roles, the per-creator invite
2655        // links (Public/Private mode), and metadata (name/description/icon/channel-name) — so a rename, role,
2656        // ban, or mode change reaches this member on sync, not just in a realtime client.
2657        if let Err(e) = service::fetch_and_apply_control(&transport, &community).await {
2658            warnings.push(format!("control fold failed: {e}"));
2659        }
2660        if service::am_i_banned(&community) {
2661            // ban self-removal → retain the held epoch keys for later self-scrub.
2662            let _ = crate::db::community::delete_community_retain_keys(&community.id.to_hex());
2663            return Ok((0, warnings));
2664        }
2665        // Walk any CHANNEL rekey so we hold the current channel key before paging it, then re-resolve so the
2666        // batch below carries the fresh channel epoch/key + the freshly-folded banned set + metadata.
2667        let (community, channel) = self.resolve_channel(channel_id)?;
2668        if let Err(e) = service::catch_up_channel_rekeys(&transport, &community, &channel.id).await {
2669            warnings.push(format!("channel catch-up failed: {e}"));
2670        }
2671        // Resume any interrupted re-founding (a privatize/ban whose rotation aborted mid-way — e.g. a
2672        // transient relay miss on the re-anchor). The GUI's sync did this; the agent's path did NOT, so an
2673        // interrupted re-founding stayed `read_cut_pending` forever (channel frozen). Best-effort + surfaced.
2674        let (community, _) = self.resolve_channel(channel_id)?;
2675        if let Err(e) = service::retry_pending_read_cut(&transport, &community).await {
2676            warnings.push(format!("read-cut resume failed: {e}"));
2677        }
2678        let (community, channel) = self.resolve_channel(channel_id)?;
2679
2680        // Guard straddles the fetch: the persist walk below writes this account's DB.
2681        let session = crate::db::current_session();
2682        let events = send::fetch_channel_page(&transport, &community, &channel, None, None, limit.max(1))
2683            .await
2684            .map_err(VectorError::Other)?;
2685        let new = Self::v1_ingest_channel_page(channel_id, &events, &channel, my_pk, &session).await;
2686        Ok((new, warnings))
2687    }
2688
2689    /// Ingest one fetched v1 channel page: STATE apply, batched persist with
2690    /// delete barriers, presence + WebXDC rows, self-removal teardown. Shared by
2691    /// the full sync above and the cursor-paged sync — the two must never drift,
2692    /// or paged history would skip the moderation/teardown arms.
2693    async fn v1_ingest_channel_page(
2694        channel_id: &str,
2695        events: &[nostr_sdk::prelude::Event],
2696        channel: &crate::community::Channel,
2697        my_pk: nostr_sdk::prelude::PublicKey,
2698        session: &std::sync::Arc<crate::db::Session>,
2699    ) -> usize {
2700        crate::db::scoped(async move {
2701            use crate::community::inbound;
2702            let outcomes = {
2703                let mut st = state::STATE.lock().await;
2704                inbound::process_channel_batch(&mut st, &events, &channel, &my_pk)
2705            };
2706            let mut new = 0usize;
2707            // Message saves COLLECT into one batched transaction; deletes are flush barriers
2708            // (see flush_message_batch — a save committing after a delete it preceded on the
2709            // wire would resurrect the deleted row).
2710            let mut pending: Vec<&crate::types::Message> = Vec::new();
2711            for o in &outcomes {
2712                // Every arm below writes this account's DB — a swap can land between them.
2713                if !session.is_live() {
2714                    pending.clear();
2715                    break;
2716                }
2717                match o {
2718                    inbound::IncomingEvent::NewMessage(m) => {
2719                        pending.push(m);
2720                        new += 1;
2721                    }
2722                    inbound::IncomingEvent::Updated { message, .. } => {
2723                        pending.push(message);
2724                    }
2725                    inbound::IncomingEvent::Removed { target_id } => {
2726                        crate::db::events::flush_message_batch(channel_id, &mut pending, &session).await;
2727                        // Tombstone FIRST, exactly as the DM delete does. Dropping the row
2728                        // alone only hides it until the next full sync re-serves the
2729                        // original from a relay: nothing recorded that it was removed, so
2730                        // it re-ingests cleanly and a moderator's deletion silently undoes
2731                        // itself. The deletion may also arrive BEFORE its target, where
2732                        // there is no row to drop and this is the only thing that lands.
2733                        crate::state::note_message_deleted(target_id);
2734                        if let Err(e) = crate::db::events::add_message_tombstone(target_id) {
2735                            crate::log_warn!("[Community delete] tombstone write failed: {e}");
2736                        }
2737                        let _ = crate::db::events::delete_event(target_id).await;
2738                    }
2739                    inbound::IncomingEvent::ReactionRemoved { reaction_id, .. } => {
2740                        // save_message is additive, so a revoked reaction's kind-7 row must be
2741                        // dropped explicitly or it resurrects on reload.
2742                        crate::db::events::flush_message_batch(channel_id, &mut pending, &session).await;
2743                        let _ = crate::db::events::delete_event(reaction_id).await;
2744                    }
2745                    inbound::IncomingEvent::Presence { npub, joined, event_id, created_at, invited_by, invited_label } => {
2746                        let et = if *joined {
2747                            crate::stored_event::SystemEventType::MemberJoined
2748                        } else {
2749                            crate::stored_event::SystemEventType::MemberLeft
2750                        };
2751                        // attribution persisted in the note: "invited_by[|label]".
2752                        let note = invited_by.as_ref().map(|by| match invited_label {
2753                            Some(l) if !l.is_empty() => format!("{by}|{l}"),
2754                            _ => by.clone(),
2755                        });
2756                        let _ = crate::db::events::save_system_event_at(event_id, channel_id, et, npub, note.as_deref(), *created_at, invited_by.as_deref(), invited_label.as_deref()).await;
2757                    }
2758                    inbound::IncomingEvent::WebxdcPeer { npub, topic_id, node_addr, event_id, created_at } => {
2759                        // Persist only (DM-parity row) — the miniapp layer bootstraps from the DB at
2760                        // game-open. Live gossip-feed pokes are the realtime subscription's job.
2761                        community::service::persist_webxdc_signal(
2762                            channel_id, npub, topic_id, node_addr.as_deref(), event_id, *created_at,
2763                        ).await;
2764                    }
2765                    inbound::IncomingEvent::Kicked { community_id }
2766                    | inbound::IncomingEvent::SelfLeft { community_id } => {
2767                        // self-removal (kick of me, or a leave I/another device authored): drop the
2768                        // community's local state but RETAIN the held epoch keys (later self-scrub). The core-level
2769                        // half of leaving; a client shell layers on subscription-refresh + chat-row teardown + UI.
2770                        // Stop the batch — the community is gone, so later same-batch writes would orphan rows.
2771                        crate::db::events::flush_message_batch(channel_id, &mut pending, &session).await;
2772                        let _ = crate::db::community::delete_community_retain_keys(community_id);
2773                        break;
2774                    }
2775                    inbound::IncomingEvent::Typing { .. } => {
2776                        // Realtime-only ephemeral signal; never fetched in a sync batch. No-op.
2777                    }
2778                }
2779            }
2780            crate::db::events::flush_message_batch(channel_id, &mut pending, &session).await;
2781            new
2782        })
2783        .await
2784    }
2785
2786    /// Cursor-paged relay sync: fetch ONE page of up to `max_events` events from
2787    /// the channel's relays (per relay — divergent relays can push the union
2788    /// higher), ingest it, and return how many MESSAGES were new. A return of 0
2789    /// means the page held nothing new, not that the channel is empty.
2790    ///
2791    /// `until_s` / `since_s` are unix-seconds bounds (the back-paging cursor and
2792    /// the time floor). Depth comes from looping, not from a bigger page:
2793    /// `max_events` is clamped to 500 (relays clamp theirs anyway) and values
2794    /// under 50 behave as 50 on v2 (the shared pager's floor).
2795    ///
2796    /// Consensus (root rotations, control folds, ban checks) is the FULL sync's
2797    /// job — [`sync_community_channel`](Self::sync_community_channel) — so a
2798    /// hundred-page walk doesn't re-fold control a hundred times. Run one full
2799    /// sync (or be listening) before deep walks.
2800    pub async fn sync_channel_events(
2801        &self,
2802        channel_id: &str,
2803        max_events: usize,
2804        until_s: Option<u64>,
2805        since_s: Option<u64>,
2806    ) -> Result<usize> {
2807        use crate::community::{send, transport::LiveTransport};
2808        let max = max_events.clamp(1, 500);
2809        if let Some(id) = self.v2_community_for_channel(channel_id)? {
2810            let community = crate::db::community::load_community_v2(&id)
2811                .map_err(VectorError::Other)?
2812                .ok_or_else(|| VectorError::Other("v2 community not found".into()))?;
2813            if community.dissolved {
2814                return Err(VectorError::Other("this community has been dissolved".into()));
2815            }
2816            let ch_id = crate::community::ChannelId(crate::simd::hex::hex_to_bytes_32(channel_id));
2817            let ch = community
2818                .channel(&ch_id)
2819                .ok_or_else(|| VectorError::Other("no such channel in this community".into()))?;
2820            // Err, not a silent 0 — "granted but the key hasn't arrived" must be
2821            // distinguishable from "nothing new" or the caller diagnoses a mute bot.
2822            if ch.private && ch.key.is_none() {
2823                return Err(VectorError::Other(
2824                    "this private channel has no key yet (awaiting rekey delivery)".into(),
2825                ));
2826            }
2827            let new = Self::v2_backfill_channel(
2828                &id, channel_id, max, 1, since_s, until_s,
2829                crate::community::transport::Evidence::Fast, 12,
2830            )
2831            .await;
2832            return Ok(new);
2833        }
2834        let (community, channel) = self.resolve_channel(channel_id)?;
2835        Self::ensure_v1_writable(&community)?;
2836        let my_pk = state::my_public_key().ok_or_else(|| VectorError::Other("Not logged in".into()))?;
2837        let transport = LiveTransport::with_timeout(std::time::Duration::from_secs(12));
2838        let session = crate::db::current_session();
2839        let events = send::fetch_channel_page(&transport, &community, &channel, until_s, since_s, max)
2840            .await
2841            .map_err(VectorError::Other)?;
2842        Ok(Self::v1_ingest_channel_page(channel_id, &events, &channel, my_pk, &session).await)
2843    }
2844
2845    /// The composer's `/` picker snapshot for `chat_id`, answered INSTANTLY
2846    /// from local state: the chat's bot-flagged members (kind-0 `bot: true` —
2847    /// the SDK sets it on every bot it builds) and their last-known manifests
2848    /// from the persistent store. When the last refresh is older than a minute
2849    /// (or the bot set changed), ONE background REQ re-fetches every bot's
2850    /// manifest together (5s unification window), persists newer editions, and
2851    /// emits `chat_commands_updated` — the UI swaps the list in when it lands.
2852    /// Works for BOTH community protocols (an invocation is plain content; only
2853    /// the optional routing tag is v2-only) and DMs. The manifest REQ always
2854    /// includes the discovery indexers beside the chat's own relays, so an
2855    /// unreachable or stranger-dropping community relay can't blind the picker.
2856    pub async fn get_chat_commands(&self, chat_id: &str) -> crate::bot_interface::ChatCommandsSnapshot {
2857        use crate::bot_interface::{self, ChatCommandsSnapshot};
2858        use nostr_sdk::prelude::ToBech32;
2859
2860        let mut bots: Vec<nostr_sdk::prelude::PublicKey> = Vec::new();
2861        let mut relays: Vec<String> = Vec::new();
2862        let community_hex = crate::db::community::community_id_for_channel(chat_id).ok().flatten();
2863        if let Some(cid_hex) = community_hex {
2864            let mut members: Vec<nostr_sdk::prelude::PublicKey> = Vec::new();
2865            if let Ok(Some(community)) = Self::load_v2_if_v2(&cid_hex) {
2866                members = community::v2::service::stored_memberlist(&community).unwrap_or_default();
2867                relays = community.relays.clone();
2868            } else {
2869                let id = crate::community::CommunityId(crate::simd::hex::hex_to_bytes_32(&cid_hex));
2870                let Ok(Some(community)) = crate::db::community::load_community(&id) else {
2871                    return ChatCommandsSnapshot { bots: 0, commands: Vec::new(), fresh: true };
2872                };
2873                relays = community.relays.clone();
2874                for (npub, _) in crate::db::community::community_member_activity(&cid_hex).unwrap_or_default() {
2875                    if let Ok(pk) = nostr_sdk::prelude::PublicKey::parse(&npub) {
2876                        members.push(pk);
2877                    }
2878                }
2879            }
2880            let state = crate::state::STATE.lock().await;
2881            for pk in members {
2882                let Ok(npub) = pk.to_bech32();
2883                if state.get_profile(&npub).map(|p| p.flags.is_bot()).unwrap_or(false) {
2884                    bots.push(pk);
2885                }
2886            }
2887        } else if chat_id.starts_with("npub1") {
2888            if let Ok(pk) = nostr_sdk::prelude::PublicKey::parse(chat_id) {
2889                let is_bot = {
2890                    let state = crate::state::STATE.lock().await;
2891                    state.get_profile(chat_id).map(|p| p.flags.is_bot()).unwrap_or(false)
2892                };
2893                if is_bot {
2894                    bots.push(pk);
2895                    // The counterpart published its manifest to its own login
2896                    // relays/indexers — our connected pool is the read set.
2897                    if let Some(client) = crate::state::nostr_client() {
2898                        relays = client.relays().await.keys().map(|u| u.to_string()).collect();
2899                    }
2900                }
2901            }
2902        }
2903
2904        if bots.is_empty() {
2905            return ChatCommandsSnapshot { bots: 0, commands: Vec::new(), fresh: true };
2906        }
2907        // The chat's own relays PLUS the discovery indexers, one REQ across the
2908        // union — a room whose relays refuse kind 10304 still resolves.
2909        relays.extend(bot_interface::DISCOVERY_RELAYS.iter().map(|s| s.to_string()));
2910        relays.sort();
2911        relays.dedup();
2912        // Deterministic order: the freshness check compares the exact bot set,
2913        // and picker sections stay stable across refreshes.
2914        bots.sort_by_key(|p| p.to_hex());
2915        let bot_hexes: Vec<String> = bots.iter().map(|p| p.to_hex()).collect();
2916        let commands = bot_interface::assemble_from_store(&bot_hexes);
2917        let fresh = bot_interface::commands_fresh(chat_id, &bot_hexes);
2918        if !fresh {
2919            bot_interface::spawn_commands_refresh(chat_id.to_string(), bots.clone(), relays);
2920        }
2921        ChatCommandsSnapshot { bots: bots.len(), commands, fresh }
2922    }
2923
2924    /// Observed members of a Community (best-effort: those who've posted or announced a join,
2925    /// minus anyone who's left or is banned). v1 entries are `{npub, last_active}`; a v2 entry
2926    /// is `{npub}` (the Complete Memberlist carries no activity time). Best-effort throughout:
2927    /// a transport failure yields an empty list, never an error.
2928    pub async fn get_community_members(&self, community_id: &str) -> Vec<serde_json::Value> {
2929        use nostr_sdk::prelude::ToBech32;
2930        // v2: the Complete Memberlist from LOCAL state (persisted guestbook +
2931        // observed authors + roster grantees − banlist). The store is seeded
2932        // post-join and cursor-caught-up by the follow worker (boot/reconnect) +
2933        // live ingest; a cold store (a hold predating the store) seeds in the
2934        // background and refreshes the UI when it lands.
2935        match Self::load_v2_if_v2(community_id) {
2936            Ok(Some(community)) => {
2937                let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
2938                let (_, cursor) = crate::db::community::get_guestbook(&cid_hex).unwrap_or_default();
2939                if cursor == 0 {
2940                    if crate::community::v2::realtime::follow_worker_running() {
2941                        crate::community::v2::realtime::enqueue_follow(community.id());
2942                    } else {
2943                        let c2 = community.clone();
2944                        db::spawn_bound(async move {
2945                            let transport = crate::community::transport::LiveTransport::with_timeout(std::time::Duration::from_secs(20));
2946                            if matches!(crate::community::v2::service::sync_guestbook(&transport, &c2).await, Ok(fresh) if !fresh.is_empty()) {
2947                                emit_event("community_refreshed", &serde_json::json!({ "community_id": cid_hex }));
2948                            }
2949                        });
2950                    }
2951                }
2952                return crate::community::v2::service::stored_memberlist(&community)
2953                    .unwrap_or_default()
2954                    .into_iter()
2955                    .filter_map(|pk| pk.to_bech32().ok())
2956                    .map(|npub| serde_json::json!({ "npub": npub }))
2957                    .collect();
2958            }
2959            Ok(None) => {} // genuinely v1 / unknown — fall through.
2960            // Can't determine the protocol: best-effort empty, never a v1 guess.
2961            Err(_) => return Vec::new(),
2962        }
2963        crate::db::community::community_member_activity(community_id)
2964            .unwrap_or_default()
2965            .into_iter()
2966            .map(|(npub, last_active)| serde_json::json!({ "npub": npub, "last_active": last_active }))
2967            .collect()
2968    }
2969
2970    /// A Community's banlist, as npubs.
2971    ///
2972    /// The counterpart to [`Self::set_member_banned`], which had none: setting a
2973    /// ban succeeds identically whether or not one was already in place, so a
2974    /// caller lifting a ban could not tell whether it changed anything, and
2975    /// anything reporting on it was guessing.
2976    ///
2977    /// Read from local state, so it reflects the last banlist edition this
2978    /// client folded rather than a fresh fetch.
2979    pub fn get_community_banned(&self, community_id: &str) -> Vec<String> {
2980        use nostr_sdk::prelude::{PublicKey, ToBech32};
2981        // Resolve the id the same way the moderation panel does: a caller may
2982        // hold a logical id that is not the row key.
2983        let cid_hex = match Self::load_v2_if_v2(community_id) {
2984            Ok(Some(c)) => crate::simd::hex::bytes_to_hex_32(&c.id().0),
2985            _ => community_id.to_string(),
2986        };
2987        crate::db::community::get_community_banlist(&cid_hex)
2988            .unwrap_or_default()
2989            .iter()
2990            .filter_map(|hex| PublicKey::parse(hex).ok())
2991            .filter_map(|pk| pk.to_bech32().ok())
2992            .collect()
2993    }
2994
2995    /// Everything the moderation panel needs about one Community, read once so the
2996    /// parts agree with each other: every member scored against the raid evidence
2997    /// ([`crate::community::raid`]), the duplicate-text cohorts behind those verdicts,
2998    /// the live invite links, and what the caller may actually do about it.
2999    ///
3000    /// v2 only — the containment this feeds (batch ban, refounding) are v2 verbs.
3001    pub fn community_moderation_intel(&self, community_id: &str) -> Result<serde_json::Value> {
3002        let community = Self::load_v2_if_v2(community_id)?
3003            .ok_or_else(|| VectorError::Other("moderation tools require a Concord v2 community".into()))?;
3004        let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
3005        let report = Self::policy_console_report(&cid_hex, &community)?;
3006        let invites = crate::db::community::list_public_invites(&cid_hex).unwrap_or_default();
3007        let banlist = crate::db::community::get_community_banlist(&cid_hex).unwrap_or_default();
3008        let caps = self.community_capabilities(community_id).unwrap_or_else(|_| serde_json::json!({}));
3009        let owner_b32 = community.owner().map_err(VectorError::Other)?.to_bech32().unwrap_or_default();
3010
3011        Ok(serde_json::json!({
3012            "community_id": cid_hex,
3013            "name": community.name,
3014            "owner_npub": owner_b32,
3015            "epoch": community.root_epoch.0,
3016            "report": &*report,
3017            "invites": invites.iter().map(|i| serde_json::json!({
3018                "token": i.token,
3019                "label": i.label,
3020                "created_at": i.created_at,
3021                "expires_at": i.expires_at,
3022                "join_count": i.join_count,
3023            })).collect::<Vec<_>>(),
3024            "banlist_count": banlist.len(),
3025            "banlist_max": crate::community::v2::roles::MAX_BANLIST,
3026            "capabilities": caps,
3027            // The designer offers per-channel exemptions, and this is the
3028            // console's own data source — one place to ask, one answer.
3029            "channels": community.channels.iter().map(|c| serde_json::json!({
3030                "id": crate::simd::hex::bytes_to_hex_32(&c.id.0),
3031                "name": c.name,
3032            })).collect::<Vec<_>>(),
3033        }))
3034    }
3035
3036    /// Screen ONE message immediately, without waiting for a sweep.
3037    ///
3038    /// Runs only the stateless rules — words, links, regex, mentions — because
3039    /// those are the only ones a single message can answer. Rate, repetition
3040    /// and cohorts describe a window and stay with the periodic evaluation.
3041    ///
3042    /// Costs no corpus read and no cache: the whole point is that a word filter
3043    /// should answer in milliseconds, not on the next 90-second tick.
3044    pub fn screen_community_message(
3045        &self,
3046        channel_id: &str,
3047        author_npub: &str,
3048        text: &str,
3049    ) -> Result<serde_json::Value> {
3050        use nostr_sdk::prelude::PublicKey;
3051        let Some(id) = self.v2_community_for_channel(channel_id)? else {
3052            return Ok(serde_json::json!({ "findings": [] }));
3053        };
3054        let community = crate::db::community::load_community_v2(&id)
3055            .map_err(VectorError::Other)?
3056            .ok_or_else(|| VectorError::Other("v2 community not found".into()))?;
3057        let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
3058        let owner = community.owner().map_err(VectorError::Other)?;
3059        let author = PublicKey::parse(author_npub).map_err(|_| VectorError::Other("unreadable author".into()))?;
3060
3061        // Same staff definition the console uses: a role carrying a moderation
3062        // permission bit, never mere role membership.
3063        use crate::community::roles::Permissions;
3064        const MOD_MASK: u64 = Permissions::MANAGE_ROLES
3065            | Permissions::MANAGE_CHANNELS
3066            | Permissions::MANAGE_METADATA
3067            | Permissions::KICK
3068            | Permissions::BAN
3069            | Permissions::MANAGE_MESSAGES;
3070        let roster = crate::db::community::get_community_roles(&cid_hex).unwrap_or_default();
3071        let hex = author.to_hex();
3072        let roles: Vec<String> =
3073            roster.grants.iter().find(|g| g.member == hex).map(|g| g.role_ids.clone()).unwrap_or_default();
3074        let is_staff = roles
3075            .iter()
3076            .any(|rid| roster.roles.iter().any(|r| &r.role_id == rid && (r.permissions.0 & MOD_MASK) != 0));
3077
3078        let now_ms = std::time::SystemTime::now()
3079            .duration_since(std::time::UNIX_EPOCH)
3080            .map(|d| d.as_millis() as u64)
3081            .unwrap_or(0);
3082        let findings = crate::community::policy::harness::screen_message(
3083            &cid_hex, &owner, &author, &roles, is_staff, channel_id, text, now_ms,
3084        );
3085        Ok(serde_json::json!({ "findings": findings }))
3086    }
3087
3088    /// The raid verdict alone, for the badge on a community header. Same assessment as
3089    /// the panel and served from the same cache, so surfacing the alert costs nothing
3090    /// once and then nothing at all until it expires.
3091    pub fn check_community_raid(&self, community_id: &str) -> Result<serde_json::Value> {
3092        let Some(community) = Self::load_v2_if_v2(community_id)? else {
3093            return Ok(serde_json::json!({ "detected": false }));
3094        };
3095        let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
3096        let report = Self::policy_console_report(&cid_hex, &community)?;
3097        let biggest = report["cohorts"].get(0);
3098        Ok(serde_json::json!({
3099            "detected": report["raid_detected"].as_bool().unwrap_or(false),
3100            "suspects": report["suspects"].as_u64().unwrap_or(0),
3101            "cohort": biggest.and_then(|c| c["size"].as_u64()).unwrap_or(0),
3102            "sample": biggest
3103                .and_then(|c| c["sample"].as_str())
3104                .map(|s| s.chars().take(60).collect::<String>())
3105                .unwrap_or_default(),
3106        }))
3107    }
3108
3109    /// Assess a community, memoised for [`RAID_REPORT_TTL_SECS`]. The read decrypts a
3110    /// four-thousand-message window, so a header badge asking on every render would be a
3111    /// real cost; the panel opening moments later reuses the same answer.
3112    fn raid_report(cid_hex: &str) -> Result<std::sync::Arc<crate::community::raid::RaidReport>> {
3113        use crate::community::raid;
3114        use crate::community::v2::guestbook::GuestbookEntry;
3115        use nostr_sdk::prelude::PublicKey;
3116        use std::collections::{HashMap, HashSet};
3117
3118        let now_secs = std::time::SystemTime::now()
3119            .duration_since(std::time::UNIX_EPOCH)
3120            .map(|d| d.as_secs())
3121            .unwrap_or(0);
3122        {
3123            let cache = raid_report_cache();
3124            let map = cache.lock().unwrap_or_else(|e| e.into_inner());
3125            if let Some((at, report)) = map.get(cid_hex) {
3126                if now_secs.saturating_sub(*at) < RAID_REPORT_TTL_SECS {
3127                    return Ok(report.clone());
3128                }
3129            }
3130        }
3131        let community = crate::db::community::load_community_v2(&crate::community::CommunityId(
3132            crate::simd::hex::hex_to_bytes_32(cid_hex),
3133        ))
3134        .map_err(VectorError::Other)?
3135        .ok_or_else(|| VectorError::Other("moderation tools require a Concord v2 community".into()))?;
3136        let owner_b32 = community.owner().map_err(VectorError::Other)?.to_bech32().unwrap_or_default();
3137        let me_b32 = state::my_public_key().and_then(|p| p.to_bech32().ok()).unwrap_or_default();
3138
3139        // Anyone holding a live grant is staff, whatever the role is called.
3140        let roster = crate::db::community::get_community_roles(cid_hex).unwrap_or_default();
3141        let staff: HashSet<String> = roster
3142            .grants
3143            .iter()
3144            .filter(|g| !g.role_ids.is_empty())
3145            .filter_map(|g| PublicKey::from_hex(&g.member).ok().and_then(|pk| pk.to_bech32().ok()))
3146            .collect();
3147
3148        // The same fold the member list paints, so the panel can't disagree with it.
3149        let members: Vec<String> = crate::community::v2::service::stored_memberlist(&community)
3150            .unwrap_or_default()
3151            .into_iter()
3152            .filter_map(|pk| pk.to_bech32().ok())
3153            .collect();
3154
3155        // Guestbook: authoritative joins, with the invite each one came through.
3156        let (events, _cursor) = crate::db::community::get_guestbook(cid_hex).unwrap_or_default();
3157        let mut joined_at: HashMap<String, u64> = HashMap::new();
3158        let mut invite_label: HashMap<String, String> = HashMap::new();
3159        for e in &events {
3160            let GuestbookEntry::Join { member, invited_by, at_ms } = &e.entry else { continue };
3161            let Ok(b32) = member.to_bech32();
3162            // Earliest join wins: a rejoin must not erase real tenure.
3163            let slot = joined_at.entry(b32.clone()).or_insert(*at_ms);
3164            *slot = (*slot).min(*at_ms);
3165            if let Some((_creator, label)) = invited_by {
3166                invite_label.entry(b32).or_insert_with(|| label.clone());
3167            }
3168        }
3169
3170        let footprints: HashMap<String, crate::db::community::AuthorFootprint> =
3171            crate::db::community::community_author_footprints(cid_hex)
3172                .unwrap_or_default()
3173                .into_iter()
3174                .map(|f| (f.npub.clone(), f))
3175                .collect();
3176
3177        // Cohort evidence comes from the recent window only — a raid is by definition
3178        // recent, and the whole history would cost a vault decrypt per row.
3179        const TEXT_WINDOW: usize = 4_000;
3180        let mut texts: HashMap<String, Vec<String>> = HashMap::new();
3181        for (npub, _at, text) in crate::db::community::community_recent_texts(cid_hex, TEXT_WINDOW).unwrap_or_default() {
3182            texts.entry(npub).or_default().push(text);
3183        }
3184
3185        let signals: Vec<raid::MemberSignals> = members
3186            .iter()
3187            .map(|npub| {
3188                let f = footprints.get(npub);
3189                raid::MemberSignals {
3190                    npub: npub.clone(),
3191                    joined_at_ms: joined_at.get(npub).copied().unwrap_or(0),
3192                    invite_label: invite_label.get(npub).cloned(),
3193                    messages: f.map(|f| f.messages).unwrap_or(0),
3194                    first_secs: f.map(|f| f.first_secs).unwrap_or(0),
3195                    last_secs: f.map(|f| f.last_secs).unwrap_or(0),
3196                    texts: texts.get(npub).cloned().unwrap_or_default(),
3197                    is_owner: *npub == owner_b32,
3198                    is_admin: staff.contains(npub),
3199                    is_me: *npub == me_b32,
3200                }
3201            })
3202            .collect();
3203
3204        let params = raid::RaidParams {
3205            known_shortcodes: crate::emoji_packs::known_shortcodes(),
3206            ..Default::default()
3207        };
3208        let report = std::sync::Arc::new(raid::assess(&signals, now_secs, &params));
3209        raid_report_cache()
3210            .lock()
3211            .unwrap_or_else(|e| e.into_inner())
3212            .insert(cid_hex.to_string(), (now_secs, report.clone()));
3213        Ok(report)
3214    }
3215
3216    /// The preset library: what a designer offers instead of a blank document.
3217    pub fn policy_presets(&self) -> Result<serde_json::Value> {
3218        let presets: Vec<serde_json::Value> = crate::community::policy::presets::all()
3219            .into_iter()
3220            .map(|p| {
3221                serde_json::json!({
3222                    "id": p.id,
3223                    "name": p.name,
3224                    "description": p.description,
3225                    "example": p.example,
3226                    "caveat": p.caveat,
3227                    "dials": p.dials,
3228                    // Every rule in plain language: a template whose behaviour
3229                    // you can only infer from its name is the same black box
3230                    // the shipped defaults used to be.
3231                    "rules": crate::community::policy::presets::describe(&p.policy),
3232                    "bytes": serde_json::to_string(&p.policy).unwrap_or_default(),
3233                })
3234            })
3235            .collect();
3236        // The from-scratch builder's catalogue rides along: it is fetched at the
3237        // same moment, and its numbers must come from the same place the
3238        // presets' do.
3239        let rule_kinds: Vec<serde_json::Value> = crate::community::policy::presets::rule_kinds()
3240            .into_iter()
3241            .map(|k| {
3242                serde_json::json!({
3243                    "id": k.id,
3244                    "label": k.label,
3245                    "description": k.description,
3246                    "input": k.input,
3247                    "input_label": k.input_label,
3248                    "input_hint": k.input_hint,
3249                    "rule": k.rule,
3250                })
3251            })
3252            .collect();
3253        Ok(serde_json::json!({ "presets": presets, "rule_kinds": rule_kinds }))
3254    }
3255
3256    /// What a candidate policy WOULD do, against real history. Stores nothing,
3257    /// publishes nothing, removes nobody — the designer never enables a policy
3258    /// from a form alone, only from a preview that named who it would catch.
3259    pub fn preview_community_policy(&self, community_id: &str, bytes: &str) -> Result<serde_json::Value> {
3260        use crate::community::v2::guestbook::GuestbookEntry;
3261        use nostr_sdk::prelude::PublicKey;
3262        let community = Self::load_v2_if_v2(community_id)?
3263            .ok_or_else(|| VectorError::Other("policies require a Concord v2 community".into()))?;
3264        let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
3265        let owner = community.owner().map_err(VectorError::Other)?;
3266        let me = state::my_public_key();
3267
3268        use crate::community::roles::Permissions;
3269        const MOD_MASK: u64 = Permissions::MANAGE_ROLES
3270            | Permissions::MANAGE_CHANNELS
3271            | Permissions::MANAGE_METADATA
3272            | Permissions::KICK
3273            | Permissions::BAN
3274            | Permissions::MANAGE_MESSAGES;
3275        let roster = crate::db::community::get_community_roles(&cid_hex).unwrap_or_default();
3276        let staff: std::collections::HashSet<String> = roster
3277            .grants
3278            .iter()
3279            .filter(|g| {
3280                g.role_ids
3281                    .iter()
3282                    .any(|rid| roster.roles.iter().any(|r| &r.role_id == rid && (r.permissions.0 & MOD_MASK) != 0))
3283            })
3284            .map(|g| g.member.clone())
3285            .collect();
3286        let roles_of: std::collections::HashMap<String, Vec<String>> =
3287            roster.grants.iter().map(|g| (g.member.clone(), g.role_ids.clone())).collect();
3288
3289        let (events, _cursor) = crate::db::community::get_guestbook(&cid_hex).unwrap_or_default();
3290        let mut joined_at: std::collections::HashMap<String, u64> = std::collections::HashMap::new();
3291        let mut invite_label: std::collections::HashMap<String, String> = std::collections::HashMap::new();
3292        for e in &events {
3293            let GuestbookEntry::Join { member, invited_by, at_ms } = &e.entry else { continue };
3294            let hex = member.to_hex();
3295            let slot = joined_at.entry(hex.clone()).or_insert(*at_ms);
3296            *slot = (*slot).min(*at_ms);
3297            if let Some((_c, label)) = invited_by {
3298                invite_label.entry(hex).or_insert_with(|| label.clone());
3299            }
3300        }
3301        let members: Vec<(PublicKey, Option<u64>, bool, Vec<String>, Option<String>)> =
3302            crate::community::v2::service::stored_memberlist(&community)
3303                .unwrap_or_default()
3304                .into_iter()
3305                .map(|pk| {
3306                    let hex = pk.to_hex();
3307                    (
3308                        pk,
3309                        joined_at.get(&hex).copied(),
3310                        staff.contains(&hex),
3311                        roles_of.get(&hex).cloned().unwrap_or_default(),
3312                        invite_label.get(&hex).cloned(),
3313                    )
3314                })
3315                .collect();
3316
3317        let now_ms = std::time::SystemTime::now()
3318            .duration_since(std::time::UNIX_EPOCH)
3319            .map(|d| d.as_millis() as u64)
3320            .unwrap_or(0);
3321        let assembled =
3322            crate::community::policy::harness::assemble(&cid_hex, &owner, me.as_ref(), &members, now_ms)
3323                .map_err(VectorError::Other)?;
3324        let preview = crate::community::policy::harness::preview_policy(&assembled, bytes, now_ms);
3325        serde_json::to_value(preview).map_err(|e| VectorError::Other(e.to_string()))
3326    }
3327
3328    /// A community's policies, as stored. Returns the exact bytes, so an editor
3329    /// round-trips what was written rather than a re-serialization of it.
3330    pub fn list_community_policies(&self, community_id: &str) -> Result<serde_json::Value> {
3331        let community = Self::load_v2_if_v2(community_id)?
3332            .ok_or_else(|| VectorError::Other("policies require a Concord v2 community".into()))?;
3333        let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
3334        let stored = crate::db::community::get_community_policies(&cid_hex).map_err(VectorError::Other)?;
3335        let rows: Vec<serde_json::Value> = stored
3336            .iter()
3337            .map(|p| {
3338                // Report what the validator says NOW: a policy stored under an
3339                // older engine can stop validating, and a moderator must see
3340                // that rather than assume their rules are running.
3341                let verdict = serde_json::from_str::<crate::community::policy::document::Policy>(&p.bytes)
3342                    .map_err(|e| e.to_string())
3343                    .and_then(|doc| doc.validate().map_err(|r| format!("{r:?}")));
3344                serde_json::json!({
3345                    "policy_id": p.policy_id,
3346                    "hash": p.hash,
3347                    "enabled": p.enabled,
3348                    "updated_at": p.updated_at,
3349                    "bytes": p.bytes,
3350                    "valid": verdict.is_ok(),
3351                    "error": verdict.err(),
3352                })
3353            })
3354            .collect();
3355        Ok(serde_json::json!({
3356            "community_id": cid_hex,
3357            "policies": rows,
3358            // Must agree with `select_policies`, which is the thing that
3359            // actually decides: the shipped defaults run unless the community
3360            // has forked them. A flag derived any other way tells a moderator
3361            // their cover is on while the engine has stood it down.
3362            "using_builtin": !stored
3363                .iter()
3364                .any(|p| p.policy_id == crate::community::policy::harness::DEFAULTS_POLICY_ID),
3365        }))
3366    }
3367
3368    /// Store a policy, validating FIRST. An invalid policy is a rejected edit,
3369    /// never a stored one that quietly evaluates to nothing.
3370    pub fn set_community_policy(&self, community_id: &str, policy_id: &str, bytes: &str, enabled: bool) -> Result<serde_json::Value> {
3371        let community = Self::load_v2_if_v2(community_id)?
3372            .ok_or_else(|| VectorError::Other("policies require a Concord v2 community".into()))?;
3373        let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
3374        if bytes.len() > crate::community::policy::types::caps::MAX_POLICY_BYTES {
3375            return Err(VectorError::Other("policy exceeds the maximum document size".into()));
3376        }
3377        let doc: crate::community::policy::document::Policy =
3378            serde_json::from_str(bytes).map_err(|e| VectorError::Other(format!("policy is not valid JSON: {e}")))?;
3379        doc.validate().map_err(|r| VectorError::Other(format!("policy rejected: {r:?}")))?;
3380
3381        let hash = crate::community::policy::harness::hash_policy_bytes(bytes.as_bytes());
3382        let hash_hex = crate::simd::hex::bytes_to_hex_32(&hash.0);
3383        let now_secs = std::time::SystemTime::now()
3384            .duration_since(std::time::UNIX_EPOCH)
3385            .map(|d| d.as_secs())
3386            .unwrap_or(0);
3387        crate::db::community::set_community_policy(&cid_hex, policy_id, bytes, &hash_hex, enabled, now_secs)
3388            .map_err(VectorError::Other)?;
3389        // The console must not keep serving verdicts from the rules that were
3390        // in force a minute ago.
3391        Self::invalidate_raid_report(&cid_hex);
3392        Ok(serde_json::json!({ "policy_id": policy_id, "hash": hash_hex, "enabled": enabled }))
3393    }
3394
3395    pub fn delete_community_policy(&self, community_id: &str, policy_id: &str) -> Result<()> {
3396        let community = Self::load_v2_if_v2(community_id)?
3397            .ok_or_else(|| VectorError::Other("policies require a Concord v2 community".into()))?;
3398        let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
3399        crate::db::community::delete_community_policy(&cid_hex, policy_id).map_err(VectorError::Other)?;
3400        Self::invalidate_raid_report(&cid_hex);
3401        Ok(())
3402    }
3403
3404    /// The built-in policy, as editable bytes — the starting point an editor
3405    /// offers instead of a blank document.
3406    pub fn builtin_policy_json(&self) -> Result<String> {
3407        serde_json::to_string_pretty(&crate::community::policy::harness::default_policy())
3408            .map_err(|e| VectorError::Other(e.to_string()))
3409    }
3410
3411    /// The moderation console's report, from the POLICY ENGINE. Memoised for
3412    /// [`RAID_REPORT_TTL_SECS`], same as the assessor it replaces.
3413    ///
3414    /// `raid.rs` still runs — [`Self::policy_side_by_side`] diffs the two — but
3415    /// the panel reads this. The engine earned it on live data: it clustered a
3416    /// real raid out of local storage and stayed silent on a healthy community
3417    /// across six runs.
3418    fn policy_console_report(cid_hex: &str, community: &crate::community::v2::community::CommunityV2) -> Result<std::sync::Arc<serde_json::Value>> {
3419        use crate::community::v2::guestbook::GuestbookEntry;
3420        use nostr_sdk::prelude::PublicKey;
3421        let now_secs = std::time::SystemTime::now()
3422            .duration_since(std::time::UNIX_EPOCH)
3423            .map(|d| d.as_secs())
3424            .unwrap_or(0);
3425        {
3426            let cache = policy_report_cache();
3427            let guard = cache.lock().unwrap_or_else(|e| e.into_inner());
3428            if let Some((at, cached)) = guard.get(cid_hex) {
3429                if now_secs.saturating_sub(*at) < RAID_REPORT_TTL_SECS {
3430                    return Ok(cached.clone());
3431                }
3432            }
3433        }
3434
3435        let owner = community.owner().map_err(VectorError::Other)?;
3436        let me = state::my_public_key();
3437
3438        // Staff = a role carrying moderation permissions. NOT mere role
3439        // membership: a cosmetic or self-serve role must never confer immunity
3440        // from the rules this engine exists to enforce.
3441        use crate::community::roles::Permissions;
3442        const MOD_MASK: u64 = Permissions::MANAGE_ROLES
3443            | Permissions::MANAGE_CHANNELS
3444            | Permissions::MANAGE_METADATA
3445            | Permissions::KICK
3446            | Permissions::BAN
3447            | Permissions::MANAGE_MESSAGES;
3448        let roster = crate::db::community::get_community_roles(cid_hex).unwrap_or_default();
3449        let staff: std::collections::HashSet<String> = roster
3450            .grants
3451            .iter()
3452            .filter(|g| {
3453                g.role_ids
3454                    .iter()
3455                    .any(|rid| roster.roles.iter().any(|r| &r.role_id == rid && (r.permissions.0 & MOD_MASK) != 0))
3456            })
3457            .map(|g| g.member.clone())
3458            .collect();
3459        let roles_of: std::collections::HashMap<String, Vec<String>> =
3460            roster.grants.iter().map(|g| (g.member.clone(), g.role_ids.clone())).collect();
3461
3462        let (events, _cursor) = crate::db::community::get_guestbook(cid_hex).unwrap_or_default();
3463        let mut joined_at: std::collections::HashMap<String, u64> = std::collections::HashMap::new();
3464        let mut invite_label: std::collections::HashMap<String, String> = std::collections::HashMap::new();
3465        for e in &events {
3466            let GuestbookEntry::Join { member, invited_by, at_ms } = &e.entry else { continue };
3467            let hex = member.to_hex();
3468            // Earliest join wins: a rejoin must not erase real tenure.
3469            let slot = joined_at.entry(hex.clone()).or_insert(*at_ms);
3470            *slot = (*slot).min(*at_ms);
3471            if let Some((_creator, label)) = invited_by {
3472                invite_label.entry(hex).or_insert_with(|| label.clone());
3473            }
3474        }
3475
3476        let members: Vec<(PublicKey, Option<u64>, bool, Vec<String>, Option<String>)> =
3477            crate::community::v2::service::stored_memberlist(community)
3478                .unwrap_or_default()
3479                .into_iter()
3480                .map(|pk| {
3481                    let hex = pk.to_hex();
3482                    (
3483                        pk,
3484                        joined_at.get(&hex).copied(),
3485                        staff.contains(&hex),
3486                        roles_of.get(&hex).cloned().unwrap_or_default(),
3487                        invite_label.get(&hex).cloned(),
3488                    )
3489                })
3490                .collect();
3491
3492        let now_ms = now_secs.saturating_mul(1000);
3493        let assembled =
3494            crate::community::policy::harness::assemble(cid_hex, &owner, me.as_ref(), &members, now_ms)
3495                .map_err(VectorError::Other)?;
3496        let report = std::sync::Arc::new(
3497            crate::community::policy::harness::evaluate_for_console(cid_hex, &assembled, now_ms)
3498                .map_err(VectorError::Other)?,
3499        );
3500        policy_report_cache()
3501            .lock()
3502            .unwrap_or_else(|e| e.into_inner())
3503            .insert(cid_hex.to_string(), (now_secs, report.clone()));
3504        Ok(report)
3505    }
3506
3507    /// Run the policy engine beside the shipped assessor and report where they
3508    /// disagree. Diagnostic only: the engine convicts nothing in production
3509    /// until this diff has been read against real data.
3510    pub fn policy_side_by_side(&self, community_id: &str) -> Result<serde_json::Value> {
3511        use crate::community::v2::guestbook::GuestbookEntry;
3512        use nostr_sdk::prelude::PublicKey;
3513        let community = Self::load_v2_if_v2(community_id)?
3514            .ok_or_else(|| VectorError::Other("moderation tools require a Concord v2 community".into()))?;
3515        let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
3516        let owner = community.owner().map_err(VectorError::Other)?;
3517
3518        // The moderation permission bits: holding one of these is staff. Mere
3519        // role membership is NOT — a cosmetic role must not confer immunity.
3520        use crate::community::roles::Permissions;
3521        const MOD_MASK: u64 = Permissions::MANAGE_ROLES
3522            | Permissions::MANAGE_CHANNELS
3523            | Permissions::MANAGE_METADATA
3524            | Permissions::KICK
3525            | Permissions::BAN
3526            | Permissions::MANAGE_MESSAGES;
3527        let roster = crate::db::community::get_community_roles(&cid_hex).unwrap_or_default();
3528        let staff: std::collections::HashSet<String> = roster
3529            .grants
3530            .iter()
3531            .filter(|g| {
3532                g.role_ids.iter().any(|rid| {
3533                    roster.roles.iter().any(|r| &r.role_id == rid && (r.permissions.0 & MOD_MASK) != 0)
3534                })
3535            })
3536            .map(|g| g.member.clone())
3537            .collect();
3538        let roles_of: std::collections::HashMap<String, Vec<String>> =
3539            roster.grants.iter().map(|g| (g.member.clone(), g.role_ids.clone())).collect();
3540
3541        let (events, _cursor) = crate::db::community::get_guestbook(&cid_hex).unwrap_or_default();
3542        let mut joined_at: std::collections::HashMap<String, u64> = std::collections::HashMap::new();
3543        for e in &events {
3544            let GuestbookEntry::Join { member, at_ms, .. } = &e.entry else { continue };
3545            let hex = member.to_hex();
3546            let slot = joined_at.entry(hex).or_insert(*at_ms);
3547            *slot = (*slot).min(*at_ms);
3548        }
3549
3550        let members: Vec<(PublicKey, Option<u64>, bool, Vec<String>)> =
3551            crate::community::v2::service::stored_memberlist(&community)
3552                .unwrap_or_default()
3553                .into_iter()
3554                .map(|pk| {
3555                    let hex = pk.to_hex();
3556                    (pk, joined_at.get(&hex).copied(), staff.contains(&hex), roles_of.get(&hex).cloned().unwrap_or_default())
3557                })
3558                .collect();
3559
3560        // The assessor's own verdicts, for the diff.
3561        let assessments: Vec<(String, String)> = Self::raid_report(&cid_hex)
3562            .map(|r| {
3563                r.members
3564                    .iter()
3565                    .map(|m| (m.npub.clone(), format!("{:?}", m.verdict).to_lowercase()))
3566                    .collect()
3567            })
3568            .unwrap_or_default();
3569
3570        let now_ms = std::time::SystemTime::now()
3571            .duration_since(std::time::UNIX_EPOCH)
3572            .map(|d| d.as_millis() as u64)
3573            .unwrap_or(0);
3574        let diff = crate::community::policy::harness::run_side_by_side(
3575            &cid_hex, &owner, &members, &assessments, now_ms,
3576        )
3577        .map_err(VectorError::Other)?;
3578        serde_json::to_value(diff).map_err(|e| VectorError::Other(e.to_string()))
3579    }
3580
3581    /// Drop a community's memoised verdict. Every moderation action changes who is a
3582    /// member, so serving the pre-action answer would leave the badge accusing people
3583    /// who are already gone.
3584    pub fn invalidate_raid_report(community_id: &str) {
3585        policy_report_cache().lock().unwrap_or_else(|e| e.into_inner()).remove(community_id);
3586        raid_report_cache().lock().unwrap_or_else(|e| e.into_inner()).remove(community_id);
3587    }
3588
3589    /// Rotate the Community's keys with no banlist edit: mint a new root epoch that
3590    /// only `retain` can follow. This is the containment that scales — a wave of
3591    /// thousands cannot fit in a 500-entry banlist, but it can be locked out of the
3592    /// next epoch in one publish. `retain` is a keep-list; everyone else is cut.
3593    ///
3594    /// Passing an empty `retain` rotates without removing anyone, which is what you
3595    /// want when the invite link leaked but the members are all real.
3596    pub async fn refound_community(&self, community_id: &str, retain: &[&str]) -> Result<()> {
3597        let community = Self::load_v2_if_v2(community_id)?
3598            .ok_or_else(|| VectorError::Other("re-founding requires a Concord v2 community".into()))?;
3599        let removed = Self::members_to_remove(&community, retain)?;
3600        let transport = crate::community::transport::LiveTransport::with_timeout(std::time::Duration::from_secs(30));
3601        crate::community::v2::service::refound_community(&transport, &community, &removed)
3602            .await
3603            .map_err(VectorError::Other)?;
3604        // The cut changed who is a member; the memoised verdict still names them.
3605        Self::invalidate_raid_report(community_id);
3606        Ok(())
3607    }
3608
3609    /// The members a `retain` keep-list would cut.
3610    ///
3611    /// Folded from the LOCAL store, and that is not an oversight. The network memberlist
3612    /// is epoch-scoped — its Guestbook plane holds only the current epoch's snapshot, and
3613    /// its channel reads only return what is decryptable now — so members stranded on a
3614    /// buried epoch are already absent from it. Those are precisely the phantoms this
3615    /// panel exists to clear: they survive ONLY in the local store, which accumulates
3616    /// across epochs. Folding the network list here returns an empty removal set and the
3617    /// sweep does nothing.
3618    ///
3619    /// The two lists answer different questions and must not be unified. "Who receives a
3620    /// new key" is the rotation's own network fold, which it does for itself. "Who is a
3621    /// phantom to evict" is this one, and being local-only is the definition of it.
3622    ///
3623    /// An empty `retain` removes nobody — a rotation for its own sake. You and the owner
3624    /// are never in the result: a keep-list omitting either is a caller mistake, and
3625    /// honouring it would lock the community's own staff out of an epoch they just minted.
3626    fn members_to_remove(
3627        community: &crate::community::v2::community::CommunityV2,
3628        retain: &[&str],
3629    ) -> Result<Vec<nostr_sdk::prelude::PublicKey>> {
3630        use nostr_sdk::prelude::PublicKey;
3631        let keep: std::collections::HashSet<PublicKey> =
3632            retain.iter().filter_map(|n| PublicKey::parse(n).ok()).collect();
3633        if keep.is_empty() {
3634            return Ok(Vec::new());
3635        }
3636        let me = state::my_public_key();
3637        let owner = community.owner().ok();
3638        Ok(crate::community::v2::service::stored_memberlist(community)
3639            .map_err(VectorError::Other)?
3640            .into_iter()
3641            .filter(|pk| !keep.contains(pk))
3642            .filter(|pk| Some(*pk) != me && Some(*pk) != owner)
3643            .collect())
3644    }
3645
3646    /// Rotate the keys around the survivors, then kick the removed off the roster.
3647    ///
3648    /// The order is load-bearing, and it is ROTATE FIRST. A Guestbook plane is derived
3649    /// per epoch, so kicks published before the rotation land on a plane the rotation
3650    /// immediately buries: every reader then has to receive them in the window before it
3651    /// follows the rekey, and whatever is still in flight is never folded by anyone.
3652    /// Publishing onto the NEW epoch instead puts the departures on the plane every
3653    /// survivor will read from now on, so a client that catches up an hour later still
3654    /// folds them. It also closes the re-admission window — the removed hold no key at
3655    /// the new epoch, so they cannot post their way back into the memberlist between
3656    /// their own kick and the rotation.
3657    ///
3658    /// The rotation alone would not clear the roster: the guestbook store is keyed by
3659    /// community and appended across epochs, and the memberlist re-admits observed
3660    /// authors from local history regardless of epoch. The kicks are what evict them.
3661    pub async fn purge_and_refound(
3662        &self,
3663        community_id: &str,
3664        retain: &[&str],
3665        on_progress: &(dyn Fn(usize, usize) + Sync),
3666    ) -> Result<serde_json::Value> {
3667        let community = Self::load_v2_if_v2(community_id)?
3668            .ok_or_else(|| VectorError::Other("re-founding requires a Concord v2 community".into()))?;
3669        let removed = Self::members_to_remove(&community, retain)?;
3670        // The operator's list IS the recipient set. `removed` is derived from the
3671        // memberlist at THIS instant and only drives the kick sweep below; the refound
3672        // fetches its own, later. Passing removal alone let an account that appeared in
3673        // between be vended a key by default — implicitly retained by the purge.
3674        let keep: Vec<nostr_sdk::prelude::PublicKey> = retain
3675            .iter()
3676            .filter_map(|n| nostr_sdk::prelude::PublicKey::parse(n).ok())
3677            .collect();
3678        log_info!("[Moderation] purge: {} to remove, {} retained", removed.len(), keep.len());
3679        let transport = crate::community::transport::LiveTransport::with_timeout(std::time::Duration::from_secs(30));
3680        // An EMPTY retain means "cut nobody", not "keep nobody".
3681        //
3682        // The two lines above read the same empty list in opposite directions:
3683        // `members_to_remove` correctly removes nobody, while `keep` becomes an empty
3684        // ALLOW-LIST — and an allow-list keeps only this device and the owner, so a
3685        // plain "Rotate keys" vended to two people and stranded everyone else at the
3686        // previous epoch, permanently. It presented as members vanishing from the
3687        // list some time later, nowhere near the button that did it.
3688        //
3689        // A purge is the caller NAMING who stays. Nobody named is not a purge.
3690        if keep.is_empty() {
3691            crate::community::v2::service::refound_community(&transport, &community, &removed)
3692                .await
3693                .map_err(VectorError::Other)?;
3694        } else {
3695            crate::community::v2::service::refound_community_retaining(&transport, &community, &removed, &keep)
3696                .await
3697                .map_err(VectorError::Other)?;
3698        }
3699        // Re-load onto the epoch the rotation just minted: kicking against the stale
3700        // struct would address the plane we just buried, which is the whole bug this
3701        // ordering exists to avoid.
3702        let fresh = Self::load_v2_if_v2(community_id)?
3703            .ok_or_else(|| VectorError::Other("community gone during the purge".into()))?;
3704        let sweep = crate::community::v2::service::kick_members(&transport, &fresh, &removed, on_progress)
3705            .await
3706            .map_err(VectorError::Other)?;
3707        // Fold our own directives back. Without this the local roster only shows what
3708        // the live subscription happened to echo before the sweep finished.
3709        let _ = crate::community::v2::service::sync_guestbook(&transport, &fresh).await;
3710        log_info!(
3711            "[Moderation] purge: rotated to epoch {}, kicked {}/{} ({} refused, {} failed)",
3712            fresh.root_epoch.0,
3713            sweep.kicked.len(),
3714            removed.len(),
3715            sweep.refused.len(),
3716            sweep.failed.len()
3717        );
3718        Ok(serde_json::json!({
3719            "kicked": sweep.kicked.len(),
3720            "refused": sweep.refused.len(),
3721            "failed": sweep.failed.len(),
3722            "rotated": true,
3723        }))
3724    }
3725
3726    /// Kick many members without rotating. Exposed for callers that want the roster
3727    /// cleaned but the keys left alone.
3728    pub async fn kick_community_members(
3729        &self,
3730        community_id: &str,
3731        npubs: &[&str],
3732        on_progress: &(dyn Fn(usize, usize) + Sync),
3733    ) -> Result<serde_json::Value> {
3734        let community = Self::load_v2_if_v2(community_id)?
3735            .ok_or_else(|| VectorError::Other("kicking requires a Concord v2 community".into()))?;
3736        use nostr_sdk::prelude::PublicKey;
3737        let targets: Vec<PublicKey> = npubs.iter().filter_map(|n| PublicKey::parse(n).ok()).collect();
3738        let transport = crate::community::transport::LiveTransport::with_timeout(std::time::Duration::from_secs(30));
3739        let sweep = crate::community::v2::service::kick_members(&transport, &community, &targets, on_progress)
3740            .await
3741            .map_err(VectorError::Other)?;
3742        let _ = crate::community::v2::service::sync_guestbook(&transport, &community).await;
3743        log_info!(
3744            "[Moderation] kick sweep: {}/{} kicked ({} refused, {} failed)",
3745            sweep.kicked.len(),
3746            targets.len(),
3747            sweep.refused.len(),
3748            sweep.failed.len()
3749        );
3750        Self::invalidate_raid_report(community_id);
3751        Ok(serde_json::json!({
3752            "kicked": sweep.kicked.len(),
3753            "refused": sweep.refused.iter().map(|(_, why)| why.clone()).collect::<Vec<_>>(),
3754            "failed": sweep.failed.len(),
3755        }))
3756    }
3757
3758    /// Retire every public invite link this Community holds. A raid arrives through a
3759    /// link, and revoking them one at a time loses the race — the whole set has to go
3760    /// in one pass before the rotation, or the next wave walks in through the survivor.
3761    /// Returns `(revoked, failed)`.
3762    pub async fn revoke_all_public_invites(&self, community_id: &str) -> Result<(usize, usize)> {
3763        let tokens: Vec<String> = crate::db::community::list_public_invites(community_id)
3764            .map_err(VectorError::Other)?
3765            .into_iter()
3766            .map(|i| i.token)
3767            .collect();
3768        let mut revoked = 0usize;
3769        let mut failed = 0usize;
3770        for token in &tokens {
3771            match self.revoke_public_invite(community_id, token).await {
3772                Ok(()) => revoked += 1,
3773                Err(_) => failed += 1,
3774            }
3775        }
3776        Ok((revoked, failed))
3777    }
3778
3779    /// One synchronous v2 follow pass — rekeys first (a base adopt moves the
3780    /// control address), then a control refold on the FRESH state, the same order
3781    /// the live follow worker runs. Returns non-fatal warnings.
3782    /// Walk one community's rekeys + control fold NOW, serialized against the live
3783    /// follow worker by the per-community lock. Public so boot can converge a community
3784    /// the probe saw move BEFORE paging its content — content read at a superseded
3785    /// epoch is wasted work, and may not open at all.
3786    pub async fn v2_inline_follow(id: &crate::community::CommunityId) -> Vec<String> {
3787        crate::db::scoped(async move {
3788            use crate::community::transport::LiveTransport;
3789            let session = crate::db::current_session();
3790            // Serialize with the live follow worker: `follow_worker_running` is
3791            // check-then-act, so a worker can spawn right after a caller saw `false` —
3792            // this shared per-community lock is what actually prevents two follows of
3793            // one community interleaving their whole-row saves.
3794            let lock = crate::community::v2::realtime::follow_lock(id);
3795            let _guard = lock.lock().await;
3796            let transport = LiveTransport::with_timeout(std::time::Duration::from_secs(12));
3797            let mut warnings: Vec<String> = Vec::new();
3798            let Ok(Some(community)) = crate::db::community::load_community_v2(id) else {
3799                warnings.push("v2 community not found".to_string());
3800                return warnings;
3801            };
3802            let cid_hex = crate::simd::hex::bytes_to_hex_32(&id.0);
3803            match crate::community::v2::service::follow_rekeys(&transport, &community, &session).await {
3804                // A tombstone surfaced during catch-up — sealed read-only; stop here.
3805                Ok(f) if f.dissolved => return warnings,
3806                Ok(f) if f.self_removed => {
3807                    // An authorized rotation that excluded us IS a removal — but keep the
3808                    // epoch keys, exactly as every other self-removal path does. They grant
3809                    // no future read (post-removal keys are never delivered); dropping them
3810                    // only destroys the ability to author a 3305 self-delete of our OWN past
3811                    // messages, each sealed under the epoch it was sent at. That is the one
3812                    // thing a removed member should still be able to do — and doubly so if
3813                    // the removal turns out to have been a false positive.
3814                    let _ = crate::db::community::delete_community_retain_keys(&cid_hex);
3815                    return warnings;
3816                }
3817                Ok(_) => {}
3818                Err(e) => warnings.push(format!("v2 rekey follow failed: {e}")),
3819            }
3820            if let Ok(Some(fresh)) = crate::db::community::load_community_v2(id) {
3821                match crate::community::v2::service::follow_control(&transport, &fresh).await {
3822                    // Walk the rekeys once more whenever the control fold moved ANYTHING.
3823                    // The document half reveals rekey work that predates it (a just-announced
3824                    // private channel's key crate already sits on its rekey plane). The
3825                    // AUTHORITY half is the one that strands: the walk above gates every
3826                    // rotation on the roster and citation heads this fold just wrote, so a
3827                    // promote-then-rotate is refused on pass 1 and, without this, never
3828                    // reconsidered. Re-read from the DB — the authority half moves columns
3829                    // the returned document does not carry.
3830                    Ok(control) if control.moved() => {
3831                        if let Ok(Some(latest)) = crate::db::community::load_community_v2(id) {
3832                            if let Err(e) = crate::community::v2::service::follow_rekeys(&transport, &latest, &session).await {
3833                                warnings.push(format!("v2 rekey follow failed: {e}"));
3834                            }
3835                        }
3836                    }
3837                    Ok(_) => {}
3838                    Err(e) => warnings.push(format!("v2 control follow failed: {e}")),
3839                }
3840            }
3841            // Banned by the freshly-folded banlist: a removal just like the rotation
3842            // exclusion above, and it arrives FIRST (CORD-04 §6 orders the Banlist edition
3843            // before the Refounding), so keying removal solely off the rotation leaves a
3844            // banned headless client running against a community that already dropped it.
3845            if let Some(me) = crate::my_public_key() {
3846                if crate::db::community::is_author_banned(&cid_hex, &me) {
3847                    let _ = crate::db::community::delete_community_retain_keys(&cid_hex);
3848                }
3849            }
3850            warnings
3851        })
3852        .await
3853    }
3854
3855    /// Fetch a v2 channel's recent chat history and PERSIST it into the shared events
3856    /// tables (the same store v1 uses), so `get_messages`/`get_new_messages` backfill for
3857    /// v2 exactly like v1. PAGES backwards until it reaches messages it already holds
3858    /// (bounded), so a reconnecting bot that slept through more than one page of traffic
3859    /// still catches the whole gap instead of only the newest `limit`. Reuses the v2
3860    /// inbound bridge (dedup + STATE aggregate) + the v1 save path. Returns the count of
3861    /// brand-new messages applied. Best-effort: a fetch failure is 0.
3862    /// Reconnect/boot catch-up for one v2 channel: fetches the newest pages
3863    /// and PAGES backwards until it reaches messages it already holds, then
3864    /// ingests through the shared pipeline. The boot volley fetches its own
3865    /// batches and shares only [`Self::v2_ingest_chat_page`].
3866    pub(crate) async fn v2_backfill_channel(
3867        id: &crate::community::CommunityId,
3868        channel_id: &str,
3869        limit: usize,
3870        max_pages: usize,
3871        since: Option<u64>,
3872        until: Option<u64>,
3873        evidence: crate::community::transport::Evidence,
3874        transport_secs: u64,
3875    ) -> usize {
3876        Self::v2_backfill_channel_counted(id, channel_id, limit, max_pages, since, until, evidence, transport_secs)
3877            .await
3878            .new_messages
3879    }
3880
3881    /// [`v2_backfill_channel`](Self::v2_backfill_channel) reporting what the RELAY
3882    /// served as well as what was new. A back-page over history we already hold is
3883    /// new-count 0 with a non-empty fetch, so only `fetched` can answer "is there
3884    /// anything older" — new-count alone stops a scroll at the first known page.
3885    pub(crate) async fn v2_backfill_channel_counted(
3886        id: &crate::community::CommunityId,
3887        channel_id: &str,
3888        limit: usize,
3889        max_pages: usize,
3890        since: Option<u64>,
3891        until: Option<u64>,
3892        evidence: crate::community::transport::Evidence,
3893        transport_secs: u64,
3894    ) -> BackfillCount {
3895        // Guard straddles the fetch: a swap mid-fetch must not persist account A's chat
3896        // into account B's STATE/DB (the message ids are global).
3897        let session = crate::db::current_session();
3898        let Some(my_pk) = state::my_public_key() else { return BackfillCount::default() };
3899        // CORD-02 §9: a dissolved community honors no NEW events — old history reads
3900        // through the explicit paths, but a catch-up sweep must not ingest anything
3901        // authored into the grave.
3902        if crate::db::community::get_community_dissolved(&crate::simd::hex::bytes_to_hex_32(&id.0)).unwrap_or(false) {
3903            return BackfillCount::default();
3904        }
3905        let Ok(Some(community)) = crate::db::community::load_community_v2(id) else { return BackfillCount::default() };
3906        let ch = crate::community::ChannelId(crate::simd::hex::hex_to_bytes_32(channel_id));
3907        let transport = crate::community::transport::LiveTransport::with_timeout(std::time::Duration::from_secs(transport_secs));
3908        let Ok(page) = crate::community::v2::service::fetch_channel_history(
3909            &transport,
3910            &community,
3911            &ch,
3912            limit.max(50),
3913            max_pages,
3914            since,
3915            until,
3916            evidence,
3917            // Keep paging while a page still contains a MESSAGE we don't hold; a page
3918            // whose messages are all known means we've reached our own history. Only
3919            // message kinds get their own rows (reactions/edits fold into their
3920            // targets), so a page with no messages is undecidable — keep paging.
3921            |page| {
3922                let mut saw_message = false;
3923                for f in page {
3924                    if matches!(&f.event, crate::community::v2::chat::ChatEvent::Message { .. }) {
3925                        saw_message = true;
3926                        if !crate::db::events::event_exists(&f.event.opened().rumor_id.to_hex()).unwrap_or(false) {
3927                            return true;
3928                        }
3929                    }
3930                }
3931                !saw_message
3932            },
3933        )
3934        .await
3935        else {
3936            return BackfillCount::default();
3937        };
3938        let fetched = page.len();
3939        let new_messages = Self::v2_ingest_chat_page(channel_id, my_pk, session, page).await;
3940        BackfillCount { fetched, new_messages }
3941    }
3942
3943    /// Ingest a fetched chat page: STATE apply, batched persist with delete
3944    /// barriers, then UI surfacing — shared by the reconnect backfill and the
3945    /// boot volley's batched paint path.
3946    pub(crate) async fn v2_ingest_chat_page(
3947        channel_id: &str,
3948        my_pk: nostr_sdk::prelude::PublicKey,
3949        session: std::sync::Arc<crate::db::Session>,
3950        page: Vec<crate::community::v2::service::FetchedEvent>,
3951    ) -> usize {
3952        crate::db::scoped(async move {
3953            use crate::community::v2::inbound::{apply_chat_to_state, ChatPersist};
3954            let mut new = 0usize;
3955            // Pass 1 — apply to STATE (per-item lock) and COLLECT outcomes in wire order.
3956            let mut outcomes: Vec<ChatPersist> = Vec::with_capacity(page.len());
3957            for f in &page {
3958                // A backfilled WebXDC peer ad persists through the shared 30078 row
3959                // (recency-gated at read) so a reopening lobby lists peers who
3960                // advertised while this device was closed — v1 sync parity. Own
3961                // echoes drop; the ad is not a chat row.
3962                if let crate::community::v2::chat::ChatEvent::Webxdc { opened } = &f.event {
3963                    if opened.author != my_pk {
3964                        if let Some((topic, addr)) = crate::webxdc::parse_peer_signal(&opened.rumor.content) {
3965                            let Ok(npub) = ToBech32::to_bech32(&opened.author);
3966                            crate::community::service::persist_webxdc_signal(
3967                                channel_id,
3968                                &npub,
3969                                &topic,
3970                                addr.as_deref(),
3971                                &opened.rumor_id.to_hex(),
3972                                opened.at_ms / 1000,
3973                            )
3974                            .await;
3975                        }
3976                    }
3977                    continue;
3978                }
3979                let outcome = {
3980                    let mut st = state::STATE.lock().await;
3981                    apply_chat_to_state(&mut st, &f.event, channel_id, &my_pk)
3982                };
3983                if let Some(outcome) = outcome {
3984                    if matches!(outcome, ChatPersist::New(_)) {
3985                        new += 1;
3986                    }
3987                    outcomes.push(outcome);
3988                }
3989            }
3990            // Pass 2 — persist: message saves COLLECT into batched transactions; deletes are
3991            // flush barriers (a save committing after a delete it preceded on the wire would
3992            // resurrect the deleted row). One tx per page in the common no-delete case.
3993            let mut pending: Vec<&crate::types::Message> = Vec::new();
3994            for outcome in &outcomes {
3995                if !session.is_live() {
3996                    pending.clear();
3997                    break;
3998                }
3999                match outcome {
4000                    ChatPersist::New(m) => pending.push(m),
4001                    ChatPersist::Updated { message, edit_event } => match edit_event {
4002                        Some(ev) => {
4003                            let mut ev = (**ev).clone();
4004                            // get-or-CREATE: a lookup-only id would leave a fresh channel's edit at
4005                            // chat_id 0 (orphaned, dropped on the reload fold).
4006                            if let Ok(cid) = crate::db::id_cache::get_or_create_chat_id(channel_id) {
4007                                ev.chat_id = cid;
4008                            }
4009                            let _ = crate::db::events::save_event(&ev).await;
4010                        }
4011                        None => pending.push(message),
4012                    },
4013                    ChatPersist::Removed(target_id) => {
4014                        crate::db::events::flush_message_batch(channel_id, &mut pending, &session).await;
4015                        let _ = crate::db::events::delete_event(target_id).await;
4016                    }
4017                    ChatPersist::ReactionRemoved { reaction_id, message } => {
4018                        crate::db::events::flush_message_batch(channel_id, &mut pending, &session).await;
4019                        let _ = crate::db::events::delete_event(reaction_id).await;
4020                        pending.push(message);
4021                    }
4022                }
4023            }
4024            crate::db::events::flush_message_batch(channel_id, &mut pending, &session).await;
4025            drop(pending);
4026            // Resolve each reply's quote before the emit: the renderer has no other source
4027            // for a parent outside its window and never retries. Placed AFTER the persists
4028            // so a parent carried by this same page resolves too. One query per page.
4029            {
4030                let quoted: Vec<&mut crate::types::Message> = outcomes
4031                    .iter_mut()
4032                    .filter_map(|o| match o {
4033                        ChatPersist::New(m)
4034                        | ChatPersist::Updated { message: m, .. }
4035                        | ChatPersist::ReactionRemoved { message: m, .. } => Some(m),
4036                        ChatPersist::Removed(_) => None,
4037                    })
4038                    .collect();
4039                let _ = crate::db::events::populate_reply_contexts(quoted).await;
4040            }
4041            // Pass 3 — surface to the live UI, mirroring v1's sweep + the live dispatch handler:
4042            // a silent DB-only backfill left the chat-list preview, unread badge, and sort order
4043            // stale until the channel was opened. Raw emits (no notification ping) — a boot
4044            // catch-up must not fire an OS ping per message. Headless consumers register no
4045            // emitter, so these are a no-op there. After the persists so nothing surfaces unsaved.
4046            for outcome in &outcomes {
4047                match outcome {
4048                    ChatPersist::New(msg) => crate::traits::emit_event(
4049                        "message_new",
4050                        &serde_json::json!({ "message": msg, "chat_id": channel_id }),
4051                    ),
4052                    ChatPersist::Updated { message, .. }
4053                    | ChatPersist::ReactionRemoved { message, .. } => {
4054                        let mut message = message.clone();
4055                        let target_id = message.id.clone();
4056                        crate::traits::emit_message_update(channel_id, &target_id, &mut message).await;
4057                    }
4058                    ChatPersist::Removed(target_id) => crate::traits::emit_event(
4059                        "message_removed",
4060                        &serde_json::json!({ "id": target_id, "chat_id": channel_id, "reason": "deleted" }),
4061                    ),
4062                }
4063            }
4064            new
4065        })
4066        .await
4067    }
4068
4069    /// The held v2 community when `community_id` names one; `Ok(None)` for v1 (or
4070    /// unknown). A DB read error PROPAGATES (fail-closed) instead of falling open
4071    /// to the v1 route on a transient failure.
4072    fn load_v2_if_v2(community_id: &str) -> Result<Option<crate::community::v2::community::CommunityV2>> {
4073        if community_id.len() != 64 {
4074            return Ok(None);
4075        }
4076        let cid = crate::community::CommunityId(crate::simd::hex::hex_to_bytes_32(community_id));
4077        match crate::db::community::community_protocol(&cid).map_err(VectorError::Other)? {
4078            Some(crate::community::ConcordProtocol::V2) => crate::db::community::load_community_v2(&cid).map_err(VectorError::Other),
4079            _ => Ok(None),
4080        }
4081    }
4082
4083    // ── Community admin actions ── role-gated; vector-core re-checks authority on every action and peers
4084    // re-verify against the owner-rooted roster, so these can't forge standing. A bunker account can't ban
4085    // in a private community (the rekey needs a raw local key).
4086
4087    fn load_community_hex(community_id: &str) -> Result<crate::community::Community> {
4088        use crate::community::CommunityId;
4089        if community_id.len() != 64 {
4090            return Err(VectorError::Other("malformed community id".into()));
4091        }
4092        crate::db::community::load_community(&CommunityId(crate::simd::hex::hex_to_bytes_32(community_id)))
4093            .map_err(VectorError::Other)?
4094            .ok_or_else(|| VectorError::Other("community not found".into()))
4095    }
4096
4097    fn admin_role_id_of(community_id: &str) -> Result<String> {
4098        let roles = crate::db::community::get_community_roles(community_id).map_err(VectorError::Other)?;
4099        // Founding mask, not ADMIN_ALL: identifies Admin roles published before
4100        // later bits (PIN_MESSAGES...) existed.
4101        roles.roles.iter()
4102            .find(|r| matches!(r.scope, crate::community::roles::RoleScope::Server)
4103                && r.permissions.contains(crate::community::roles::Permissions::ADMIN_FOUNDING_MASK))
4104            .map(|r| r.role_id.clone())
4105            .ok_or_else(|| VectorError::Other("admin role not found (roster not synced?)".into()))
4106    }
4107
4108    /// My effective management capabilities in a community (role engine — owner is just position 0). Use to
4109    /// confirm a promotion/demotion landed. A local read: the roster is folded + persisted by the passive
4110    /// sync (v1) / control follow (v2), never fetched here.
4111    pub fn community_capabilities(&self, community_id: &str) -> Result<serde_json::Value> {
4112        use crate::community::service;
4113        if let Some(v2) = Self::load_v2_if_v2(community_id)? {
4114            use crate::community::roles::Permissions;
4115            let me = state::my_public_key().ok_or_else(|| VectorError::Other("Not logged in".into()))?.to_hex();
4116            let owner_hex = v2.owner().map_err(VectorError::Other)?.to_hex();
4117            let roster = crate::db::community::get_community_roles(community_id).map_err(VectorError::Other)?;
4118            // A banned member holds no standing (CORD-04 §4), even if a since-skipped
4119            // roster persist still lists their grant — the banlist advances on its own gate.
4120            let banned = crate::db::community::get_community_banlist(community_id).unwrap_or_default();
4121            if banned.contains(&me) && me != owner_hex {
4122                return Ok(serde_json::json!({
4123                    "manage_metadata": false, "manage_channels": false, "create_invite": false, "kick": false,
4124                    "ban": false, "manage_messages": false, "manage_roles": false, "manage_admin_role": false,
4125                    "pin_messages": false, "control_write": false,
4126                }));
4127            }
4128            // Actions that land as Control editions additionally need the plane's
4129            // write key (CORD-02 §2): a role-authorized staffer whose control_wrap
4130            // hasn't arrived yet factually cannot publish, and an honest false here
4131            // beats a bot retry-looping a publish every reader drops. KICK
4132            // (Guestbook) and MANAGE_MESSAGES (Chat planes) never need it.
4133            let control_write = crate::community::v2::control::ControlPlane::of(&v2).can_write();
4134            let has = |p: u64| roster.is_authorized(&me, Some(&owner_hex), p);
4135            let has_ctl = |p: u64| control_write && has(p);
4136            return Ok(serde_json::json!({
4137                "manage_metadata": has_ctl(Permissions::MANAGE_METADATA), "manage_channels": has_ctl(Permissions::MANAGE_CHANNELS),
4138                "create_invite": has_ctl(Permissions::CREATE_INVITE), "kick": has(Permissions::KICK), "ban": has_ctl(Permissions::BAN),
4139                "manage_messages": has(Permissions::MANAGE_MESSAGES), "manage_roles": has_ctl(Permissions::MANAGE_ROLES),
4140                // Only the owner (position 0) strictly outranks the position-1 Admin role.
4141                "manage_admin_role": me == owner_hex && control_write,
4142                "pin_messages": has_ctl(Permissions::PIN_MESSAGES),
4143                "control_write": control_write,
4144            }));
4145        }
4146        let community = Self::load_community_hex(community_id)?;
4147        let caps = service::caller_capabilities(&community);
4148        let manage_admin_role = Self::admin_role_id_of(community_id).ok()
4149            .map(|rid| service::caller_can_manage_role_id(&community, &rid))
4150            .unwrap_or(false);
4151        Ok(serde_json::json!({
4152            "manage_metadata": caps.manage_metadata, "manage_channels": caps.manage_channels,
4153            "create_invite": caps.create_invite, "kick": caps.kick, "ban": caps.ban,
4154            "manage_messages": caps.manage_messages, "manage_roles": caps.manage_roles,
4155            "manage_admin_role": manage_admin_role,
4156            // Pins are Concord v2 only (CORD-04 §7) — v1 has no control-plane entity for them.
4157            "pin_messages": false,
4158        }))
4159    }
4160
4161    /// Pin a message in a v2 community channel (CORD-04 §7). The proof is
4162    /// rebuilt from the message's recovered wrap; see `service::pin_message`.
4163    pub async fn pin_community_message(&self, community_id: &str, channel_id: &str, message_id: &str) -> Result<()> {
4164        use crate::community::{transport::LiveTransport, ChannelId};
4165        let v2 = Self::load_v2_if_v2(community_id)?
4166            .ok_or_else(|| VectorError::Other("pins are only available in Concord v2 communities".into()))?;
4167        let ch = ChannelId(crate::simd::hex::hex_to_bytes_32(channel_id));
4168        let transport = LiveTransport::with_timeout(std::time::Duration::from_secs(12));
4169        crate::community::v2::service::pin_message(&transport, &v2, &ch, message_id)
4170            .await
4171            .map_err(VectorError::Other)
4172    }
4173
4174    /// Unpin a message: the next Pin List edition without the entry.
4175    pub async fn unpin_community_message(&self, community_id: &str, channel_id: &str, message_id: &str) -> Result<()> {
4176        use crate::community::{transport::LiveTransport, ChannelId};
4177        let v2 = Self::load_v2_if_v2(community_id)?
4178            .ok_or_else(|| VectorError::Other("pins are only available in Concord v2 communities".into()))?;
4179        let ch = ChannelId(crate::simd::hex::hex_to_bytes_32(channel_id));
4180        let transport = LiveTransport::with_timeout(std::time::Duration::from_secs(12));
4181        crate::community::v2::service::unpin_message(&transport, &v2, &ch, message_id)
4182            .await
4183            .map_err(VectorError::Other)
4184    }
4185
4186    /// Fetch a pinned attachment FROM THE PIN ALONE (CORD-04 §7's whole point:
4187    /// the proof carries the rumor, whose imeta carries the blob URL and the
4188    /// file's decryption material — no chat history and no old epoch key
4189    /// needed). Serves the already-downloaded copy when its content hash
4190    /// verifies; otherwise runs the full download walk (mirrors + hash-swap),
4191    /// decrypts, verifies, and caches at the same content-addressed path the
4192    /// chat pipeline uses. Returns `{ path, mime, name }`.
4193    pub async fn fetch_pinned_attachment(
4194        &self,
4195        community_id: &str,
4196        channel_id: &str,
4197        rumor_id: &str,
4198    ) -> Result<serde_json::Value> {
4199        crate::db::scoped(async move {
4200            use crate::community::ChannelId;
4201            let v2 = Self::load_v2_if_v2(community_id)?
4202                .ok_or_else(|| VectorError::Other("pins are only available in Concord v2 communities".into()))?;
4203            let ch = ChannelId(crate::simd::hex::hex_to_bytes_32(channel_id));
4204            let pins = crate::community::v2::service::read_channel_pins(&v2, &ch).map_err(VectorError::Other)?;
4205            let pin = pins
4206                .pins
4207                .iter()
4208                .find(|p| p.rumor_id == rumor_id)
4209                .ok_or_else(|| VectorError::Other("that message is not pinned".into()))?;
4210            let dir = crate::db::get_download_dir();
4211            let tag = pin
4212                .tags
4213                .iter()
4214                .find(|t| t.first().map(String::as_str) == Some("imeta"))
4215                .map(|t| nostr_sdk::prelude::Tag::custom("imeta", t[1..].to_vec()))
4216                .ok_or_else(|| VectorError::Other("this pin carries no attachment".into()))?;
4217            let attachment = crate::community::attachments::attachment_from_imeta(&tag, &dir)
4218                .ok_or_else(|| VectorError::Other("this pin's attachment metadata is malformed".into()))?;
4219
4220            let respond = |path: &std::path::Path| {
4221                serde_json::json!({
4222                    "path": path.to_string_lossy(),
4223                    "name": attachment.name.to_string(),
4224                    "extension": attachment.extension.to_string(),
4225                })
4226            };
4227
4228            // The author-committed content address (the pin verified the rumor, so
4229            // `ox` carries the author's signature-weight claim — the same trust the
4230            // chat pipeline extends). Reuse and fresh downloads both verify it.
4231            let expected = attachment.original_hash.as_deref();
4232            let path = std::path::PathBuf::from(&*attachment.path);
4233            if let Ok(bytes) = std::fs::read(&path) {
4234                match expected {
4235                    Some(want) if crate::crypto::sha256_hex(&bytes) == want => return Ok(respond(&path)),
4236                    None => return Ok(respond(&path)),
4237                    _ => {} // stale or foreign bytes at the path: re-download
4238                }
4239            }
4240
4241            let author_npub = nostr_sdk::prelude::PublicKey::from_hex(&pin.author)
4242                .ok()
4243                .and_then(|pk| nostr_sdk::prelude::ToBech32::to_bech32(&pk).ok());
4244            let bytes = self.download_attachment_from(&attachment, author_npub.as_deref()).await?;
4245            if let Some(want) = expected {
4246                if crate::crypto::sha256_hex(&bytes) != want {
4247                    return Err(VectorError::Other("downloaded bytes do not match the pinned content hash".into()));
4248                }
4249            }
4250            std::fs::write(&path, &bytes).map_err(|e| VectorError::Other(format!("could not cache the attachment: {e}")))?;
4251            Ok(respond(&path))
4252        })
4253        .await
4254    }
4255
4256    /// A channel's verified pins from the locally folded head — local-only read.
4257    /// `sealed: true` means the list exists but is unreadable on this device;
4258    /// render it as unavailable, never as empty.
4259    pub fn get_channel_pins(&self, community_id: &str, channel_id: &str) -> Result<serde_json::Value> {
4260        use crate::community::ChannelId;
4261        let Some(v2) = Self::load_v2_if_v2(community_id)? else {
4262            // v1 channels simply have no pins — an empty read, not an error, so
4263            // one frontend path serves both stacks.
4264            return Ok(serde_json::json!({ "pins": [], "sealed": false, "version": 0 }));
4265        };
4266        let ch = ChannelId(crate::simd::hex::hex_to_bytes_32(channel_id));
4267        let pins = crate::community::v2::service::read_channel_pins(&v2, &ch).map_err(VectorError::Other)?;
4268        serde_json::to_value(&pins).map_err(|e| VectorError::Other(e.to_string()))
4269    }
4270
4271    /// The community's owner npub + the admin npubs (role overview). A local read,
4272    /// like [`Self::community_capabilities`].
4273    /// The whole role graph: every role with its position and colour, and who
4274    /// holds what.
4275    ///
4276    /// `community_roles` above answers only "who is an admin", which is all the
4277    /// crown ever needed. A member list that groups people by rank needs the
4278    /// hierarchy itself, and npubs rather than hex so it can match a roster it
4279    /// already holds.
4280    pub fn community_role_graph(&self, community_id: &str) -> Result<serde_json::Value> {
4281        use nostr_sdk::prelude::{PublicKey, ToBech32};
4282        let roster = crate::db::community::get_community_roles(community_id).map_err(VectorError::Other)?;
4283        // A banned member is gone from every read (§4), roles included.
4284        let banned = crate::db::community::get_community_banlist(community_id).unwrap_or_default();
4285        let roles: Vec<serde_json::Value> = roster
4286            .roles
4287            .iter()
4288            .map(|r| {
4289                serde_json::json!({
4290                    "role_id": r.role_id,
4291                    "name": r.name,
4292                    "position": r.position,
4293                    "color": r.color,
4294                })
4295            })
4296            .collect();
4297        let grants: Vec<serde_json::Value> = roster
4298            .grants
4299            .iter()
4300            .filter(|g| !banned.contains(&g.member))
4301            .filter_map(|g| {
4302                let npub = PublicKey::from_hex(&g.member).ok().and_then(|pk| pk.to_bech32().ok())?;
4303                Some(serde_json::json!({ "npub": npub, "role_ids": g.role_ids }))
4304            })
4305            .collect();
4306        Ok(serde_json::json!({ "roles": roles, "grants": grants }))
4307    }
4308
4309    /// Whether `npub` holds `permission` in this community, per the folded roster.
4310    ///
4311    /// The roster IS the ACL (CORD-04), so this is the same question every
4312    /// enforcement path asks — a bot gating a chat command on it refuses exactly
4313    /// what the protocol would, instead of inventing a second permission model
4314    /// that can disagree with the first.
4315    ///
4316    /// Deliberately NOT [`community_capabilities`]: that answers "could I publish
4317    /// this", which additionally needs the Control Plane write key. Here the
4318    /// caller is asking *someone else* for authority while the bot does the
4319    /// publishing, so the write key is the bot's problem, not theirs.
4320    ///
4321    /// Fails CLOSED. A banned member holds nothing; an unreadable or not-yet-folded
4322    /// roster answers `false` for everyone but the owner, because `has_permission`
4323    /// finds no grant. Authority must never be granted by an absence of data.
4324    pub fn member_has_permission(&self, community_id: &str, npub: &str, permission: u64) -> Result<bool> {
4325        use nostr_sdk::prelude::PublicKey;
4326        let who = PublicKey::parse(npub).map_err(|_| VectorError::Other(format!("invalid npub: {npub}")))?.to_hex();
4327        let owner_hex = match Self::load_v2_if_v2(community_id)? {
4328            Some(v2) => v2.owner().map_err(VectorError::Other)?.to_hex(),
4329            None => Self::load_community_hex(community_id)?
4330                .owner_attestation
4331                .as_ref()
4332                .and_then(|att| crate::community::owner::verify_owner_attestation(att, community_id))
4333                .map(|pk| pk.to_hex())
4334                .unwrap_or_default(),
4335        };
4336        if who != owner_hex && crate::db::community::get_community_banlist(community_id).unwrap_or_default().contains(&who) {
4337            return Ok(false);
4338        }
4339        let roster = crate::db::community::get_community_roles(community_id).map_err(VectorError::Other)?;
4340        Ok(roster.is_authorized(&who, Some(&owner_hex), permission))
4341    }
4342
4343    pub fn community_roles(&self, community_id: &str) -> Result<serde_json::Value> {
4344        use nostr_sdk::prelude::{PublicKey, ToBech32};
4345        if let Some(v2) = Self::load_v2_if_v2(community_id)? {
4346            let owner = v2.owner().map_err(VectorError::Other)?;
4347            let roster = crate::db::community::get_community_roles(community_id).map_err(VectorError::Other)?;
4348            // Exclude banned members from the admin list (a banned npub vanishes, §4).
4349            let banned = crate::db::community::get_community_banlist(community_id).unwrap_or_default();
4350            let admins: Vec<String> = roster.grants.iter()
4351                .filter(|g| roster.is_admin(&g.member) && !banned.contains(&g.member))
4352                .filter_map(|g| PublicKey::from_hex(&g.member).ok().and_then(|pk| pk.to_bech32().ok()))
4353                .collect();
4354            return Ok(serde_json::json!({ "owner": owner.to_bech32().ok(), "admins": admins }));
4355        }
4356        let community = Self::load_community_hex(community_id)?;
4357        let owner = community.owner_attestation.as_ref()
4358            .and_then(|att| crate::community::owner::verify_owner_attestation(att, &community.id.to_hex()))
4359            .and_then(|pk| ToBech32::to_bech32(&pk).ok());
4360        let roles = crate::db::community::get_community_roles(community_id).map_err(VectorError::Other)?;
4361        let admins: Vec<String> = roles.grants.iter().filter(|g| roles.is_admin(&g.member))
4362            .filter_map(|g| PublicKey::from_hex(&g.member).ok().and_then(|pk| pk.to_bech32().ok()))
4363            .collect();
4364        Ok(serde_json::json!({ "owner": owner, "admins": admins }))
4365    }
4366
4367    /// Fold the v2 control plane back in right after publishing an authority change, so the
4368    /// LOCAL roster/banlist — which is what every read is served from (crowns, in-chat tags,
4369    /// capabilities, moderation gates) — is current by the time the call returns. `publish`
4370    /// only returns once a relay ACKed, so this refetch sees our own edition; the fold
4371    /// announces `community_refreshed` itself when the roster actually moved.
4372    ///
4373    /// Best-effort: the edition is already published, so a failed refold is a stale local
4374    /// cache the next follow repairs, never a failed action.
4375    async fn converge_v2_authority(
4376        transport: &crate::community::transport::LiveTransport,
4377        community_id: &str,
4378    ) {
4379        crate::db::scoped(async move {
4380            // Reload rather than reuse the caller's clone: the publish advanced edition floors,
4381            // and a rekey/refound may have moved the control address under us.
4382            if let Ok(Some(fresh)) = Self::load_v2_if_v2(community_id) {
4383                let _ = crate::community::v2::service::follow_control(transport, &fresh).await;
4384                // Membership is part of the view being converged: an unban must
4385                // re-fetch the Guestbook, because a Join that legally raced the ban
4386                // window may exist only on the relays — and our own just-published
4387                // edition doesn't echo back to trigger a follow.
4388                if let Ok(added) = crate::community::v2::service::sync_guestbook(transport, &fresh).await {
4389                    if !added.is_empty() {
4390                        traits::emit_event_json(
4391                            "community_refreshed",
4392                            serde_json::json!({ "community_id": community_id }),
4393                        );
4394                    }
4395                }
4396            }
4397        })
4398        .await
4399    }
4400
4401    /// Grant a member the @admin role. Requires MANAGE_ROLES + outranking the role's position.
4402    pub async fn grant_admin(&self, community_id: &str, npub: &str) -> Result<()> {
4403        use crate::community::{service, transport::LiveTransport};
4404        let member = nostr_sdk::prelude::PublicKey::parse(npub).map_err(|_| VectorError::Other("invalid npub".into()))?;
4405        let transport = LiveTransport::with_timeout(std::time::Duration::from_secs(12));
4406        if let Some(v2) = Self::load_v2_if_v2(community_id)? {
4407            crate::community::v2::service::grant_admin(&transport, &v2, &member)
4408                .await
4409                .map_err(VectorError::Other)?;
4410            Self::converge_v2_authority(&transport, community_id).await;
4411            return Ok(());
4412        }
4413        let community = Self::load_community_hex(community_id)?;
4414        let role_id = Self::admin_role_id_of(community_id)?;
4415        service::grant_role(&transport, &community, member, &role_id).await.map_err(VectorError::Other)
4416    }
4417
4418    /// Revoke a member's @admin role.
4419    pub async fn revoke_admin(&self, community_id: &str, npub: &str) -> Result<()> {
4420        use crate::community::{service, transport::LiveTransport};
4421        let member = nostr_sdk::prelude::PublicKey::parse(npub).map_err(|_| VectorError::Other("invalid npub".into()))?;
4422        let transport = LiveTransport::with_timeout(std::time::Duration::from_secs(12));
4423        if let Some(v2) = Self::load_v2_if_v2(community_id)? {
4424            crate::community::v2::service::revoke_admin(&transport, &v2, &member)
4425                .await
4426                .map_err(VectorError::Other)?;
4427            Self::converge_v2_authority(&transport, community_id).await;
4428            return Ok(());
4429        }
4430        let community = Self::load_community_hex(community_id)?;
4431        let role_id = Self::admin_role_id_of(community_id)?;
4432        service::revoke_role(&transport, &community, member, &role_id).await.map_err(VectorError::Other)
4433    }
4434
4435    /// Cooperatively kick a member — they self-remove but can rejoin. Requires KICK + outrank.
4436    pub async fn kick_member(&self, community_id: &str, npub: &str) -> Result<()> {
4437        crate::db::scoped(async move {
4438            use crate::community::{service, transport::LiveTransport};
4439            let pk = nostr_sdk::prelude::PublicKey::parse(npub).map_err(|_| VectorError::Other("invalid npub".into()))?;
4440            let transport = LiveTransport::with_timeout(std::time::Duration::from_secs(12));
4441            if let Some(v2) = Self::load_v2_if_v2(community_id)? {
4442                crate::community::v2::service::kick_member(&transport, &v2, &pk)
4443                    .await
4444                    .map_err(VectorError::Other)?;
4445                // Catch the local Guestbook up on our own Kick, so the memberlist read
4446                // (which folds the STORE, not the network) drops them before this returns
4447                // instead of waiting on the relay echo. The control fold follows because a
4448                // Kick strips roles first (CORD-04 §6), which moves the roster too.
4449                if let Ok(fresh) = crate::community::v2::service::sync_guestbook(&transport, &v2).await {
4450                    if !fresh.is_empty() {
4451                        emit_event("community_refreshed", &serde_json::json!({ "community_id": community_id }));
4452                    }
4453                }
4454                Self::converge_v2_authority(&transport, community_id).await;
4455                return Ok(());
4456            }
4457            let community = Self::load_community_hex(community_id)?;
4458            let channel = community.channels.first().ok_or_else(|| VectorError::Other("community has no channel".into()))?;
4459            service::publish_kick(&transport, &community, channel, &pk.to_hex()).await.map(|_| ()).map_err(VectorError::Other)
4460        })
4461        .await
4462    }
4463
4464    /// Ban (`true`) or unban (`false`) a member. Ban is terminal (no rejoin). The read cut:
4465    /// a private community re-founds (full rotation); a public one rotates just the private
4466    /// channels the member could read (CORD-05 §5 — no refound, the link refresh would
4467    /// re-hand them the root). Requires BAN + outrank, checked against the folded roster
4468    /// BEFORE any publish.
4469    pub async fn set_member_banned(&self, community_id: &str, npub: &str, banned: bool) -> Result<()> {
4470        self.set_members_banned(community_id, &[npub], banned).await
4471    }
4472
4473    /// Ban (`true`) or unban (`false`) a whole batch as ONE moderation unit: one
4474    /// Banlist edition, one grant-strip pass, one read cut — a private community
4475    /// re-founds ONCE with every target removed, however many there are. Additive
4476    /// on ban, reductive on unban, over the freshest folded list either way.
4477    ///
4478    /// Serialized per community (with every other ban/unban) so concurrent
4479    /// moderation composes in arrival order instead of the last write erasing
4480    /// its siblings. The wire caps a Banlist at 500 entries — a batch that would
4481    /// exceed it fails before anything publishes.
4482    pub async fn set_members_banned(&self, community_id: &str, npubs: &[&str], banned: bool) -> Result<()> {
4483        use crate::community::{service, transport::LiveTransport, CommunityId};
4484        if npubs.is_empty() {
4485            return Ok(());
4486        }
4487        // Parse every target up front — one bad npub fails the batch before any publish.
4488        let mut pks: Vec<nostr_sdk::prelude::PublicKey> = Vec::with_capacity(npubs.len());
4489        for n in npubs {
4490            let pk = nostr_sdk::prelude::PublicKey::parse(n).map_err(|_| VectorError::Other(format!("invalid npub: {n}")))?;
4491            if !pks.contains(&pk) {
4492                pks.push(pk);
4493            }
4494        }
4495        let transport = LiveTransport::with_timeout(std::time::Duration::from_secs(12));
4496        // Dual-stack: a v2 Ban is the CORD-04 §6 three-removal composition, in order —
4497        // the Banlist edition first (instant silence), then the Grant strip (authority
4498        // removal), then the Refounding read-cut (cryptographic severance).
4499        if community_id.len() == 64 {
4500            let cid = CommunityId(crate::simd::hex::hex_to_bytes_32(community_id));
4501            // A transient protocol-read failure must fail the ban loudly: flattened
4502            // to "not v2" it reroutes onto the v1 list no v2 reader folds — an Ok
4503            // that banned no one.
4504            if let Some(crate::community::ConcordProtocol::V2) = crate::db::community::community_protocol(&cid).map_err(VectorError::Other)? {
4505                return crate::community::v2::service::set_members_banned(&transport, &cid, &pks, banned)
4506                    .await
4507                    .map_err(VectorError::Other);
4508            }
4509        }
4510        // v1: same latest-wins mutation over the held list.
4511        let hexes: Vec<String> = pks.iter().map(|p| p.to_hex()).collect();
4512        let mut list = crate::db::community::get_community_banlist(community_id).map_err(VectorError::Other)?;
4513        list.retain(|h| !hexes.contains(h));
4514        if banned {
4515            list.extend(hexes);
4516        }
4517        let community = Self::load_community_hex(community_id)?;
4518        service::publish_banlist(&transport, &community, &list).await.map_err(VectorError::Other)?;
4519        // A ban removes them from the memberlist; the memoised verdict still counts
4520        // them as present and keeps flagging a raid nobody is running any more.
4521        Self::invalidate_raid_report(community_id);
4522        Ok(())
4523    }
4524
4525    /// Re-vend the epoch chain to members a past rotation forgot.
4526    ///
4527    /// A client follows rekeys hop by hop and can only ever derive `held + 1`, so
4528    /// a member missed at one hop is stranded there permanently — no later
4529    /// rotation can reach them, and re-inviting does not help because they are
4530    /// already a member. This publishes the blobs that should have existed, keyed
4531    /// exactly like the original rotation so they merge into it.
4532    ///
4533    /// Requires rotation authority (owner or BAN) and the archived roots, which
4534    /// the rotator holds. Publishing nothing else and touching no membership.
4535    pub async fn rescue_stranded_members(&self, community_id: &str, npubs: &[&str]) -> Result<serde_json::Value> {
4536        use nostr_sdk::prelude::PublicKey;
4537        let community = Self::load_v2_if_v2(community_id)?
4538            .ok_or_else(|| VectorError::Other("rescuing requires a Concord v2 community".into()))?;
4539        let mut targets: Vec<PublicKey> = Vec::with_capacity(npubs.len());
4540        for n in npubs {
4541            let pk = PublicKey::parse(n).map_err(|_| VectorError::Other(format!("invalid npub: {n}")))?;
4542            if !targets.contains(&pk) {
4543                targets.push(pk);
4544            }
4545        }
4546        let transport = crate::community::transport::LiveTransport::with_timeout(std::time::Duration::from_secs(30));
4547        let report = crate::community::v2::service::rescue_stranded_members(&transport, &community, &targets)
4548            .await
4549            .map_err(VectorError::Other)?;
4550        serde_json::to_value(report).map_err(|e| VectorError::Other(e.to_string()))
4551    }
4552
4553    /// Repair a v2 community's roster cache: keep what is stored, but reset its
4554    /// PROVENANCE to zero so the next control fold is allowed to replace it.
4555    ///
4556    /// The completeness guard retains the cached roster whenever a stored entity is
4557    /// floored but folds no head — protection against a relay quietly stripping
4558    /// admins. If the cache itself is wrong, that same guard pins the wrong data in
4559    /// place: the fold can see the real roster and still be refused. Zeroing the
4560    /// provenance marks the cache a seed — a guess, not evidence — which the fold may
4561    /// overwrite. It publishes nothing and touches no key material; worst case the
4562    /// next fold writes what it can see, which is what a fresh join would get.
4563    pub async fn repair_community_roster(&self, community_id: &str) -> Result<serde_json::Value> {
4564        use crate::community::CommunityId;
4565        if community_id.len() != 64 {
4566            return Err(VectorError::Other("malformed community id".into()));
4567        }
4568        let cid = CommunityId(crate::simd::hex::hex_to_bytes_32(community_id));
4569        if crate::db::community::load_community_v2(&cid).map_err(VectorError::Other)?.is_none() {
4570            return Err(VectorError::Other("not a held Concord v2 community".into()));
4571        }
4572        let before = crate::db::community::get_community_roles(community_id).map_err(VectorError::Other)?;
4573        let before_at = crate::db::community::get_community_roles_at(community_id).map_err(VectorError::Other)?;
4574        crate::db::community::set_community_roles(community_id, &before, 0).map_err(VectorError::Other)?;
4575        // Fold immediately so the repair is one step, not two.
4576        let transport = crate::community::transport::LiveTransport::with_timeout(std::time::Duration::from_secs(20));
4577        let community = crate::db::community::load_community_v2(&cid)
4578            .map_err(VectorError::Other)?
4579            .ok_or_else(|| VectorError::Other("community vanished mid-repair".into()))?;
4580        let follow = crate::community::v2::service::follow_control(&transport, &community).await;
4581        let after = crate::db::community::get_community_roles(community_id).map_err(VectorError::Other)?;
4582        Ok(serde_json::json!({
4583            "provenance_before": before_at,
4584            "provenance_after": crate::db::community::get_community_roles_at(community_id).unwrap_or(0),
4585            "roles_before": before.roles.len(),
4586            "grants_before": before.grants.len(),
4587            "roles_after": after.roles.len(),
4588            "grants_after": after.grants.len(),
4589            "fold_error": follow.as_ref().err().cloned(),
4590            "repaired": after.grants.len() > before.grants.len(),
4591        }))
4592    }
4593
4594    /// Explain this account's epoch state for a v2 community: what it holds, what
4595    /// the next-epoch rekey plane offers, and why each rotation there was or was not
4596    /// adopted.
4597    ///
4598    /// This is the answer to "is this client stranded, and why" — a question that is
4599    /// otherwise unanswerable, because a stranded client sees only silence and
4600    /// silence looks exactly like a quiet community. Run it from two clients on one
4601    /// community to compare their views; the `held` block is where they diverge
4602    /// first. Read-only; returns public keys and verdicts, never key material.
4603    pub async fn explain_epoch_state(&self, community_id: &str) -> Result<serde_json::Value> {
4604        use crate::community::{transport::LiveTransport, CommunityId};
4605        if community_id.len() != 64 {
4606            return Err(VectorError::Other("malformed community id".into()));
4607        }
4608        let cid = CommunityId(crate::simd::hex::hex_to_bytes_32(community_id));
4609        let community = crate::db::community::load_community_v2(&cid)
4610            .map_err(VectorError::Other)?
4611            .ok_or_else(|| VectorError::Other("not a held Concord v2 community".into()))?;
4612        let transport = LiveTransport::with_timeout(std::time::Duration::from_secs(15));
4613        crate::community::v2::service::explain_epoch_state(&transport, &community)
4614            .await
4615            .map_err(VectorError::Other)
4616    }
4617
4618    /// Contain a raid (v2 only): ban the named raiders, cut everyone who joined
4619    /// through the door during the raid window, and perform a SEVERING Refounding
4620    /// — the rotation that REVOKES the public invite links instead of carrying
4621    /// them into the new epoch.
4622    ///
4623    /// This is the raid lane. An ordinary ban keeps its shipped behaviour, where a
4624    /// Public community deliberately does not roll the root (the stable-URL refresh
4625    /// would undo it). Here the link IS the attack surface, so it closes.
4626    ///
4627    /// `window_start_secs` is the raid's opening edge (unix seconds); pass 0 to cut
4628    /// only the convicted. Returns an honest report — a failed refound is reported,
4629    /// never dressed up as a closed door.
4630    pub async fn contain_raid(
4631        &self,
4632        community_id: &str,
4633        npubs: &[&str],
4634        window_start_secs: u64,
4635        cut_also: &[&str],
4636    ) -> Result<crate::community::v2::service::ContainmentReport> {
4637        use crate::community::{transport::LiveTransport, CommunityId};
4638        if community_id.len() != 64 {
4639            return Err(VectorError::Other("malformed community id".into()));
4640        }
4641        let mut pks: Vec<nostr_sdk::prelude::PublicKey> = Vec::with_capacity(npubs.len());
4642        for n in npubs {
4643            let pk = nostr_sdk::prelude::PublicKey::parse(n).map_err(|_| VectorError::Other(format!("invalid npub: {n}")))?;
4644            if !pks.contains(&pk) {
4645                pks.push(pk);
4646            }
4647        }
4648        let mut extra: Vec<nostr_sdk::prelude::PublicKey> = Vec::with_capacity(cut_also.len());
4649        for n in cut_also {
4650            let pk = nostr_sdk::prelude::PublicKey::parse(n).map_err(|_| VectorError::Other(format!("invalid npub: {n}")))?;
4651            if !extra.contains(&pk) {
4652                extra.push(pk);
4653            }
4654        }
4655        let cid = CommunityId(crate::simd::hex::hex_to_bytes_32(community_id));
4656        match crate::db::community::community_protocol(&cid).map_err(VectorError::Other)? {
4657            Some(crate::community::ConcordProtocol::V2) => {}
4658            // v1 has no severing rotation and no per-creator link custody; routing a
4659            // containment there would silently do something else entirely.
4660            _ => return Err(VectorError::Other("raid containment requires a Concord v2 community".into())),
4661        }
4662        // Unbound caller (SDK/command): a swap mid-containment would ban in one
4663        // account and rotate in another.
4664        let session = crate::db::current_session();
4665        let transport = LiveTransport::with_timeout(std::time::Duration::from_secs(20));
4666        let report = crate::community::v2::service::contain_raid(&transport, &cid, &pks, window_start_secs, &extra)
4667            .await
4668            .map_err(VectorError::Other)?;
4669        if !session.is_live() {
4670            return Err(VectorError::Other("account changed during raid containment".into()));
4671        }
4672        Self::invalidate_raid_report(community_id);
4673        Ok(report)
4674    }
4675
4676    /// Owner dissolution / "Delete Community": publish the terminal GroupDissolved tombstone (and
4677    /// retire the owner's own invite links, no rekey), sealing the community permanently. Owner-only
4678    /// (re-verified cryptographically in `service::dissolve_community`); irreversible.
4679    pub async fn dissolve_community(&self, community_id: &str) -> Result<()> {
4680        use crate::community::{service, transport::LiveTransport, CommunityId};
4681        if community_id.len() != 64 {
4682            return Err(VectorError::Other("malformed community id".into()));
4683        }
4684        let cid = CommunityId(crate::simd::hex::hex_to_bytes_32(community_id));
4685        let transport = LiveTransport::with_timeout(std::time::Duration::from_secs(12));
4686        // Dual-stack: a v2 community dissolves at its own `community_id`-derived
4687        // dissolved plane (CORD-02 §9), NOT v1's control-plane roster edition.
4688        if let Some(Some(crate::community::ConcordProtocol::V2)) = crate::db::community::community_protocol(&cid).ok() {
4689            let community = crate::db::community::load_community_v2(&cid)
4690                .map_err(VectorError::Other)?
4691                .ok_or_else(|| VectorError::Other("v2 community not found".into()))?;
4692            return crate::community::v2::service::dissolve_community(&transport, &community)
4693                .await
4694                .map_err(VectorError::Other);
4695        }
4696        let community = Self::load_community_hex(community_id)?;
4697        service::dissolve_community(&transport, &community).await.map_err(VectorError::Other)
4698    }
4699
4700    /// Edit community metadata (name / description) as an authorized member (MANAGE_METADATA). `None` leaves
4701    /// a field unchanged; an empty description clears it.
4702    pub async fn edit_community_metadata(&self, community_id: &str, name: Option<&str>, description: Option<&str>) -> Result<()> {
4703        use crate::community::{service, transport::LiveTransport, CommunityId};
4704        let transport = LiveTransport::with_timeout(std::time::Duration::from_secs(12));
4705        // Dual-stack: a v2 metadata edit is an authorized vsk-0 control edition.
4706        // Overlay onto the FULL held document (`CommunityV2::metadata()`) — an
4707        // edition replaces the entity, so a bare name edit would otherwise wipe
4708        // the icon/banner for every member (CORD-02 §6).
4709        if community_id.len() == 64 {
4710            let cid = CommunityId(crate::simd::hex::hex_to_bytes_32(community_id));
4711            if let Some(Some(crate::community::ConcordProtocol::V2)) = crate::db::community::community_protocol(&cid).ok() {
4712                let community = crate::db::community::load_community_v2(&cid)
4713                    .map_err(VectorError::Other)?
4714                    .ok_or_else(|| VectorError::Other("v2 community not found".into()))?;
4715                let mut meta = community.metadata();
4716                if let Some(n) = name {
4717                    meta.name = n.to_string();
4718                }
4719                if let Some(d) = description {
4720                    meta.description = if d.is_empty() { None } else { Some(d.to_string()) };
4721                }
4722                return crate::community::v2::service::edit_community_metadata(&transport, &community, &meta)
4723                    .await
4724                    .map_err(VectorError::Other);
4725            }
4726        }
4727        let mut community = Self::load_community_hex(community_id)?;
4728        if let Some(n) = name { community.name = n.to_string(); }
4729        if let Some(d) = description { community.description = if d.is_empty() { None } else { Some(d.to_string()) }; }
4730        service::republish_community_metadata(&transport, &community).await.map_err(VectorError::Other)
4731    }
4732
4733
4734
4735    /// Leave a Community: announce a best-effort "left" presence (before dropping keys), then
4736    /// drop the held keys + local channel chats. You need a fresh invite to rejoin.
4737    pub async fn leave_community(&self, community_id: &str) -> Result<()> {
4738        crate::db::scoped(async move {
4739            use crate::community::{transport::LiveTransport, CommunityId};
4740            if community_id.len() != 64 {
4741                return Err(VectorError::Other("malformed community id".into()));
4742            }
4743            let id = CommunityId(crate::simd::hex::hex_to_bytes_32(community_id));
4744            // v2: guestbook Leave + cross-device List tombstone + local delete, in the service.
4745            if let Some(v2) = Self::load_v2_if_v2(community_id)? {
4746                let channel_ids: Vec<String> =
4747                    v2.channels.iter().map(|ch| crate::simd::hex::bytes_to_hex_32(&ch.id.0)).collect();
4748                let transport = LiveTransport::with_timeout(std::time::Duration::from_secs(12));
4749                crate::community::v2::service::leave_community(&transport, &v2)
4750                    .await
4751                    .map_err(VectorError::Other)?;
4752                let mut st = state::STATE.lock().await;
4753                st.chats.retain(|c| !channel_ids.contains(&c.id));
4754                return Ok(());
4755            }
4756            let community = crate::db::community::load_community(&id).map_err(VectorError::Other)?;
4757            let channel_ids: Vec<String> = community
4758                .as_ref()
4759                .map(|c| c.channels.iter().map(|ch| ch.id.to_hex()).collect())
4760                .unwrap_or_default();
4761            // "Left" announcement BEFORE dropping keys (afterward we can't sign/seal into the channel).
4762            if let Some(ref c) = community {
4763                if let Some(primary) = c.channels.first() {
4764                    let transport = LiveTransport::with_timeout(std::time::Duration::from_secs(12));
4765                    let _ = crate::community::service::publish_presence(&transport, c, primary, false, None).await;
4766                }
4767            }
4768            // voluntary leave is a self-removal → retain the held epoch keys for later self-scrub.
4769            crate::db::community::delete_community_retain_keys(community_id).map_err(VectorError::Other)?;
4770            {
4771                let mut st = state::STATE.lock().await;
4772                st.chats.retain(|c| !channel_ids.contains(&c.id));
4773            }
4774            Ok(())
4775        })
4776        .await
4777    }
4778
4779    /// Resolve a channel id to its owning Community + the Channel (with its secret key).
4780    /// Refuse a WRITE into a sealed community (CORD-02 §9). Once dissolved, no honest
4781    /// peer accepts another event, so a send would sit pending forever with no reason
4782    /// given. v2 enforces this inside `chat_send_context`; v1 has no shared send gate,
4783    /// so each write path calls this.
4784    ///
4785    /// Reads are deliberately untouched — a dissolved community stays browsable.
4786    fn ensure_v1_writable(community: &crate::community::Community) -> Result<()> {
4787        if crate::db::community::get_community_dissolved(&community.id.to_hex()).unwrap_or(false) {
4788            return Err(VectorError::Other("this community has been dissolved".into()));
4789        }
4790        Ok(())
4791    }
4792
4793    fn resolve_channel(
4794        &self,
4795        channel_id: &str,
4796    ) -> Result<(crate::community::Community, crate::community::Channel)> {
4797        use crate::community::CommunityId;
4798        let community_id = crate::db::community::community_id_for_channel(channel_id)
4799            .map_err(VectorError::Other)?
4800            .ok_or_else(|| VectorError::Other("Unknown Community channel".into()))?;
4801        if community_id.len() != 64 {
4802            return Err(VectorError::Other("malformed community id".into()));
4803        }
4804        let community = crate::db::community::load_community(&CommunityId(
4805            crate::simd::hex::hex_to_bytes_32(&community_id),
4806        ))
4807        .map_err(VectorError::Other)?
4808        .ok_or_else(|| VectorError::Other("Community not found".into()))?;
4809        let channel = community
4810            .channels
4811            .iter()
4812            .find(|c| c.id.to_hex() == channel_id)
4813            .cloned()
4814            .ok_or_else(|| VectorError::Other("Channel not found in Community".into()))?;
4815        Ok((community, channel))
4816    }
4817
4818
4819    /// Sync DM history from relays using NIP-77 negentropy set reconciliation.
4820    ///
4821    /// Reconciles local wrapper history with relay state, fetches missing events,
4822    /// and processes them through the standard prepare → commit pipeline.
4823    ///
4824    /// Returns (total_events, new_messages).
4825    ///
4826    /// ```no_run
4827    /// # async fn example() -> vector_core::Result<()> {
4828    /// let core = vector_core::VectorCore;
4829    /// // Sync last 7 days of DMs
4830    /// let (events, new) = core.sync_dms(Some(7), &vector_core::NoOpEventHandler).await?;
4831    /// println!("Processed {} events, {} new messages", events, new);
4832    /// # Ok(())
4833    /// # }
4834    /// ```
4835    pub async fn sync_dms(
4836        &self,
4837        since_days: Option<u64>,
4838        handler: &dyn InboundEventHandler,
4839    ) -> Result<(u32, u32)> {
4840        crate::db::scoped(async move {
4841            use futures_util::StreamExt;
4842            use nostr_sdk::prelude::*;
4843
4844            let client = state::nostr_client()
4845                .ok_or(VectorError::Other("Not connected".into()))?;
4846            let my_pk = state::my_public_key()
4847                .ok_or(VectorError::Other("Not logged in".into()))?;
4848
4849            // Load known wrapper IDs + timestamps for negentropy fingerprinting.
4850            // A windowed sync bounds the read in SQL: materialising all of
4851            // history to keep a few days of it is most of this function's cost
4852            // on an established account.
4853            let (items, filter) = if let Some(days) = since_days {
4854                let since_ts = Timestamp::now().as_secs().saturating_sub(days * 24 * 3600);
4855                let items = db::wrappers::load_negentropy_items_since(since_ts)
4856                    .unwrap_or_default();
4857                let filter = Filter::new()
4858                    .pubkey(my_pk)
4859                    .kind(Kind::GiftWrap)
4860                    .since(Timestamp::from_secs(since_ts));
4861                (items, filter)
4862            } else {
4863                let items = db::wrappers::load_negentropy_items().unwrap_or_default();
4864                let filter = Filter::new()
4865                    .pubkey(my_pk)
4866                    .kind(Kind::GiftWrap);
4867                (items, filter)
4868            };
4869
4870            log_info!("[SyncDMs] {} negentropy items, since_days={:?}", items.len(), since_days);
4871
4872            // Dry-run negentropy: exchange fingerprints to identify missing events
4873            let sync_opts = nostr_sdk::prelude::SyncOptions::new()
4874                .direction(nostr_sdk::prelude::SyncDirection::Down)
4875                .initial_timeout(std::time::Duration::from_secs(10))
4876                .dry_run();
4877
4878            // Race all relays — first to reconcile drives the fetch. Relays with a
4879            // fresh no-NIP-77 verdict skip the doomed reconcile and get a bounded
4880            // REQ pass below instead.
4881            let relay_map = client.relays().await;
4882            let (all_relays, no_neg_relays): (Vec<(RelayUrl, Relay)>, Vec<(RelayUrl, Relay)>) =
4883                relay_map.iter()
4884                    .map(|(url, relay)| (url.clone(), relay.clone()))
4885                    .partition(|(url, _)| negentropy::neg_supported_cached(url.as_str()) != Some(false));
4886            drop(relay_map);
4887            let skipped_no_neg: Vec<String> = no_neg_relays.iter().map(|(u, _)| u.to_string()).collect();
4888            if !skipped_no_neg.is_empty() {
4889                log_info!("[SyncDMs] {} relay(s) on REQ path (no NIP-77)", skipped_no_neg.len());
4890            }
4891
4892            // Tor-aware like the GUI path — a fixed clearnet budget over Tor makes
4893            // a healthy relay's slow first frame look like connected-silence and
4894            // earns it a false 24h no-NEG verdict in the shared account KV.
4895            let neg_budget = relay_request_timeout(std::time::Duration::from_secs(10));
4896            let neg_outer = neg_budget + std::time::Duration::from_secs(5);
4897            let connect_allowance = relay_request_timeout(std::time::Duration::from_secs(3))
4898                .min(neg_outer);
4899            let mut relay_futs = futures_util::stream::FuturesUnordered::new();
4900            for (url, relay) in &all_relays {
4901                let url = url.clone();
4902                let relay = relay.clone();
4903                let f = filter.clone();
4904                let i = items.clone();
4905                let o = sync_opts.clone();
4906                relay_futs.push(async move {
4907                    if !negentropy::wait_connected(&relay, connect_allowance).await {
4908                        return (url, None, false);
4909                    }
4910                    // Outer slack over the initial_timeout so the SDK's error
4911                    // (which distinguishes refusal from silence) surfaces first.
4912                    let result = tokio::time::timeout(
4913                        neg_outer,
4914                        relay.sync(f).items(i).opts(o),
4915                    ).await;
4916                    let connected = relay.status() == RelayStatus::Connected;
4917                    (url, Some(result), connected)
4918                });
4919            }
4920
4921            // Collect missing IDs from all relays
4922            let cap_session = crate::db::current_session();
4923            let mut all_missing: std::collections::HashSet<EventId> = std::collections::HashSet::new();
4924            while let Some((url, result, connected)) = relay_futs.next().await {
4925                let Some(result) = result else {
4926                    log_warn!("[SyncDMs] {} skipped: not connected", url);
4927                    continue;
4928                };
4929                match result {
4930                    Ok(Ok(recon)) => {
4931                        let count = recon.remote.len();
4932                        all_missing.extend(recon.remote);
4933                        log_info!("[SyncDMs] {} reconciled: {} missing", url, count);
4934                        negentropy::record_neg_support(url.as_str(), true);
4935                    }
4936                    Ok(Err(e)) => {
4937                        log_warn!("[SyncDMs] {} failed: {}", url, e);
4938                        if cap_session.is_live()
4939                            && negentropy::classify_neg_sync_error(&e.to_string(), connected) == Some(false)
4940                        {
4941                            log_info!("[SyncDMs] {} marked no-NIP-77 for 24h", url);
4942                            negentropy::record_neg_support(url.as_str(), false);
4943                        }
4944                    }
4945                    Err(_) => log_warn!("[SyncDMs] {} timed out ({:?})", url, neg_outer),
4946                }
4947            }
4948
4949            let mut total_events = 0u32;
4950            let mut new_messages = 0u32;
4951
4952            // No-NIP-77 relays still contribute: one bounded REQ over the same
4953            // filter. The 500-event cap keeps a `since_days: None` call from
4954            // pulling a whole mailbox — deep history is negentropy's job on the
4955            // relays that speak it.
4956            if !skipped_no_neg.is_empty() {
4957                let req_filter = filter.clone().limit(500);
4958                match client
4959                    .stream_events(nostr_sdk::prelude::ReqTarget::manual(
4960                        skipped_no_neg.iter().cloned().map(|u| (u, vec![req_filter.clone()])),
4961                    ))
4962                    .timeout(std::time::Duration::from_secs(20))
4963                    .await
4964                {
4965                    Ok(stream) => {
4966                        let mut seen: std::collections::HashSet<[u8; 32]> = std::collections::HashSet::new();
4967                        tokio::pin!(stream);
4968                        while let Some((_relay, res)) = stream.next().await {
4969                            let Ok(event) = res else { continue };
4970                            // Straddles the stream: a swap mid-drain must not push
4971                            // the old account's wrappers through the new account's
4972                            // pipeline (ErrorSkip would ledger them there).
4973                            if !seen.insert(event.id.to_bytes()) { continue; }
4974                            total_events += 1;
4975                            let prepared = event_handler::prepare_event(event, &client, my_pk).await;
4976                            if event_handler::commit_prepared_event(prepared, false, handler).await {
4977                                new_messages += 1;
4978                            }
4979                        }
4980                    }
4981                    Err(e) => log_warn!("[SyncDMs] REQ pass failed: {}", e),
4982                }
4983            }
4984
4985            if all_missing.is_empty() {
4986                log_info!("[SyncDMs] No missing events");
4987                return Ok((total_events, new_messages));
4988            }
4989
4990            // Fetch missing events in batches
4991            log_info!("[SyncDMs] Fetching {} missing events", all_missing.len());
4992            let ids: Vec<EventId> = all_missing.into_iter().collect();
4993            let relay_strs: Vec<String> = client.relays().await.keys()
4994                .map(|u| u.to_string()).collect();
4995
4996            const BATCH_SIZE: usize = 500;
4997
4998            for batch in ids.chunks(BATCH_SIZE) {
4999                // The #p is not redundant: Ditto refuses gift-wrap REQs that carry
5000                // neither authors nor #p, even authed — ids-only returns nothing.
5001                let f = Filter::new().ids(batch.to_vec()).kind(Kind::GiftWrap).pubkey(my_pk);
5002                match client
5003                    .stream_events(nostr_sdk::prelude::ReqTarget::manual(
5004                        relay_strs.iter().cloned().map(|u| (u, vec![f.clone()])),
5005                    ))
5006                    .timeout(std::time::Duration::from_secs(30))
5007                    .await
5008                {
5009                    Ok(stream) => {
5010                        let client_clone = client.clone();
5011                        let prepared_stream = stream
5012                            .filter_map(|(_relay, res)| async move { res.ok() })
5013                            .map(move |event| {
5014                                let c = client_clone.clone();
5015                                db::spawn_bound(async move {
5016                                    event_handler::prepare_event(event, &c, my_pk).await
5017                                })
5018                            })
5019                            .buffer_unordered(8);
5020                        tokio::pin!(prepared_stream);
5021
5022                        while let Some(result) = prepared_stream.next().await {
5023                            total_events += 1;
5024                            if let Ok(prepared) = result {
5025                                if event_handler::commit_prepared_event(prepared, false, handler).await {
5026                                    new_messages += 1;
5027                                }
5028                            }
5029                        }
5030                    }
5031                    Err(e) => log_warn!("[SyncDMs] Batch fetch error: {}", e),
5032                }
5033            }
5034
5035            log_info!("[SyncDMs] Complete: {} events processed, {} new messages", total_events, new_messages);
5036            Ok((total_events, new_messages))
5037        })
5038        .await
5039    }
5040
5041    // ========================================================================
5042    // Event Subscription
5043    // ========================================================================
5044
5045    /// Subscribe to incoming DM events (NIP-17 GiftWraps).
5046    ///
5047    /// Returns the subscription ID for use in a custom notification loop.
5048    /// For a complete listen-and-process loop, use [`listen()`](Self::listen) instead.
5049    pub async fn subscribe_dms(&self) -> Result<nostr_sdk::prelude::SubscriptionId> {
5050        use nostr_sdk::prelude::*;
5051        let client = state::nostr_client()
5052            .ok_or(VectorError::Other("Not connected".into()))?;
5053        let my_pk = state::my_public_key()
5054            .ok_or(VectorError::Other("Not logged in".into()))?;
5055
5056        let filter = Filter::new()
5057            .pubkey(my_pk)
5058            .kind(Kind::GiftWrap)
5059            .limit(0);
5060
5061        let output = client.subscribe(filter).await
5062            .map_err(|e| VectorError::Nostr(e.to_string()))?;
5063        Ok(output.value)
5064    }
5065
5066    /// Catch up every locally-held Community: fold control / re-foundings / rekeys / banlist and
5067    /// fetch recent messages into local state for each channel. State-only (does not replay to an
5068    /// [`InboundEventHandler`]). Called at `listen()` start and periodically for outage resilience;
5069    /// also safe to call manually after a known disconnect.
5070    ///
5071    /// Catch up every locally-held Community. v1 channels are synced inline; a v2
5072    /// community is ENQUEUED for the follow worker (control/rekey re-fold + adopt),
5073    /// non-blocking. State-only (no handler replay of history). Called at `listen()`
5074    /// start and on reconnect; safe to call manually — the v2 enqueue is a no-op if
5075    /// no `listen()` worker is running.
5076    /// Returns each community's non-fatal follow warnings (rekey/control catch-up
5077    /// failures), prefixed with the id. A caller that discards them is blind to
5078    /// "the sync ran, but this account never adopted the rotation" — which reads
5079    /// exactly like a healthy quiet sync while the account sits on a dead epoch.
5080    pub async fn sync_communities(&self) -> Result<Vec<String>> {
5081        let mut all_warnings: Vec<String> = Vec::new();
5082        // Discover + rehydrate memberships from the Community List across devices (CORD-02 §8),
5083        // bootstrapping from the client's connected relays so even a fresh device that
5084        // holds no community yet can find them. Best-effort.
5085        {
5086            use crate::community::{transport::LiveTransport, v2::service as v2};
5087            let bootstrap: Vec<String> = match crate::state::nostr_client() {
5088                Some(client) => client.relays().await.keys().map(|r| r.to_string()).collect(),
5089                None => Vec::new(),
5090            };
5091            let transport = LiveTransport::with_timeout(std::time::Duration::from_secs(12));
5092            if let Ok(outcome) = v2::sync_community_list(&transport, &bootstrap).await {
5093                // Headless: core already dropped the rows; a GUI shell additionally clears the
5094                // chat rows + STATE via `removed` (see `ListSyncOutcome`).
5095                let joined = outcome.joined;
5096                for c in &joined {
5097                    if community::v2::realtime::follow_worker_running() {
5098                        community::v2::realtime::enqueue_follow(c.id());
5099                    } else {
5100                        let cid_hex = crate::simd::hex::bytes_to_hex_32(&c.id().0);
5101                        all_warnings.extend(
5102                            Self::v2_inline_follow(c.id()).await.into_iter().map(|w| format!("{}: {w}", &cid_hex[..8])),
5103                        );
5104                    }
5105                }
5106                if !joined.is_empty() {
5107                    if let Some(client) = crate::state::nostr_client() {
5108                        community::v2::realtime::refresh_subscription(&client).await;
5109                    }
5110                }
5111            }
5112        }
5113
5114        let ids = db::community::list_community_ids().map_err(VectorError::from)?;
5115        for id in ids {
5116            if matches!(db::community::community_protocol(&id).ok().flatten(), Some(crate::community::ConcordProtocol::V2)) {
5117                // With a live listen() the coalescing worker owns the follow; headless
5118                // (no worker) it would be dropped, so walk it inline instead.
5119                if community::v2::realtime::follow_worker_running() {
5120                    community::v2::realtime::enqueue_follow(&id);
5121                } else {
5122                    let cid_hex = crate::simd::hex::bytes_to_hex_32(&id.0);
5123                    all_warnings.extend(
5124                        Self::v2_inline_follow(&id).await.into_iter().map(|w| format!("{}: {w}", &cid_hex[..8])),
5125                    );
5126                }
5127                // Back-fill each channel's chat, exactly as the v1 arm below does.
5128                // Without this a headless client only ever sees messages that arrive
5129                // LIVE: anything sent while it was down — or while it held no key for
5130                // a private channel — is never fetched, so a bot granted access reads
5131                // an empty room. Cheap when there is nothing new (id-deduped), and a
5132                // channel we cannot read simply yields nothing.
5133                if let Ok(Some(c)) = db::community::load_community_v2(&id) {
5134                    for ch in &c.channels {
5135                        let hex = crate::simd::hex::bytes_to_hex_32(&ch.id.0);
5136                        let _ = Self::v2_backfill_channel(
5137                            &id, &hex, 50, 2, None, None,
5138                            crate::community::transport::Evidence::Fast, 12,
5139                        ).await;
5140                    }
5141                }
5142                continue;
5143            }
5144            if let Ok(Some(community)) = db::community::load_community(&id) {
5145                for ch in &community.channels {
5146                    let _ = self.sync_community_channel(&ch.id.to_hex(), 50).await;
5147                }
5148            }
5149        }
5150        Ok(all_warnings)
5151    }
5152
5153
5154    /// Start listening for incoming DMs.
5155    ///
5156    /// Blocks until the client disconnects. Processes GiftWraps
5157    /// (DMs, files) → prepare_event → commit_prepared_event.
5158    ///
5159    /// ```no_run
5160    /// use vector_core::*;
5161    /// use std::sync::Arc;
5162    ///
5163    /// struct MyBot;
5164    /// impl InboundEventHandler for MyBot {
5165    ///     fn on_dm_received(&self, chat_id: &str, msg: &Message, _is_new: bool) {
5166    ///         if msg.mine { return; }
5167    ///         let to = chat_id.to_string();
5168    ///         let reply = format!("Echo: {}", msg.content);
5169    ///         tokio::spawn(async move {
5170    ///             let _ = VectorCore.send_dm(&to, &reply).await;
5171    ///         });
5172    ///     }
5173    /// }
5174    ///
5175    /// # async fn example() -> vector_core::Result<()> {
5176    /// let core = VectorCore::init(CoreConfig {
5177    ///     data_dir: "/tmp/bot-data".into(),
5178    ///     event_emitter: None,
5179    /// })?;
5180    /// core.login("nsec1...", None).await?;
5181    /// core.listen(Arc::new(MyBot)).await?;
5182    /// # Ok(())
5183    /// # }
5184    /// ```
5185    pub async fn listen(&self, handler: Arc<dyn InboundEventHandler>) -> Result<()> {
5186        use nostr_sdk::prelude::*;
5187
5188        let client = state::nostr_client()
5189            .ok_or(VectorError::Other("Not connected".into()))?;
5190        let my_pk = state::my_public_key()
5191            .ok_or(VectorError::Other("Not logged in".into()))?;
5192
5193        // Start the stream-AUTH responder BEFORE any relay interaction: a gating
5194        // relay issues its NIP-42 challenge ONCE per connection, and the DM
5195        // subscribe below consumes it via nostr-sdk's user auto-auth — if the
5196        // responder isn't already watching, that challenge is never remembered
5197        // and the stream keys registered later can NEVER authenticate (the relay
5198        // won't re-challenge an authed connection; the v2 sub dies silently).
5199        community::v2::streamauth::ensure_responder(&client);
5200
5201        // Outage resilience — catch up on connect, then re-sync periodically.
5202        //
5203        // Catch up BEFORE going realtime so a bot that was offline folds any missed re-foundings /
5204        // metadata / banlist changes (and recent messages) into local state, and subscribes at the
5205        // CURRENT epoch pseudonyms. This is state-only: historical messages are not replayed to the
5206        // handler (matches the gateway model) — query them via `get_messages`.
5207        // Spawn the single per-community follow worker for this session; the v2
5208        // follow queue (fed by dispatch, catch-up, and sync) drains through it.
5209        community::v2::realtime::spawn_follow_worker(handler.clone());
5210        let _ = self.sync_communities().await;
5211        let _ = self.sync_dms(None, &NoOpEventHandler).await;
5212
5213        // Subscribe to DMs (GiftWraps) AND Community channel events — one loop dispatches both
5214        // through the same handler, so `on_dm_received`/`on_community_message` share a sink.
5215        let dm_sub_id = self.subscribe_dms().await?;
5216        community::realtime::refresh_subscription(&client).await;
5217        community::v2::realtime::refresh_subscription(&client).await;
5218
5219        // The subscriptions above are COMMITTED: live events flow from here. Say
5220        // so — the startup gauntlet (relay-connect wait + stream-auth priming)
5221        // can take a minute against a slow relay set, and without this signal a
5222        // bot cannot tell "still connecting" from "subscribed to nothing".
5223        // Fires even with 0 communities: that too is a completed, ready state.
5224        handler.on_subscription_ready(db::community::list_community_ids().map(|v| v.len()).unwrap_or(0));
5225
5226        // Outage resilience via the relay Monitor — event-driven, not polling.
5227        //
5228        // (1) Reconnect-driven catch-up: a `limit(0)` realtime sub never replays what was published
5229        // while we were down, so a relay (re)connecting is exactly when we must catch up. On each
5230        // Connected transition we refold consensus + reconcile DMs (NIP-77 negentropy → only the
5231        // diff) and re-track the realtime sub at the current epochs. Idle when healthy. Stops on swap.
5232        if let Some(monitor) = client.monitor() {
5233            let mut rx = monitor.subscribe();
5234            db::spawn_bound(async move {
5235                // Debounce reconnect bursts: StatusChanged is per-relay, but one catch-up queries the
5236                // whole pool — so coalesce Connected transitions within a short window into one resync.
5237                let mut last_resync: Option<std::time::Instant> = None;
5238                while let Ok(notification) = rx.recv().await {
5239                    let MonitorNotification::StatusChanged { status, .. } = notification;
5240                    if status == RelayStatus::Connected {
5241                        if last_resync.is_some_and(|t| t.elapsed() < std::time::Duration::from_secs(3)) {
5242                            continue;
5243                        }
5244                        let _ = VectorCore.sync_communities().await;
5245                        let _ = VectorCore.sync_dms(None, &NoOpEventHandler).await;
5246                        if let Some(c) = state::nostr_client() {
5247                            community::realtime::refresh_subscription(&c).await;
5248                            community::v2::realtime::refresh_subscription(&c).await;
5249                        }
5250                        last_resync = Some(std::time::Instant::now());
5251                    }
5252                }
5253            });
5254        }
5255
5256        // (2) Health probe: a relay can report Connected while silently dead. Every 60s probe each
5257        // with a tiny query + timeout; a zombie is force-reconnected (which fires the monitor above
5258        // → catch-up), and Disconnected/Terminated relays are reconnected directly.
5259        {
5260            let client_health = client.clone();
5261            db::spawn_bound(async move {
5262                tokio::time::sleep(std::time::Duration::from_secs(30)).await; // warm-up
5263                loop {
5264                    for (url, relay) in client_health.relays().await {
5265                        match relay.status() {
5266                            RelayStatus::Connected => {
5267                                let probe = tokio::time::timeout(
5268                                    std::time::Duration::from_secs(10),
5269                                    client_health
5270                                        .fetch_events(nostr_sdk::prelude::ReqTarget::single(
5271                                            url.to_string(),
5272                                            [Filter::new().kind(Kind::Metadata).limit(1)],
5273                                        ))
5274                                        .timeout(std::time::Duration::from_secs(8)),
5275                                )
5276                                .await;
5277                                if !matches!(probe, Ok(Ok(_))) {
5278                                    let _ = relay.disconnect();
5279                                    tokio::time::sleep(std::time::Duration::from_millis(500)).await;
5280                                    let _ = relay.try_connect().timeout(crate::relay_connect_timeout(std::time::Duration::from_secs(10))).await;
5281                                }
5282                            }
5283                            RelayStatus::Terminated | RelayStatus::Disconnected => {
5284                                let _ = relay.try_connect().timeout(crate::relay_connect_timeout(std::time::Duration::from_secs(10))).await;
5285                            }
5286                            _ => {}
5287                        }
5288                    }
5289                    tokio::time::sleep(std::time::Duration::from_secs(60)).await;
5290                }
5291            });
5292        }
5293
5294        let client_for_closure = client.clone();
5295
5296        // 0.45 removed `handle_notifications`; drive the stream directly. It ends when
5297        // the client shuts down, which is what stops this loop on `swap_session`.
5298        let mut notifications = client.notifications();
5299        while let Some(notification) = notifications.next().await {
5300            let handler = handler.clone();
5301            let c = client_for_closure.clone();
5302            let dm_sid = dm_sub_id.clone();
5303            {
5304                // Relay OKs feed the send pipeline: an OK that outlives the
5305                // per-attempt wait still confirms delivery, and can rescue a
5306                // message already marked Failed.
5307                if let nostr_sdk::prelude::ClientNotification::Message { message, .. } = &notification {
5308                    if let nostr_sdk::prelude::RelayMessage::Ok { event_id, status, .. } = &**message {
5309                        sending::note_relay_ok(event_id, *status);
5310                    }
5311                }
5312                if let nostr_sdk::prelude::ClientNotification::Event { event, subscription_id, .. } = notification {
5313                    if subscription_id == dm_sid {
5314                        // DMs, files, reactions
5315                        let prepared = event_handler::prepare_event(*event, &c, my_pk).await;
5316                        event_handler::commit_prepared_event(prepared, true, &*handler).await;
5317                    } else if community::realtime::subscription_id().await.as_ref() == Some(&subscription_id)
5318                        || community::realtime::poolwide_subscription_id().await.as_ref() == Some(&subscription_id)
5319                    {
5320                        // Community (v1) channel messages / reactions / edits / control editions.
5321                        // OR the pool-wide sub (the path that streams on Android) — else v1 events
5322                        // arriving under it match no branch and are silently dropped.
5323                        community::realtime::dispatch_event(*event, handler.clone()).await;
5324                    } else if community::v2::realtime::subscription_id().await.as_ref() == Some(&subscription_id)
5325                        || community::v2::realtime::poolwide_subscription_id().await.as_ref() == Some(&subscription_id)
5326                    {
5327                        // Concord v2 plane events (authors-addressed kind-1059/21059).
5328                        community::v2::realtime::dispatch_event(*event, handler.clone()).await;
5329                    }
5330                }
5331            }
5332        }
5333
5334        Ok(())
5335    }
5336
5337    /// Disconnect and clean up.
5338    pub async fn logout(&self) {
5339        if let Some(client) = state::nostr_client() {
5340            let _ = client.disconnect().await;
5341        }
5342        db::close_database();
5343    }
5344
5345    /// Tear down the current session for an in-process account swap — the account-agnostic core of
5346    /// the app's `reset_session()`. Advances the session generation FIRST so any background task
5347    /// holding a `std::sync::Arc<crate::db::Session>` short-circuits before it can touch the next account's storage; shuts
5348    /// the client down (which ends any `listen()` notification loop bound to it, so the old account's
5349    /// events can't land in the new account's DB); closes the DB pool; and clears the key vaults plus
5350    /// all in-memory per-account state. Follow with `login()` to bind the next account, then re-attach
5351    /// `listen()`. (The app's `reset_session()` additionally clears Tauri-only caches it owns.)
5352    pub async fn swap_session(&self) {
5353        // FIRST — invalidate every captured guard before any teardown begins.
5354        state::clear_message_tombstones();
5355
5356        // Shut the client down before anything else: this detaches relay subscriptions and ends the
5357        // prior `listen()` loop, so it stops firing the old account's events into the new session.
5358        if let Some(client) = state::take_nostr_client() {
5359            let _ = client.shutdown().await;
5360        }
5361        db::close_database();
5362
5363        // Key vaults + transient secrets.
5364        state::ENCRYPTION_KEY.clear(&[&state::MY_SECRET_KEY]);
5365        state::MY_SECRET_KEY.clear(&[&state::ENCRYPTION_KEY]);
5366        {
5367            use zeroize::Zeroize;
5368            if let Ok(mut g) = state::MNEMONIC_SEED.lock() {
5369                if let Some(s) = g.as_mut() { s.zeroize(); }
5370                *g = None;
5371            }
5372            if let Ok(mut g) = state::PENDING_NSEC.lock() {
5373                if let Some(s) = g.as_mut() { s.zeroize(); }
5374                *g = None;
5375            }
5376        }
5377
5378        // Everything else the account owned — chats, profiles, wrapper ids,
5379        // parked events, row-id caches, the sync queue, the relay caches, the
5380        // theme tags, the active chat — went with the session `close_database`
5381        // released. Only teardown that DOES something beyond forgetting is left.
5382
5383        // Unsubscribing is an action: these hold live relay subscriptions keyed
5384        // to the prior account's plane pseudonyms.
5385        crate::community::realtime::clear().await;
5386        crate::community::v2::realtime::clear().await;
5387        // So is disconnecting: the pooled clients are AUTHENTICATED as the prior
5388        // account's plane secret keys, and must not be left holding sockets.
5389        crate::community::transport::clear_plane_pool();
5390    }
5391}
5392
5393#[cfg(all(test, feature = "tor", not(target_arch = "wasm32")))]
5394mod transport_policy_tests {
5395    use std::time::Duration;
5396
5397    /// ONE test covering proxy + budgets: the Tor preference is a process-global
5398    /// atomic, so separate `#[test]` fns would race under the parallel runner.
5399    #[test]
5400    fn tor_transport_policy() {
5401        let short = Duration::from_secs(5);
5402        let long = Duration::from_secs(300);
5403
5404        // Tor off: connections may go direct, and every caller's clearnet budget
5405        // passes through untouched so the common path is never slowed down.
5406        crate::tor::set_tor_enabled_pref(false);
5407        assert_eq!(super::tor_proxy_target(), None);
5408        assert_eq!(super::relay_connect_timeout(short), short);
5409        assert_eq!(super::relay_request_timeout(short), short);
5410
5411        // `RequiredButInactive` (Tor chosen, proxy not up yet) must raise the floor
5412        // just like `Active`: that window is when connects are slowest, and treating
5413        // it as clearnet is what tore relays down mid-handshake.
5414        crate::tor::set_tor_enabled_pref(true);
5415        assert!(matches!(
5416            crate::tor::transport_state(),
5417            crate::tor::TorTransportState::RequiredButInactive
5418        ));
5419        // THE leak invariant: `None` here means "connect direct". While Tor is the
5420        // chosen transport it must never be None — least of all during bootstrap,
5421        // which is exactly when a naive implementation falls through to direct.
5422        // Silent failure with an IP disclosure as the cost, so it gets a permanent
5423        // guard rather than a one-off manual check.
5424        assert_eq!(
5425            super::tor_proxy_target(),
5426            Some(crate::tor::blackhole_proxy_addr()),
5427            "Tor enabled but inactive must blackhole, never connect direct"
5428        );
5429        assert_eq!(super::relay_connect_timeout(short), super::TOR_RELAY_CONNECT_FLOOR);
5430        assert_eq!(super::relay_request_timeout(short), super::TOR_RELAY_REQUEST_FLOOR);
5431
5432        // The floor only ever raises. A caller asking for longer than the floor has
5433        // a reason to, and shortening it would abort operations that used to finish.
5434        for tor in [true, false] {
5435            crate::tor::set_tor_enabled_pref(tor);
5436            assert_eq!(super::relay_connect_timeout(long), long, "connect, tor={tor}");
5437            assert_eq!(super::relay_request_timeout(long), long, "request, tor={tor}");
5438        }
5439    }
5440}
5441
5442#[cfg(test)]
5443mod facade_tests {
5444    use super::*;
5445
5446    /// SSRF regression: `download_attachment` must reject a private/link-local URL via
5447    /// `validate_url_not_private` BEFORE any network fetch (the URL is attacker-controlled).
5448    #[tokio::test]
5449    async fn download_attachment_rejects_private_url() {
5450        let att = crate::types::Attachment {
5451            url: "http://169.254.169.254/latest/meta-data/".to_string(),
5452            ..Default::default()
5453        };
5454        match VectorCore.download_attachment(&att).await {
5455            Err(VectorError::Other(msg)) => {
5456                assert!(msg.contains("Private/internal"), "expected SSRF rejection, got: {msg}")
5457            }
5458            other => panic!("expected SSRF rejection, got {other:?}"),
5459        }
5460    }
5461
5462    #[tokio::test]
5463    async fn download_attachment_rejects_empty_url() {
5464        let att = crate::types::Attachment::default();
5465        assert!(VectorCore.download_attachment(&att).await.is_err());
5466    }
5467
5468    /// The facade dual-stack dispatch: a v2 community surfaces in `list_communities`
5469    /// with `version: 2`, and `v2_community_for_channel` routes its channels to the
5470    /// v2 send path — while a v1 community is untouched (version 1).
5471    #[tokio::test]
5472    async fn list_communities_and_channel_routing_are_protocol_aware() {
5473        use crate::community::transport::memory::MemoryRelay;
5474        use nostr_sdk::prelude::Keys;
5475
5476        let _guard = crate::db::DB_TEST_GUARD.lock().unwrap_or_else(|e| e.into_inner());
5477        crate::db::close_database();
5478        crate::db::clear_id_caches();
5479        let tmp = tempfile::tempdir().unwrap();
5480        // A valid bech32-charset, npub-length account dir name.
5481        let acct = {
5482            const B: &[u8] = b"qpzry9x8gf2tvdw0s3jn54khce6mua7l";
5483            let mut s = String::from("npub1");
5484            for i in 0..58 {
5485                s.push(B[(i * 7 + 3) % 32] as char);
5486            }
5487            s
5488        };
5489        std::fs::create_dir_all(tmp.path().join(&acct)).unwrap();
5490        crate::db::set_app_data_dir(crate::db::shared_test_data_dir().to_path_buf());
5491        crate::db::set_current_account(acct.clone()).unwrap();
5492        crate::db::init_database(&acct).unwrap();
5493        let _ = crate::state::take_nostr_client();
5494        let me = Keys::generate();
5495        crate::state::MY_SECRET_KEY.store_from_keys(&me, &[]);
5496        crate::state::set_my_public_key(me.public_key());
5497
5498        // Create a v2 community directly through the v2 service (offline).
5499        let relay = MemoryRelay::new();
5500        let community = crate::community::v2::service::create_community(&relay, "V2 Guild", vec!["wss://r".into()], None)
5501            .await
5502            .unwrap();
5503        let channel_hex = crate::simd::hex::bytes_to_hex_32(&community.channels[0].id.0);
5504
5505        // The facade lists it as version 2, owned by me.
5506        let listed = VectorCore.list_communities().await;
5507        let v2 = listed.iter().find(|c| c["version"] == 2).expect("the v2 community is listed");
5508        assert_eq!(v2["name"], "V2 Guild");
5509        assert_eq!(v2["is_owner"], true);
5510        assert_eq!(v2["channels"][0]["channel_id"], channel_hex);
5511
5512        // The channel routes to the v2 send path.
5513        assert_eq!(
5514            VectorCore.v2_community_for_channel(&channel_hex).unwrap(),
5515            Some(community.identity.community_id),
5516            "a v2 channel is routed to v2"
5517        );
5518        // An unknown channel routes nowhere (would fall through to v1).
5519        assert_eq!(VectorCore.v2_community_for_channel(&"00".repeat(32)).unwrap(), None);
5520    }
5521
5522    /// The facade builds a v2 invite URL by trimming `/invite` off the v1
5523    /// constant (v2's `build_invite_url` re-appends its own `/invite/<naddr>`).
5524    /// Lock that the derived URL is v2-shaped and round-trips through the v2
5525    /// parser — a stale constant or a double-`/invite` would silently break joins.
5526    #[test]
5527    fn v2_invite_url_base_derivation_round_trips() {
5528        use crate::community::v2::derive::TOKEN_LEN;
5529        use crate::community::v2::invite::{build_invite_url, parse_invite_link};
5530        use nostr_sdk::prelude::Keys;
5531        let base = crate::community::public_invite::INVITE_URL_BASE.trim_end_matches("/invite");
5532        assert!(!base.ends_with("/invite"), "the bare domain must not carry /invite");
5533        let signer = Keys::generate();
5534        let token = [0x07u8; TOKEN_LEN];
5535        let url = build_invite_url(base, &signer.public_key(), &token, &[]).unwrap();
5536        assert!(url.contains("/invite/"), "a v2 URL carries the naddr path");
5537        assert!(!url.contains("/invite/invite/"), "no doubled /invite from the base");
5538        let parsed = parse_invite_link(&url).unwrap();
5539        assert_eq!(parsed.link_signer, signer.public_key());
5540        assert_eq!(parsed.token, token);
5541    }
5542}
5543
5544#[cfg(test)]
5545mod history_paging_tests {
5546    use super::*;
5547
5548    fn msg(at: u64, id_byte: u8, content: &str) -> Message {
5549        Message {
5550            id: format!("{:02x}", id_byte).repeat(32),
5551            content: content.to_string(),
5552            at,
5553            ..Default::default()
5554        }
5555    }
5556
5557    /// The cursor is compared by VALUE: paging must step strictly through a
5558    /// same-millisecond wall (the id tiebreak) and must survive the cursored
5559    /// message being deleted — a persisted cursor must never wedge the walk.
5560    #[tokio::test]
5561    async fn history_pages_through_a_same_ms_wall_and_a_deleted_cursor() {
5562        let chat_id = "test-history-paging-wall";
5563        {
5564            let mut st = state::STATE.lock().await;
5565            st.ensure_community_chat(chat_id);
5566            // A wall of three messages in the SAME millisecond, plus one older.
5567            for m in [msg(500, 0x01, "old"), msg(900, 0xaa, "wall-a"), msg(900, 0xbb, "wall-b"), msg(900, 0xcc, "wall-c")] {
5568                st.add_message_to_chat(chat_id, &m);
5569            }
5570        }
5571        let core = VectorCore;
5572
5573        // Newest 2 — the tail of the wall, chronological.
5574        let newest = core.get_messages_before(chat_id, None, 2).await;
5575        assert_eq!(newest.iter().map(|m| m.content.as_str()).collect::<Vec<_>>(), ["wall-b", "wall-c"]);
5576
5577        // Page strictly before wall-b: its same-ms predecessor, then the older one.
5578        let cursor = (newest[0].at, newest[0].id.as_str().to_string());
5579        let page = core.get_messages_before(chat_id, Some((cursor.0, &cursor.1)), 10).await;
5580        assert_eq!(page.iter().map(|m| m.content.as_str()).collect::<Vec<_>>(), ["old", "wall-a"]);
5581
5582        // The cursored message vanishes (delete / self-destruct): the SAME cursor
5583        // still resolves to the same page, because nothing resolves it to a row.
5584        {
5585            let mut st = state::STATE.lock().await;
5586            let chat = st.get_chat_mut(chat_id).unwrap();
5587            chat.messages.remove_by_hex_id(&cursor.1);
5588        }
5589        let page = core.get_messages_before(chat_id, Some((cursor.0, &cursor.1)), 10).await;
5590        assert_eq!(
5591            page.iter().map(|m| m.content.as_str()).collect::<Vec<_>>(),
5592            ["old", "wall-a"],
5593            "a deleted cursor pages identically"
5594        );
5595
5596        // Unknown chat: empty, not an error.
5597        assert!(core.get_messages_before("test-history-paging-nochat", None, 5).await.is_empty());
5598    }
5599}
5600
5601#[cfg(test)]
5602mod moderation_cache_audit {
5603    /// Every moderation action changes who is a member, and the console's verdict is
5604    /// memoised for 90s. An action that does not drop the memo leaves the console
5605    /// listing people it just removed and flagging a raid that is already over —
5606    /// which is exactly what shipped: `invalidate_raid_report` existed with no
5607    /// caller at all. Parsed from source, so a new action cannot quietly skip it.
5608    #[test]
5609    fn every_moderation_action_drops_the_memoised_verdict() {
5610        let src = include_str!("lib.rs");
5611        for name in [
5612            "pub async fn refound_community",
5613            "pub async fn kick_community_members",
5614            "pub async fn set_members_banned",
5615            "pub async fn contain_raid",
5616        ] {
5617            let start = src.find(name).unwrap_or_else(|| panic!("{name} not found — rename?"));
5618            // The body ends where the next item at method indentation begins.
5619            let rest = &src[start + name.len()..];
5620            let end = rest.find("\n    pub ").unwrap_or(rest.len());
5621            assert!(
5622                rest[..end].contains("invalidate_raid_report"),
5623                "{name} mutates membership but never drops the memoised console verdict"
5624            );
5625        }
5626    }
5627
5628    /// A ban by ANOTHER admin reaches this device as an adopted fold, not a local
5629    /// call — and with a moderation bot doing the work, that is the ONLY way it ever
5630    /// arrives. The three stores the console reports from must drop the memo on
5631    /// write, or the human admin watches a raid that was handled 90 seconds ago.
5632    #[test]
5633    fn an_adopted_change_drops_the_verdict_too() {
5634        let src = include_str!("db/community.rs");
5635        for name in ["pub fn set_community_banlist", "pub fn set_community_roles", "pub fn set_guestbook"] {
5636            let start = src.find(name).unwrap_or_else(|| panic!("{name} not found — rename?"));
5637            let rest = &src[start + name.len()..];
5638            let end = rest.find("\npub fn ").unwrap_or(rest.len());
5639            assert!(
5640                rest[..end].contains("forget_console_verdict"),
5641                "{name} changes who the console reports on but leaves the memo standing"
5642            );
5643        }
5644    }
5645}