arcly-http 0.2.2

Enterprise-grade NestJS-inspired web framework on axum: zero-lock DI, declarative controllers, multi-tenant data routing, transactional outbox, ABAC, and a self-documenting OpenAPI surface
Documentation
//! Server-side session store abstraction.
//!
//! `SessionStore` is a trait; the app provides a concrete implementation
//! (e.g. `RedisSessionStore`). `SessionManager` wraps the store, handles
//! the session-ID cookie (signed via HMAC, same as `CookieService`), and
//! exposes `load_from_headers` / `create` / `save` / `delete`.
//!
//! `RequestContext::session()` returns the loaded session (if any) so
//! handlers can read/write typed values without knowing how they're stored.
//!
//! ## Usage
//!
//! ```ignore
//! ctx.provide(SessionManager::new(
//!     RedisSessionStore::new(redis),
//!     SessionConfig {
//!         cookie_name: "arcly_session",
//!         ttl_secs:    3600,
//!         secret:      env_or("SESSION_SECRET", "change-in-prod"),
//!         ..Default::default()
//!     },
//! ));
//! ```

use std::collections::HashMap;
use std::sync::{Arc, RwLock};
use std::time::{SystemTime, UNIX_EPOCH};

use futures::future::BoxFuture;
use serde::{de::DeserializeOwned, Serialize};

use crate::cookie::{CookieConfig, CookieService, SameSite};

// ─── Session ──────────────────────────────────────────────────────────────────

pub type SessionData = HashMap<String, serde_json::Value>;

/// A single server-side session. Thread-safe via an interior `RwLock`.
///
/// **Contract**: never hold the `data` read/write guard across an
/// `.await` — it is a synchronous `std::sync::RwLock`, scoped to this one
/// session. Read what you need into locals, drop the guard, then await.
pub struct Session {
    pub id: String,
    pub data: RwLock<SessionData>,
    pub expires_at: u64,
}

impl Session {
    pub fn new_empty(id: String, ttl_secs: u64) -> Arc<Self> {
        Arc::new(Self {
            id,
            data: RwLock::new(HashMap::new()),
            expires_at: unix_now() + ttl_secs,
        })
    }

    pub fn from_data(id: String, data: SessionData, ttl_secs: u64) -> Arc<Self> {
        Arc::new(Self {
            id,
            data: RwLock::new(data),
            expires_at: unix_now() + ttl_secs,
        })
    }

    pub fn id(&self) -> &str {
        &self.id
    }

    pub fn get<T: DeserializeOwned>(&self, key: &str) -> Option<T> {
        let data = self.data.read().ok()?;
        serde_json::from_value(data.get(key)?.clone()).ok()
    }

    pub fn set<T: Serialize>(&self, key: &str, value: T) {
        if let Ok(v) = serde_json::to_value(value) {
            if let Ok(mut data) = self.data.write() {
                data.insert(key.to_owned(), v);
            }
        }
    }

    pub fn remove(&self, key: &str) {
        if let Ok(mut data) = self.data.write() {
            data.remove(key);
        }
    }

    pub fn is_expired(&self) -> bool {
        unix_now() >= self.expires_at
    }

    pub fn data_as_json(&self) -> String {
        self.data
            .read()
            .ok()
            .and_then(|d| serde_json::to_string(&*d).ok())
            .unwrap_or_else(|| "{}".to_string())
    }
}

pub fn unix_now() -> u64 {
    SystemTime::now()
        .duration_since(UNIX_EPOCH)
        .map(|d| d.as_secs())
        .unwrap_or(0)
}

// ─── SessionStore trait ───────────────────────────────────────────────────────

