Skip to main content

bext_plugin_api/
session.rs

1//! Session capability trait and types.
2//!
3//! A session store owns the lifecycle of session records. The trait is
4//! deliberately storage-agnostic: it accommodates both server-backed stores
5//! (e.g. Redis, Postgres) where the cookie carries only an opaque id, and
6//! stateless client-side stores (e.g. signed/encrypted cookie) where the
7//! "id" is itself the ciphertext and no server state is kept.
8//!
9//! Design notes:
10//!
11//! - `SessionId` is an opaque string. Server-backed stores put a uuid or
12//!   similar random token there. Cookie-only stores put the encrypted +
13//!   signed payload there. The host treats it as opaque and writes it into
14//!   the session cookie; backends know how to interpret it on read.
15//!
16//! - `create` and `update` both return a `SessionId`. For server-backed
17//!   stores the id is stable across updates; for cookie-only stores the id
18//!   changes on every mutation (because the ciphertext changes) and the
19//!   host must re-set the cookie. Callers should always write back the id
20//!   returned by `update`.
21//!
22//! - Session data is passed as a JSON string so the trait surface stays
23//!   WASM-ABI friendly, matching the style used in `lifecycle.rs`. The
24//!   schema inside the blob is defined by the caller (typically an Auth
25//!   plugin storing a user id + claims; an app storing cart state; etc.).
26//!
27//! - `user_id` is optional so anonymous sessions (pre-login carts, guest
28//!   checkout, CSRF tokens) are first-class. Auth plugins that require a
29//!   logged-in user enforce that themselves.
30//!
31//! - This trait deliberately does not import any Auth types. Session is
32//!   the substrate Auth sits on, not the other way round. An Auth plugin
33//!   uses a `SessionPlugin` to persist proof-of-identity; the session store
34//!   does not know or care what that proof looks like.
35
36use std::collections::HashMap;
37
38/// Opaque session identifier written into the session cookie.
39///
40/// Server-backed stores use this as a lookup key (e.g. a uuid). Cookie-only
41/// stores encode the entire (encrypted, signed) session payload here. The
42/// host treats it as an opaque string.
43#[derive(Debug, Clone, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
44pub struct SessionId(pub String);
45
46impl SessionId {
47    pub fn new(s: impl Into<String>) -> Self {
48        Self(s.into())
49    }
50
51    pub fn as_str(&self) -> &str {
52        &self.0
53    }
54}
55
56/// A session record as observed by callers.
57///
58/// `data_json` is a JSON-encoded object whose schema is defined by the
59/// caller. `user_id` is `None` for anonymous sessions. `expires_at_ms` is
60/// the absolute unix millisecond at which the session is considered
61/// expired; backends may garbage-collect after that point.
62#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
63pub struct SessionRecord {
64    pub id: SessionId,
65    pub user_id: Option<String>,
66    pub data_json: String,
67    pub created_at_ms: u64,
68    pub expires_at_ms: u64,
69}
70
71/// Options for creating a new session.
72#[derive(Debug, Clone, Default)]
73pub struct CreateOptions {
74    /// Optional user id. `None` for anonymous sessions.
75    pub user_id: Option<String>,
76    /// Initial session data as JSON. Use `"{}"` for an empty session.
77    pub data_json: String,
78    /// Session lifetime in milliseconds. Backends interpret this as the
79    /// cookie Max-Age and/or the server-side TTL.
80    pub ttl_ms: u64,
81}
82
83/// Errors a session store can return. Kept as a flat enum so the shape is
84/// stable across backends; backend-specific detail goes in the wrapped
85/// message.
86#[derive(Debug, Clone)]
87pub enum SessionError {
88    /// The id did not exist, or was present but has expired.
89    NotFound,
90    /// The id was present but failed integrity / signature checks
91    /// (cookie-only stores) or was malformed (server-backed stores).
92    Invalid(String),
93    /// Transport / storage layer failure (network, disk, etc.).
94    Backend(String),
95}
96
97impl std::fmt::Display for SessionError {
98    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
99        match self {
100            Self::NotFound => f.write_str("session not found"),
101            Self::Invalid(m) => write!(f, "invalid session: {m}"),
102            Self::Backend(m) => write!(f, "session backend error: {m}"),
103        }
104    }
105}
106
107impl std::error::Error for SessionError {}
108
109/// Storage backend for sessions.
110///
111/// Implementations fall into two families:
112///
113/// 1. **Server-backed** (`@bext/session-redis`, `@bext/session-pg`). The
114///    `SessionId` is a short random token used as a lookup key into an
115///    external store. `update` returns the same id it received; `delete`
116///    removes the record; `touch` bumps the TTL cheaply.
117///
118/// 2. **Client-backed** (`@bext/session-cookie`). The `SessionId` is the
119///    encrypted, signed session payload itself — there is no server state.
120///    `update` returns a *new* id (the freshly-encrypted payload), `delete`
121///    is a no-op from the store's perspective (the host clears the cookie),
122///    and `touch` can re-sign the payload with a refreshed expiry.
123///
124/// Callers MUST treat the id returned by `create` / `update` / `touch` as
125/// authoritative and write it back into the session cookie. Assuming the id
126/// is stable breaks cookie-only stores.
127///
128/// All methods take `&self` — implementations are expected to hold any
129/// mutable state behind their own synchronisation (e.g. a connection pool).
130pub trait SessionPlugin: Send + Sync {
131    /// Unique identifier for this backend (e.g. `"cookie"`, `"redis"`).
132    fn name(&self) -> &str;
133
134    /// Create a new session. Returns the id the host should write into the
135    /// session cookie.
136    fn create(&self, opts: CreateOptions) -> Result<SessionId, SessionError>;
137
138    /// Look up an existing session. Returns `NotFound` if the id is
139    /// unknown or has expired; `Invalid` if integrity checks fail.
140    fn read(&self, id: &SessionId) -> Result<SessionRecord, SessionError>;
141
142    /// Replace session data. Returns the id that should subsequently be
143    /// used — for server-backed stores this is the same id; for cookie
144    /// stores the id changes on every mutation.
145    fn update(&self, id: &SessionId, data_json: &str) -> Result<SessionId, SessionError>;
146
147    /// Extend the session's expiry without rewriting data. Returns the id
148    /// that should subsequently be used (same caveat as `update`).
149    ///
150    /// For server-backed stores this is a cheap TTL bump; for cookie
151    /// stores it may re-sign the payload with a new expiry.
152    fn touch(&self, id: &SessionId, ttl_ms: u64) -> Result<SessionId, SessionError>;
153
154    /// Remove the session. For server-backed stores this deletes the
155    /// record; for cookie stores this is a no-op (the host is expected to
156    /// clear the cookie). Idempotent: deleting an unknown id is `Ok(())`.
157    fn delete(&self, id: &SessionId) -> Result<(), SessionError>;
158
159    /// Health check. Default: always healthy. Server-backed stores should
160    /// override this to ping their transport.
161    fn is_healthy(&self) -> bool {
162        true
163    }
164}
165
166/// Convenience helper for tests and simple callers. Not part of the trait
167/// so it does not force a particular JSON shape on backends.
168pub fn empty_data_json() -> String {
169    "{}".to_string()
170}
171
172/// Convenience helper for constructing a `HashMap`-backed data blob when
173/// the caller does not want to pull in serde_json directly. Returns a JSON
174/// object string.
175pub fn encode_data_map(map: &HashMap<String, String>) -> String {
176    serde_json::to_string(map).unwrap_or_else(|_| "{}".to_string())
177}