car-server-core 0.30.0

Transport-neutral library for the CAR daemon JSON-RPC dispatcher (used by car-server and tokhn-daemon)
//! Channel identity + the inbound-channel seam (channel-agnostic, Unit 1/2/3).
//!
//! This module is the **cross-platform** spine of the multi-channel approval
//! transport. It defines, with NO `#[cfg(target_os = ...)]` gate anywhere:
//!
//! - [`ChannelId`] — the closed enum naming each approval channel
//!   (`IMessage`, `Slack`). Serialized as a stable lowercase string key
//!   (`"imessage"` / `"slack"`) so it is a deterministic [`std::collections::BTreeMap`]
//!   key in the per-channel config (Unit 2) and a stable wire token (Unit 6).
//! - [`ChannelConfig`] — the per-channel trust state (today's three iMessage
//!   fields, now per channel): `enabled`, `allowlisted_handles`,
//!   `active_pairing_code`.
//! - [`InboundChannel`] — the **inbound seam** (the missing half #403 never
//!   abstracted). A thin object-safe trait exposing `channel()` + an async
//!   `run(sink, cancel)`: each adapter OWNS its own loop and feeds the
//!   channel-agnostic delivery sink. The shared boundary is message
//!   **delivery**, not retrieval — so a poll-based source (iMessage) and a
//!   push-based source (Slack) both fit without either faking the other's
//!   change-detection model (the `inbound-delivery-model` technical call).
//!
//! Cross-platform on purpose (`macos-cfg-gating` call): the registry, the
//! trait, `ChannelId`, and `ChannelConfig` compile on every platform. Only the
//! iMessage ADAPTER impl + its chat.db readers carry `#[cfg(target_os =
//! "macos")]`. There are NO cargo feature flags (CLAUDE.md hard rule #1).

use async_trait::async_trait;
use serde::{Deserialize, Serialize};

use crate::host::HostState;
use car_ffi_common::integrations::InboundMessage;

/// The closed set of approval channels. **Exhaustively matched everywhere** —
/// no `_ =>` wildcard (CLAUDE.md rule #2), so a future channel forces every
/// match site to be revisited rather than silently swallowed.
///
/// Serialized as a stable lowercase string (`"imessage"` / `"slack"`) so it is
/// a deterministic `BTreeMap` key on disk (Unit 2) and a back-compat wire token
/// (Unit 6 adds the optional `channel` request field, defaulting to
/// `IMessage`). `Ord` is derived for the `BTreeMap` key ordering, giving
/// `messaging.json` a deterministic channel order regardless of insert order.
#[derive(
    Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize,
)]
pub enum ChannelId {
    /// Apple iMessage — the FIRST adapter (#403's transport, re-homed). The
    /// back-compat default for the WS surface.
    #[serde(rename = "imessage")]
    IMessage,
    /// Slack — Socket Mode inbound + Web API + Block Kit buttons (Unit 4). The
    /// maximally-different second channel that proves the seam holds.
    #[serde(rename = "slack")]
    Slack,
}

impl ChannelId {
    /// Every channel, in `Ord` order. The cross-platform boot/registry iterates
    /// this to spawn each enabled adapter (Unit 3). Exhaustive by construction —
    /// adding a variant means adding it here.
    pub const ALL: [ChannelId; 2] = [ChannelId::IMessage, ChannelId::Slack];

    /// The stable string key (matches the serde rename). Exhaustive match — no
    /// wildcard.
    pub fn as_str(self) -> &'static str {
        match self {
            ChannelId::IMessage => "imessage",
            ChannelId::Slack => "slack",
        }
    }

    /// Parse the stable string key back to a `ChannelId`. The WS surface uses
    /// this to map the optional `channel` request field (Unit 6); an absent
    /// field defaults to `IMessage` at the call site, not here.
    pub fn from_str_opt(s: &str) -> Option<ChannelId> {
        match s {
            "imessage" => Some(ChannelId::IMessage),
            "slack" => Some(ChannelId::Slack),
            _ => None,
        }
    }
}

impl Default for ChannelId {
    /// iMessage is the back-compat default (the only channel #403 shipped).
    fn default() -> Self {
        ChannelId::IMessage
    }
}

/// A persisted, serializable reference to a channel's tokens in the OS keychain
/// (MC-9). It carries ONLY the keychain key NAMES the bearer values live under,
/// never the `xoxb-`/`xapp-` strings themselves — so `messaging.json` holds a
/// token *reference*, not a token. Its presence in a [`ChannelConfig`] doubles
/// as the "tokens have been provisioned" marker (the adapter + UI can tell that
/// credentials exist without reading them).
///
/// This is the on-disk twin of the in-memory
/// [`crate::slack_adapter::SlackTokenRefs`] the provisioning write path returns;
/// they hold the same key names but this one is `Serialize`/`Deserialize` for
/// the durable config.
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
pub struct SlackTokenRef {
    /// Keychain key NAME the bot token (`xoxb-`) is stored under. Never the
    /// bearer value.
    pub bot_token_key: String,
    /// Keychain key NAME the app-level token (`xapp-`) is stored under. Never
    /// the bearer value.
    pub app_token_key: String,
}