/// Backend store for sessions. Implement this to plug in Redis, Postgres, etc.
pub trait SessionStore: Send + Sync + 'static {
    /// Load a session by ID. `ttl_secs` is the manager's configured lifetime and
    /// should be used to stamp the returned `Session::expires_at` so the in-memory
    /// expiry matches the store's TTL configuration.
    fn load<'a>(&'a self, id: &'a str, ttl_secs: u64) -> BoxFuture<'a, Option<Arc<Session>>>;
    fn save<'a>(&'a self, session: &'a Session, ttl_secs: u64) -> BoxFuture<'a, bool>;
    fn delete<'a>(&'a self, id: &'a str) -> BoxFuture<'a, bool>;
    fn new_id(&self) -> String;
}

// ─── Configuration ────────────────────────────────────────────────────────────

/// Configuration for [`SessionManager`].
pub struct SessionConfig {
    /// Cookie name for the session ID. Defaults to `"arcly_session"`.
    pub cookie_name: &'static str,
    /// Session lifetime in seconds. Defaults to 3 600.
    pub ttl_secs: u64,
    /// HMAC signing secret for the session-ID cookie.
    pub secret: String,
    /// `Secure` flag on session cookie. Defaults to `true`.
    pub secure: bool,
    /// `HttpOnly` flag on session cookie. Defaults to `true`.
    pub http_only: bool,
    /// `SameSite` policy for session cookie. Defaults to `Lax`.
    pub same_site: SameSite,
}

impl Default for SessionConfig {
    fn default() -> Self {
        Self {
            cookie_name: "arcly_session",
            ttl_secs: 3_600,
            secret: "change-session-secret-in-production".to_string(),
            secure: true,
            http_only: true,
            same_site: SameSite::Lax,
        }
    }
}

// ─── SessionManager ───────────────────────────────────────────────────────────

/// Orchestrates session lifecycle: cookie extraction, store lookup, creation,
/// persistence, and deletion.
///
/// Not `#[Injectable]` — provide via `ctx.provide(SessionManager::new(...))`.
pub struct SessionManager {
    store: Box<dyn SessionStore>,
    ttl_secs: u64,
    cookie: CookieService,
}

impl SessionManager {
    pub fn new(store: impl SessionStore, config: SessionConfig) -> Self {
        let cookie = CookieService::new(CookieConfig {
            name: config.cookie_name,
            secret: config.secret,
            max_age_secs: config.ttl_secs,
            secure: config.secure,
            http_only: config.http_only,
            same_site: config.same_site,
            ..Default::default()
        });
        Self {
            store: Box::new(store),
            ttl_secs: config.ttl_secs,
            cookie,
        }
    }

    /// Reads and verifies the session-ID cookie, then loads the session from
    /// the store. Returns `None` if the cookie is missing, tampered, or the
    /// session has expired / been deleted.
    pub async fn load_from_headers(&self, headers: &axum::http::HeaderMap) -> Option<Arc<Session>> {
        let session_id = self.cookie.extract(headers)?;
        self.store.load(&session_id, self.ttl_secs).await
    }

    /// Create a new empty session (not yet persisted — call `save` after
    /// populating data).
    pub async fn create(&self) -> Arc<Session> {
        Session::new_empty(self.store.new_id(), self.ttl_secs)
    }

    /// Persist `session` to the store with the configured TTL.
    pub async fn save(&self, session: &Session) -> bool {
        self.store.save(session, self.ttl_secs).await
    }

    /// Delete `session` from the store (logout / invalidation).
    pub async fn delete(&self, id: &str) -> bool {
        self.store.delete(id).await
    }

    /// Returns a `Set-Cookie` header value that sets the session-ID cookie.
    pub fn session_cookie(&self, id: &str) -> String {
        self.cookie.bake(id)
    }

    /// Returns a `Set-Cookie` header value that clears the session-ID cookie.
    pub fn clear_cookie(&self) -> String {
        self.cookie.clear()
    }

    /// Hot-swap the session-cookie HMAC secret (delegates to the internal
    /// `CookieService`; previous secret stays valid for the grace window).
    pub fn rotate_cookie_secret(&self, new_secret: &[u8], version: u64) {
        self.cookie.rotate_secret(new_secret, version);
    }
}