tail-fin-core 0.6.5

Public session-lifecycle abstractions for tail-fin: Site trait, SessionManager, Credentials, SessionStatus, auth errors. The stable API surface that downstream agents (Flock A2A etc.) consume.
Documentation
//! Cross-site session lifecycle abstractions.
//!
//! Every site crate (`tail-fin-twitter`, `tail-fin-sa`, `tail-fin-reddit`,
//! ...) implements the [`Site`] trait. Orchestration layers ([`SessionManager`]
//! in [`crate::session`], plus Flock A2A agents) consume the trait and don't
//! need to know which site they're talking to beyond the trait contract.
//!
//! See `docs/superpowers/plans/2026-04-17-site-trait-phase-1.md` for the
//! architecture rationale.

use async_trait::async_trait;
use night_fury_core::BrowserSession;
use serde_json::Value;
use std::time::{Duration, SystemTime};

use crate::error::{AuthFailureKind, SiteError};

/// Session liveness state as reported by [`Site::validate`].
#[derive(Debug, Clone)]
pub enum SessionStatus {
    /// Session is healthy; continue using.
    Valid,

    /// Session still works but may expire soon — consider preemptive refresh.
    Degrading {
        estimated_expiry: Option<SystemTime>,
        hint: String,
    },

    /// Session is dead — need to refresh or re-login.
    Expired,

    /// Server is blocking us (captcha, rate limit, anti-bot). Don't retry immediately.
    Blocked {
        reason: String,
        retry_after: Option<Duration>,
    },

    /// Validation itself failed (network error, etc.) — state is unknown.
    Unknown,
}

/// Input to [`Site::detect_auth_failure`]. Not every site uses HTTP status as
/// the failure signal — some return JSON with error codes, some redirect to a
/// login page. This struct carries the raw observations.
#[derive(Debug, Clone, Default)]
pub struct FailureIndicators {
    pub status: Option<u16>,
    /// First ~1KB of the response body, trimmed.
    pub body_preview: String,
    pub final_url: Option<String>,
    pub response_headers: Vec<(String, String)>,
}

/// Per-site lifecycle + identity contract.
///
/// # Implementation guidance
///
/// Required methods (no defaults): `id`, `display_name`, `cookie_domain_patterns`,
/// `refresh_url`, `validate`.
///
/// Optional methods with sensible defaults: `refresh_interval_min`, `refresh`,
/// `attempt_login`, `detect_auth_failure`.
///
/// Override `refresh` only if the site needs more than `navigate + wait + get_cookies`
/// (e.g. Twitter needs a `scroll` to trigger GraphQL that emits Set-Cookie).
#[async_trait]
pub trait Site: Send + Sync + 'static {
    /// Globally unique site identifier. Convention: lowercase, no spaces.
    /// Examples: `"twitter"`, `"seekingalpha"`, `"shopee-sg"`.
    fn id(&self) -> &'static str;

    /// Human-readable name for logging / UI.
    fn display_name(&self) -> &'static str;

    /// Cookie domain glob patterns to filter `get_cookies_for_domain`.
    /// Example: `&["*.twitter.com", "*.x.com"]` for Twitter/X.
    fn cookie_domain_patterns(&self) -> &'static [&'static str];

    /// URL to visit for server-side cookie refresh.
    /// The default [`Self::refresh`] impl navigates here.
    fn refresh_url(&self) -> &'static str;

    /// Minimum interval between refreshes. Callers (e.g. [`crate::session::SessionManager`])
    /// respect this to avoid hammering the site. Default: 60 seconds.
    fn refresh_interval_min(&self) -> Duration {
        Duration::from_secs(60)
    }

    /// Trigger server-side cookie refresh and return fresh cookies for
    /// this site's domains.
    ///
    /// Default impl: navigate to [`Self::refresh_url`], read cookies filtered
    /// by the first entry of [`Self::cookie_domain_patterns`]. Override if
    /// the site needs custom actions (scroll to trigger GraphQL, wait for
    /// specific URL, etc.).
    async fn refresh(&self, session: &BrowserSession) -> Result<Vec<Value>, SiteError> {
        session
            .refresh_cookies(self.refresh_url())
            .await
            .map_err(|e| SiteError::RefreshFailed {
                site: self.id(),
                reason: format!("navigate failed: {e}"),
            })?;

        let pattern = self
            .cookie_domain_patterns()
            .first()
            .copied()
            .unwrap_or("*");

        session
            .get_cookies_for_domain(pattern)
            .await
            .map_err(|e| SiteError::RefreshFailed {
                site: self.id(),
                reason: format!("get_cookies_for_domain failed: {e}"),
            })
    }

    /// Check session liveness. Typically a lightweight HTTP probe against
    /// an authenticated endpoint.
    async fn validate(&self, session: &BrowserSession) -> Result<SessionStatus, SiteError>;

    /// Attempt automated login. Most sites return `ManualLoginRequired`
    /// because login is too complex or involves 2FA — override only for
    /// sites with clean API login flows.
    async fn attempt_login(
        &self,
        _session: &BrowserSession,
        _credentials: &Credentials,
    ) -> Result<Vec<Value>, SiteError> {
        Err(SiteError::ManualLoginRequired { site: self.id() })
    }

    /// Given observations from a failed request, classify the failure mode.
    /// Default returns `None` — caller falls back to generic treatment.
    fn detect_auth_failure(&self, _indicators: &FailureIndicators) -> Option<AuthFailureKind> {
        None
    }
}