/// Per-channel trust state. The first three fields are exactly #403's iMessage
/// config (`enabled`, `allowlisted_handles`, `active_pairing_code`) — now held
/// once per channel under
/// [`crate::messaging_config::MessagingConfig::channels`]. The Slack-only
/// `slack_token_ref` holds the keychain REFERENCE for that channel's provisioned
/// tokens (MC-9 — a ref, never a bearer), `None` for iMessage / an
/// un-provisioned Slack channel.
///
/// `Default` is **fail-closed**: `enabled = false` (MC-5 — both channels start
/// disabled), empty allowlist, no pairing code, no token ref.
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
pub struct ChannelConfig {
    /// Master opt-in flag for THIS channel. Default `false` — the channel is
    /// silent until the host UI flips it on.
    #[serde(default)]
    pub enabled: bool,
    /// Handles permitted to resolve approvals over THIS channel. A handle lands
    /// here ONLY via a host-gated `messaging.config.set` add or a code-proven
    /// pairing — never from an inbound message.
    #[serde(default)]
    pub allowlisted_handles: Vec<String>,
    /// The currently-active pairing code for THIS channel, if a pairing is in
    /// flight. `None` when no pairing is pending; cleared on a successful bind.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub active_pairing_code: Option<String>,
    /// Keychain reference for THIS channel's provisioned tokens (Slack only,
    /// MC-9). `Some` once `messaging.config.set` has provisioned `bot_token` +
    /// `app_token` into the keychain; the value is a *reference* (key names),
    /// never a bearer. `None` for iMessage and for an un-provisioned Slack
    /// channel. Doubles as the "provisioned" marker.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub slack_token_ref: Option<SlackTokenRef>,
    /// The Slack conversation/channel id (`C0123…` / `D024…`) the outbound
    /// approval prompt posts into (Slack only). This is **configuration, not a
    /// secret** — unlike the tokens it is persisted IN `messaging.json` (NOT the
    /// keychain), set on the same host-gated `messaging.config.set` call as the
    /// tokens. `None` for iMessage and for a Slack channel that has not had its
    /// post-channel set yet (the adapter is then built but cannot post — a
    /// safe no-op outbound).
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub slack_channel_id: Option<String>,
}

/// The cancel signal the boot path flips on daemon shutdown. A
/// `tokio::sync::watch::Receiver<bool>` set to `true` tells an adapter's
/// `run()` loop to stop. Shared shape across channels (iMessage poll loop,
/// Slack reconnect loop).
pub type CancelSignal = tokio::sync::watch::Receiver<bool>;

/// The channel-agnostic inbound DELIVERY sink. An adapter feeds each inbound
/// message it observes to `deliver`; the sink routes it to the host's approval
/// semantics (the iMessage adapter wires this to its `handle_inbound`). The
/// boundary is delivery, NOT retrieval — `InboundMessage` carries only what
/// `handle_inbound` reads (`handle_id` + `body`); the watermark stays private to
/// the iMessage adapter's poll loop and never crosses this sink.
#[async_trait]
pub trait InboundSink: Send + Sync {
    /// Deliver one observed inbound message into the channel-agnostic approval
    /// path. Async because resolution touches the async `HostState`.
    async fn deliver(&self, channel: ChannelId, msg: &InboundMessage);
}

/// The **inbound seam** — the abstraction #403 never had. Each channel adapter
/// implements this: it names its [`ChannelId`] and OWNS its own run loop,
/// feeding observed messages into the supplied [`InboundSink`]. Object-safe via
/// `#[async_trait]` (the cross-platform registry holds `Box<dyn InboundChannel>`).
///
/// `run()` is async — each adapter drives its own cadence: iMessage polls on a
/// bounded interval; Slack holds a Socket Mode WebSocket. Neither fakes the
/// other's model.
#[async_trait]
pub trait InboundChannel: Send + Sync {
    /// Which channel this adapter serves.
    fn channel(&self) -> ChannelId;

    /// Run the adapter's inbound loop until `cancel` flips to `true`. The
    /// adapter feeds every observed inbound message to `sink.deliver(...)`.
    async fn run(&self, sink: &dyn InboundSink, cancel: CancelSignal);
}

/// Cross-platform spawn handle bundle: the cancel sender the boot path holds to
/// stop every spawned adapter loop on shutdown. Returned by the registry boot.
pub struct ChannelRegistryHandles {
    /// Flip to `true` to stop every spawned adapter's `run()` loop.
    pub cancel_tx: tokio::sync::watch::Sender<bool>,
}

/// Reference to the shared host state an adapter resolves approvals against.
/// Kept here (cross-platform) so the registry signature does not leak any
/// macOS-only type. The iMessage adapter wraps this in its own
/// `MessagingOrchestrator`.
pub type SharedHost = std::sync::Arc<HostState>;