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}