/// Credentials for sites that support automated login.
///
/// Phase 1 defines this type but doesn't use it — most sites return
/// `ManualLoginRequired` from `attempt_login`. Phase 3 fleshes out
/// credential storage / vault resolution.
#[derive(Clone)]
pub enum Credentials {
    UsernamePassword {
        username: String,
        password: String, // NOTE: Phase 3 will replace String with SecretString
    },
    OAuth {
        refresh_token: String,
        client_id: String,
    },
    /// "Don't log in — just use these cookies."
    CookieJar(Vec<Value>),
    /// Manual — no stored credentials; login happens out-of-band.
    Manual,
}

impl std::fmt::Debug for Credentials {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        // Never expose actual secrets in debug output.
        match self {
            Credentials::UsernamePassword { username, .. } => f
                .debug_struct("UsernamePassword")
                .field("username", username)
                .field("password", &"***")
                .finish(),
            Credentials::OAuth { client_id, .. } => f
                .debug_struct("OAuth")
                .field("refresh_token", &"***")
                .field("client_id", client_id)
                .finish(),
            Credentials::CookieJar(v) => f.debug_tuple("CookieJar").field(&v.len()).finish(),
            Credentials::Manual => f.debug_struct("Manual").finish(),
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn session_status_debug() {
        let s = SessionStatus::Valid;
        assert!(format!("{s:?}").contains("Valid"));

        let s = SessionStatus::Blocked {
            reason: "captcha".into(),
            retry_after: Some(Duration::from_secs(30)),
        };
        let debug = format!("{s:?}");
        assert!(debug.contains("Blocked"));
        assert!(debug.contains("captcha"));
    }

    #[test]
    fn failure_indicators_default_empty() {
        let f = FailureIndicators::default();
        assert!(f.status.is_none());
        assert!(f.body_preview.is_empty());
        assert!(f.final_url.is_none());
        assert!(f.response_headers.is_empty());
    }

    #[test]
    fn credentials_debug_hides_secrets() {
        let c = Credentials::UsernamePassword {
            username: "alice".into(),
            password: "supersecret".into(),
        };
        let debug = format!("{c:?}");
        assert!(debug.contains("alice"));
        assert!(!debug.contains("supersecret"));
        assert!(debug.contains("***"));
    }

    #[test]
    fn credentials_oauth_debug_hides_token() {
        let c = Credentials::OAuth {
            refresh_token: "tok_xyz_123".into(),
            client_id: "client_abc".into(),
        };
        let debug = format!("{c:?}");
        assert!(debug.contains("client_abc"));
        assert!(!debug.contains("tok_xyz_123"));
    }
}