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