bext-plugin-api 0.2.0

Plugin trait definitions and shared types for bext — the public ABI for plugin authors
Documentation
//! Session capability trait and types.
//!
//! A session store owns the lifecycle of session records. The trait is
//! deliberately storage-agnostic: it accommodates both server-backed stores
//! (e.g. Redis, Postgres) where the cookie carries only an opaque id, and
//! stateless client-side stores (e.g. signed/encrypted cookie) where the
//! "id" is itself the ciphertext and no server state is kept.
//!
//! Design notes:
//!
//! - `SessionId` is an opaque string. Server-backed stores put a uuid or
//!   similar random token there. Cookie-only stores put the encrypted +
//!   signed payload there. The host treats it as opaque and writes it into
//!   the session cookie; backends know how to interpret it on read.
//!
//! - `create` and `update` both return a `SessionId`. For server-backed
//!   stores the id is stable across updates; for cookie-only stores the id
//!   changes on every mutation (because the ciphertext changes) and the
//!   host must re-set the cookie. Callers should always write back the id
//!   returned by `update`.
//!
//! - Session data is passed as a JSON string so the trait surface stays
//!   WASM-ABI friendly, matching the style used in `lifecycle.rs`. The
//!   schema inside the blob is defined by the caller (typically an Auth
//!   plugin storing a user id + claims; an app storing cart state; etc.).
//!
//! - `user_id` is optional so anonymous sessions (pre-login carts, guest
//!   checkout, CSRF tokens) are first-class. Auth plugins that require a
//!   logged-in user enforce that themselves.
//!
//! - This trait deliberately does not import any Auth types. Session is
//!   the substrate Auth sits on, not the other way round. An Auth plugin
//!   uses a `SessionPlugin` to persist proof-of-identity; the session store
//!   does not know or care what that proof looks like.

use std::collections::HashMap;

/// Opaque session identifier written into the session cookie.
///
/// Server-backed stores use this as a lookup key (e.g. a uuid). Cookie-only
/// stores encode the entire (encrypted, signed) session payload here. The
/// host treats it as an opaque string.
#[derive(Debug, Clone, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
pub struct SessionId(pub String);

impl SessionId {
    pub fn new(s: impl Into<String>) -> Self {
        Self(s.into())
    }

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

/// A session record as observed by callers.
///
/// `data_json` is a JSON-encoded object whose schema is defined by the
/// caller. `user_id` is `None` for anonymous sessions. `expires_at_ms` is
/// the absolute unix millisecond at which the session is considered
/// expired; backends may garbage-collect after that point.
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct SessionRecord {
    pub id: SessionId,
    pub user_id: Option<String>,
    pub data_json: String,
    pub created_at_ms: u64,
    pub expires_at_ms: u64,
}

/// Options for creating a new session.
#[derive(Debug, Clone, Default)]
pub struct CreateOptions {
    /// Optional user id. `None` for anonymous sessions.
    pub user_id: Option<String>,
    /// Initial session data as JSON. Use `"{}"` for an empty session.
    pub data_json: String,
    /// Session lifetime in milliseconds. Backends interpret this as the
    /// cookie Max-Age and/or the server-side TTL.
    pub ttl_ms: u64,
}

/// Errors a session store can return. Kept as a flat enum so the shape is
/// stable across backends; backend-specific detail goes in the wrapped
/// message.
#[derive(Debug, Clone)]
pub enum SessionError {
    /// The id did not exist, or was present but has expired.
    NotFound,
    /// The id was present but failed integrity / signature checks
    /// (cookie-only stores) or was malformed (server-backed stores).
    Invalid(String),
    /// Transport / storage layer failure (network, disk, etc.).
    Backend(String),
}

impl std::fmt::Display for SessionError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            Self::NotFound => f.write_str("session not found"),
            Self::Invalid(m) => write!(f, "invalid session: {m}"),
            Self::Backend(m) => write!(f, "session backend error: {m}"),
        }
    }
}

impl std::error::Error for SessionError {}

/// Storage backend for sessions.
///
/// Implementations fall into two families:
///
/// 1. **Server-backed** (`@bext/session-redis`, `@bext/session-pg`). The
///    `SessionId` is a short random token used as a lookup key into an
///    external store. `update` returns the same id it received; `delete`
///    removes the record; `touch` bumps the TTL cheaply.
///
/// 2. **Client-backed** (`@bext/session-cookie`). The `SessionId` is the
///    encrypted, signed session payload itself — there is no server state.
///    `update` returns a *new* id (the freshly-encrypted payload), `delete`
///    is a no-op from the store's perspective (the host clears the cookie),
///    and `touch` can re-sign the payload with a refreshed expiry.
///
/// Callers MUST treat the id returned by `create` / `update` / `touch` as
/// authoritative and write it back into the session cookie. Assuming the id
/// is stable breaks cookie-only stores.
///
/// All methods take `&self` — implementations are expected to hold any
/// mutable state behind their own synchronisation (e.g. a connection pool).
pub trait SessionPlugin: Send + Sync {
    /// Unique identifier for this backend (e.g. `"cookie"`, `"redis"`).
    fn name(&self) -> &str;

    /// Create a new session. Returns the id the host should write into the
    /// session cookie.
    fn create(&self, opts: CreateOptions) -> Result<SessionId, SessionError>;

    /// Look up an existing session. Returns `NotFound` if the id is
    /// unknown or has expired; `Invalid` if integrity checks fail.
    fn read(&self, id: &SessionId) -> Result<SessionRecord, SessionError>;

    /// Replace session data. Returns the id that should subsequently be
    /// used — for server-backed stores this is the same id; for cookie
    /// stores the id changes on every mutation.
    fn update(&self, id: &SessionId, data_json: &str) -> Result<SessionId, SessionError>;

    /// Extend the session's expiry without rewriting data. Returns the id
    /// that should subsequently be used (same caveat as `update`).
    ///
    /// For server-backed stores this is a cheap TTL bump; for cookie
    /// stores it may re-sign the payload with a new expiry.
    fn touch(&self, id: &SessionId, ttl_ms: u64) -> Result<SessionId, SessionError>;

    /// Remove the session. For server-backed stores this deletes the
    /// record; for cookie stores this is a no-op (the host is expected to
    /// clear the cookie). Idempotent: deleting an unknown id is `Ok(())`.
    fn delete(&self, id: &SessionId) -> Result<(), SessionError>;

    /// Health check. Default: always healthy. Server-backed stores should
    /// override this to ping their transport.
    fn is_healthy(&self) -> bool {
        true
    }
}

/// Convenience helper for tests and simple callers. Not part of the trait
/// so it does not force a particular JSON shape on backends.
pub fn empty_data_json() -> String {
    "{}".to_string()
}

/// Convenience helper for constructing a `HashMap`-backed data blob when
/// the caller does not want to pull in serde_json directly. Returns a JSON
/// object string.
pub fn encode_data_map(map: &HashMap<String, String>) -> String {
    serde_json::to_string(map).unwrap_or_else(|_| "{}".to_string())
}