use std::collections::HashMap;
use std::sync::Mutex;
use std::time::{Duration, Instant};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
const DEFAULT_SESSION_TTL: Duration = Duration::from_secs(300);
pub const SESSION_SWEEP_INTERVAL: Duration = Duration::from_secs(60);
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct RenderHints {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub qr: Option<QrFormat>,
}
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum QrFormat {
#[default]
Utf8,
PngBase64,
UriOnly,
}
#[derive(Debug, Default, Serialize, Deserialize)]
pub struct CeremonyRequest {
#[serde(default)]
pub session_id: Option<Uuid>,
#[serde(default)]
pub ceremony: Option<String>,
#[serde(default)]
pub data: serde_json::Map<String, serde_json::Value>,
#[serde(default)]
pub render: Option<RenderHints>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CeremonyResponse {
pub session_id: Uuid,
pub prompts: Vec<Prompt>,
pub messages: Vec<Message>,
pub complete: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub error: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub result_data: Option<serde_json::Map<String, serde_json::Value>>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Prompt {
pub key: String,
pub prompt: String,
pub input_type: InputType,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub options: Vec<SelectOption>,
#[serde(default = "default_true")]
pub required: bool,
}
fn default_true() -> bool {
true
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SelectOption {
pub value: String,
pub label: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum InputType {
SelectOne,
SelectMany,
Text,
Secret,
SecretConfirm,
Code,
Entropy,
Fido2,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Message {
pub kind: MessageKind,
pub title: String,
pub content: String,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum MessageKind {
Info,
QrCode,
Summary,
Error,
}
pub struct Session {
pub id: Uuid,
pub ceremony_type: String,
pub bag: serde_json::Map<String, serde_json::Value>,
pub render: RenderHints,
pub created_at: Instant,
pub last_active: Instant,
pub complete: bool,
}
impl Session {
pub fn set(&mut self, key: impl Into<String>, value: serde_json::Value) {
self.bag.insert(key.into(), value);
}
pub fn get(&self, key: &str) -> Option<&serde_json::Value> {
self.bag.get(key)
}
pub fn get_str(&self, key: &str) -> Option<&str> {
self.bag.get(key).and_then(|v| v.as_str())
}
pub fn has(&self, key: &str) -> bool {
self.bag.contains_key(key)
}
pub fn remove(&mut self, key: &str) -> Option<serde_json::Value> {
self.bag.remove(key)
}
}
pub enum EvalResult {
NeedInput {
prompts: Vec<Prompt>,
messages: Vec<Message>,
},
ValidationError {
prompts: Vec<Prompt>,
messages: Vec<Message>,
error: String,
},
Complete {
messages: Vec<Message>,
},
Fatal(String),
}
pub trait CeremonyRules: Send + Sync {
fn validate_ceremony_type(&self, ceremony: &str) -> Result<(), String>;
fn evaluate(
&self,
ceremony_type: &str,
bag: &mut serde_json::Map<String, serde_json::Value>,
render: &RenderHints,
) -> EvalResult;
}
pub struct CeremonyHost<R: CeremonyRules> {
rules: R,
sessions: Mutex<HashMap<Uuid, Session>>,
session_ttl: Duration,
}
impl<R: CeremonyRules> CeremonyHost<R> {
pub fn new(rules: R) -> Self {
Self {
rules,
sessions: Mutex::new(HashMap::new()),
session_ttl: DEFAULT_SESSION_TTL,
}
}
pub fn with_ttl(rules: R, ttl: Duration) -> Self {
Self {
rules,
sessions: Mutex::new(HashMap::new()),
session_ttl: ttl,
}
}
pub fn rules(&self) -> &R {
&self.rules
}
pub fn step(&self, request: CeremonyRequest) -> Result<CeremonyResponse, CeremonyError> {
match request.session_id {
None => self.start_new(request),
Some(id) => self.continue_existing(id, request),
}
}
pub fn sweep_expired(&self) -> usize {
let mut sessions = self.sessions.lock().unwrap_or_else(|e| {
tracing::warn!("ceremony session lock was poisoned, recovering");
e.into_inner()
});
let now = Instant::now();
let before = sessions.len();
sessions.retain(|_id, session| now.duration_since(session.last_active) < self.session_ttl);
let removed = before - sessions.len();
if removed > 0 {
tracing::debug!(
removed,
remaining = sessions.len(),
"Swept expired ceremony sessions"
);
}
removed
}
pub fn active_session_count(&self) -> usize {
self.sessions
.lock()
.unwrap_or_else(|e| {
tracing::warn!("ceremony session lock was poisoned, recovering");
e.into_inner()
})
.len()
}
fn start_new(&self, request: CeremonyRequest) -> Result<CeremonyResponse, CeremonyError> {
let ceremony = request
.ceremony
.as_deref()
.ok_or_else(|| CeremonyError::MissingField("ceremony".into()))?;
self.rules
.validate_ceremony_type(ceremony)
.map_err(CeremonyError::InvalidCeremony)?;
let render = request.render.unwrap_or_default();
let now = Instant::now();
let mut session = Session {
id: Uuid::now_v7(),
ceremony_type: ceremony.to_string(),
bag: request.data,
render: render.clone(),
created_at: now,
last_active: now,
complete: false,
};
let result = self.rules.evaluate(ceremony, &mut session.bag, &render);
self.finalize(session, result)
}
fn continue_existing(
&self,
session_id: Uuid,
request: CeremonyRequest,
) -> Result<CeremonyResponse, CeremonyError> {
let mut sessions = self.sessions.lock().unwrap_or_else(|e| {
tracing::warn!("ceremony session lock was poisoned, recovering");
e.into_inner()
});
let session = sessions
.get_mut(&session_id)
.ok_or(CeremonyError::SessionNotFound(session_id))?;
let now = Instant::now();
if now.duration_since(session.last_active) >= self.session_ttl {
sessions.remove(&session_id);
return Err(CeremonyError::SessionExpired);
}
if session.complete {
return Err(CeremonyError::AlreadyComplete);
}
session.last_active = now;
if let Some(render) = &request.render {
session.render = render.clone();
}
for (key, value) in request.data {
session.bag.insert(key, value);
}
let render = session.render.clone();
let ceremony_type = session.ceremony_type.clone();
let result = self
.rules
.evaluate(&ceremony_type, &mut session.bag, &render);
let Some(session) = sessions.remove(&session_id) else {
return Err(CeremonyError::SessionNotFound(session_id));
};
drop(sessions);
self.finalize(session, result)
}
fn finalize(
&self,
mut session: Session,
result: EvalResult,
) -> Result<CeremonyResponse, CeremonyError> {
let session_id = session.id;
let (prompts, messages, complete, error) = match result {
EvalResult::NeedInput { prompts, messages } => (prompts, messages, false, None),
EvalResult::ValidationError {
prompts,
messages,
error,
} => (prompts, messages, false, Some(error)),
EvalResult::Complete { messages } => (Vec::new(), messages, true, None),
EvalResult::Fatal(msg) => {
let messages = vec![Message {
kind: MessageKind::Error,
title: "Ceremony failed".into(),
content: msg.clone(),
}];
(Vec::new(), messages, true, Some(msg))
}
};
session.complete = complete;
let result_data = if complete && error.is_none() {
Some(session.bag.clone())
} else {
None
};
if !complete {
let mut sessions = self.sessions.lock().unwrap_or_else(|e| {
tracing::warn!("ceremony session lock was poisoned, recovering");
e.into_inner()
});
sessions.insert(session_id, session);
}
Ok(CeremonyResponse {
session_id,
prompts,
messages,
complete,
error,
result_data,
})
}
}
#[derive(Debug, thiserror::Error)]
pub enum CeremonyError {
#[error("session not found: {0}")]
SessionNotFound(Uuid),
#[error("session expired")]
SessionExpired,
#[error("missing required field: {0}")]
MissingField(String),
#[error("invalid ceremony type: {0}")]
InvalidCeremony(String),
#[error("ceremony already complete")]
AlreadyComplete,
#[error("internal error: {0}")]
Internal(String),
}
impl CeremonyError {
pub fn http_status(&self) -> u16 {
match self {
Self::SessionNotFound(_) => 404,
Self::SessionExpired => 410,
Self::MissingField(_) => 400,
Self::InvalidCeremony(_) => 400,
Self::AlreadyComplete => 409,
Self::Internal(_) => 500,
}
}
}
impl Prompt {
pub fn select_one(
key: impl Into<String>,
prompt: impl Into<String>,
options: Vec<SelectOption>,
) -> Self {
Self {
key: key.into(),
prompt: prompt.into(),
input_type: InputType::SelectOne,
options,
required: true,
}
}
pub fn secret(key: impl Into<String>, prompt: impl Into<String>) -> Self {
Self {
key: key.into(),
prompt: prompt.into(),
input_type: InputType::Secret,
options: Vec::new(),
required: true,
}
}
pub fn secret_confirm(key: impl Into<String>, prompt: impl Into<String>) -> Self {
Self {
key: key.into(),
prompt: prompt.into(),
input_type: InputType::SecretConfirm,
options: Vec::new(),
required: true,
}
}
pub fn code(key: impl Into<String>, prompt: impl Into<String>) -> Self {
Self {
key: key.into(),
prompt: prompt.into(),
input_type: InputType::Code,
options: Vec::new(),
required: true,
}
}
pub fn text(key: impl Into<String>, prompt: impl Into<String>) -> Self {
Self {
key: key.into(),
prompt: prompt.into(),
input_type: InputType::Text,
options: Vec::new(),
required: true,
}
}
pub fn entropy(key: impl Into<String>, prompt: impl Into<String>) -> Self {
Self {
key: key.into(),
prompt: prompt.into(),
input_type: InputType::Entropy,
options: Vec::new(),
required: true,
}
}
pub fn fido2(key: impl Into<String>, prompt: impl Into<String>) -> Self {
Self {
key: key.into(),
prompt: prompt.into(),
input_type: InputType::Fido2,
options: Vec::new(),
required: true,
}
}
}
impl SelectOption {
pub fn new(value: impl Into<String>, label: impl Into<String>) -> Self {
Self {
value: value.into(),
label: label.into(),
description: None,
}
}
pub fn with_description(
value: impl Into<String>,
label: impl Into<String>,
description: impl Into<String>,
) -> Self {
Self {
value: value.into(),
label: label.into(),
description: Some(description.into()),
}
}
}
impl Message {
pub fn info(title: impl Into<String>, content: impl Into<String>) -> Self {
Self {
kind: MessageKind::Info,
title: title.into(),
content: content.into(),
}
}
pub fn qr_code(title: impl Into<String>, content: impl Into<String>) -> Self {
Self {
kind: MessageKind::QrCode,
title: title.into(),
content: content.into(),
}
}
pub fn summary(title: impl Into<String>, content: impl Into<String>) -> Self {
Self {
kind: MessageKind::Summary,
title: title.into(),
content: content.into(),
}
}
pub fn error(title: impl Into<String>, content: impl Into<String>) -> Self {
Self {
kind: MessageKind::Error,
title: title.into(),
content: content.into(),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
struct GreetRules;
impl CeremonyRules for GreetRules {
fn validate_ceremony_type(&self, ceremony: &str) -> Result<(), String> {
match ceremony {
"greet" => Ok(()),
other => Err(format!("unknown ceremony: {other}")),
}
}
fn evaluate(
&self,
_ceremony_type: &str,
bag: &mut serde_json::Map<String, serde_json::Value>,
_render: &RenderHints,
) -> EvalResult {
match bag.get("name").and_then(|v| v.as_str()) {
None => {
EvalResult::NeedInput {
prompts: vec![Prompt::text("name", "What is your name?")],
messages: vec![Message::info("Welcome", "Please introduce yourself.")],
}
}
Some("") => {
bag.remove("name");
EvalResult::ValidationError {
prompts: vec![Prompt::text("name", "What is your name?")],
messages: Vec::new(),
error: "Name cannot be empty".into(),
}
}
Some(name) => {
let summary = format!("Hello, {name}!");
EvalResult::Complete {
messages: vec![Message::summary("Greeting complete", &summary)],
}
}
}
}
}
fn make_host() -> CeremonyHost<GreetRules> {
CeremonyHost::new(GreetRules)
}
#[test]
fn start_new_ceremony_returns_prompts() {
let host = make_host();
let resp = host
.step(CeremonyRequest {
session_id: None,
ceremony: Some("greet".into()),
data: serde_json::Map::new(),
render: None,
})
.unwrap();
assert!(!resp.complete);
assert_eq!(resp.prompts.len(), 1);
assert_eq!(resp.prompts[0].key, "name");
assert_eq!(resp.prompts[0].input_type, InputType::Text);
assert_eq!(resp.messages.len(), 1);
assert_eq!(resp.messages[0].kind, MessageKind::Info);
assert_eq!(host.active_session_count(), 1);
}
#[test]
fn complete_ceremony_with_data() {
let host = make_host();
let r1 = host
.step(CeremonyRequest {
session_id: None,
ceremony: Some("greet".into()),
data: serde_json::Map::new(),
render: None,
})
.unwrap();
assert!(!r1.complete);
let mut data = serde_json::Map::new();
data.insert("name".into(), serde_json::json!("Alice"));
let r2 = host
.step(CeremonyRequest {
session_id: Some(r1.session_id),
ceremony: None,
data,
render: None,
})
.unwrap();
assert!(r2.complete);
assert!(r2.prompts.is_empty());
assert_eq!(r2.messages.len(), 1);
assert_eq!(r2.messages[0].kind, MessageKind::Summary);
assert!(r2.messages[0].content.contains("Alice"));
assert_eq!(host.active_session_count(), 0);
}
#[test]
fn prefill_completes_in_one_step() {
let host = make_host();
let mut data = serde_json::Map::new();
data.insert("name".into(), serde_json::json!("Bob"));
let resp = host
.step(CeremonyRequest {
session_id: None,
ceremony: Some("greet".into()),
data,
render: None,
})
.unwrap();
assert!(resp.complete);
assert!(resp.prompts.is_empty());
assert!(resp.messages[0].content.contains("Bob"));
assert_eq!(host.active_session_count(), 0);
}
#[test]
fn validation_error_re_prompts() {
let host = make_host();
let r1 = host
.step(CeremonyRequest {
session_id: None,
ceremony: Some("greet".into()),
data: serde_json::Map::new(),
render: None,
})
.unwrap();
let mut data = serde_json::Map::new();
data.insert("name".into(), serde_json::json!(""));
let r2 = host
.step(CeremonyRequest {
session_id: Some(r1.session_id),
ceremony: None,
data,
render: None,
})
.unwrap();
assert!(!r2.complete);
assert_eq!(r2.error.as_deref(), Some("Name cannot be empty"));
assert_eq!(r2.prompts.len(), 1);
assert_eq!(r2.prompts[0].key, "name");
assert_eq!(host.active_session_count(), 1);
let mut data = serde_json::Map::new();
data.insert("name".into(), serde_json::json!("Charlie"));
let r3 = host
.step(CeremonyRequest {
session_id: Some(r2.session_id),
ceremony: None,
data,
render: None,
})
.unwrap();
assert!(r3.complete);
assert!(r3.messages[0].content.contains("Charlie"));
}
#[test]
fn invalid_ceremony_type() {
let host = make_host();
let err = host
.step(CeremonyRequest {
session_id: None,
ceremony: Some("bogus".into()),
data: serde_json::Map::new(),
render: None,
})
.unwrap_err();
assert!(matches!(err, CeremonyError::InvalidCeremony(_)));
assert_eq!(err.http_status(), 400);
}
#[test]
fn missing_ceremony_field() {
let host = make_host();
let err = host
.step(CeremonyRequest {
session_id: None,
ceremony: None,
data: serde_json::Map::new(),
render: None,
})
.unwrap_err();
assert!(matches!(err, CeremonyError::MissingField(_)));
}
#[test]
fn unknown_session_returns_not_found() {
let host = make_host();
let err = host
.step(CeremonyRequest {
session_id: Some(Uuid::now_v7()),
ceremony: None,
data: serde_json::Map::new(),
render: None,
})
.unwrap_err();
assert!(matches!(err, CeremonyError::SessionNotFound(_)));
assert_eq!(err.http_status(), 404);
}
#[test]
fn sweep_removes_expired() {
let host = CeremonyHost::with_ttl(GreetRules, Duration::from_millis(1));
let _ = host
.step(CeremonyRequest {
session_id: None,
ceremony: Some("greet".into()),
data: serde_json::Map::new(),
render: None,
})
.unwrap();
assert_eq!(host.active_session_count(), 1);
std::thread::sleep(Duration::from_millis(10));
let removed = host.sweep_expired();
assert_eq!(removed, 1);
assert_eq!(host.active_session_count(), 0);
}
#[test]
fn render_hints_propagate() {
let host = make_host();
let resp = host
.step(CeremonyRequest {
session_id: None,
ceremony: Some("greet".into()),
data: serde_json::Map::new(),
render: Some(RenderHints {
qr: Some(QrFormat::PngBase64),
}),
})
.unwrap();
let sessions = host.sessions.lock().unwrap();
let session = sessions.get(&resp.session_id).unwrap();
assert_eq!(session.render.qr, Some(QrFormat::PngBase64));
}
#[test]
fn qr_format_serde_round_trip() {
let hints = RenderHints {
qr: Some(QrFormat::PngBase64),
};
let json = serde_json::to_string(&hints).unwrap();
assert!(json.contains("png_base64"));
let parsed: RenderHints = serde_json::from_str(&json).unwrap();
assert_eq!(parsed.qr, Some(QrFormat::PngBase64));
}
#[test]
fn prompt_and_message_serde() {
let prompt = Prompt::select_one(
"color",
"Pick a color",
vec![
SelectOption::new("red", "Red"),
SelectOption::with_description("blue", "Blue", "The color of the sky"),
],
);
let json = serde_json::to_value(&prompt).unwrap();
assert_eq!(json["key"], "color");
assert_eq!(json["input_type"], "select_one");
assert_eq!(json["options"].as_array().unwrap().len(), 2);
let msg = Message::qr_code("Scan me", "data:image/png;base64,abc123");
let json = serde_json::to_value(&msg).unwrap();
assert_eq!(json["kind"], "qr_code");
}
#[test]
fn complete_response_serde() {
let resp = CeremonyResponse {
session_id: Uuid::now_v7(),
prompts: vec![Prompt::text("foo", "Enter foo")],
messages: vec![Message::info("Note", "Something")],
complete: false,
error: None,
result_data: None,
};
let json = serde_json::to_string(&resp).unwrap();
let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
assert_eq!(parsed["complete"], false);
assert!(parsed["prompts"].is_array());
assert!(parsed["messages"].is_array());
assert!(parsed.get("error").is_none());
}
struct MultiRules;
impl CeremonyRules for MultiRules {
fn validate_ceremony_type(&self, ceremony: &str) -> Result<(), String> {
match ceremony {
"multi" => Ok(()),
other => Err(format!("unknown: {other}")),
}
}
fn evaluate(
&self,
_ceremony_type: &str,
bag: &mut serde_json::Map<String, serde_json::Value>,
_render: &RenderHints,
) -> EvalResult {
let has_color = bag.get("color").and_then(|v| v.as_str()).is_some();
let has_size = bag.get("size").and_then(|v| v.as_str()).is_some();
let has_confirm = bag.get("confirm").and_then(|v| v.as_str()).is_some();
if !has_color || !has_size {
let mut prompts = Vec::new();
if !has_color {
prompts.push(Prompt::select_one(
"color",
"Pick a color",
vec![
SelectOption::new("red", "Red"),
SelectOption::new("blue", "Blue"),
],
));
}
if !has_size {
prompts.push(Prompt::select_one(
"size",
"Pick a size",
vec![
SelectOption::new("s", "Small"),
SelectOption::new("l", "Large"),
],
));
}
return EvalResult::NeedInput {
prompts,
messages: vec![Message::info("Setup", "Choose your preferences.")],
};
}
if !has_confirm {
let summary = format!(
"Color: {}, Size: {}",
bag["color"].as_str().unwrap(),
bag["size"].as_str().unwrap()
);
return EvalResult::NeedInput {
prompts: vec![Prompt::text("confirm", "Type 'yes' to confirm")],
messages: vec![Message::summary("Review", &summary)],
};
}
EvalResult::Complete {
messages: vec![Message::summary("Done", "Order placed.")],
}
}
}
#[test]
fn multi_prompt_returns_multiple_fields() {
let host = CeremonyHost::new(MultiRules);
let r1 = host
.step(CeremonyRequest {
session_id: None,
ceremony: Some("multi".into()),
data: serde_json::Map::new(),
render: None,
})
.unwrap();
assert!(!r1.complete);
assert_eq!(r1.prompts.len(), 2);
assert_eq!(r1.prompts[0].key, "color");
assert_eq!(r1.prompts[1].key, "size");
assert_eq!(r1.messages.len(), 1);
let mut data = serde_json::Map::new();
data.insert("color".into(), serde_json::json!("red"));
data.insert("size".into(), serde_json::json!("l"));
let r2 = host
.step(CeremonyRequest {
session_id: Some(r1.session_id),
ceremony: None,
data,
render: None,
})
.unwrap();
assert!(!r2.complete);
assert_eq!(r2.prompts.len(), 1);
assert_eq!(r2.prompts[0].key, "confirm");
assert_eq!(r2.messages.len(), 1);
assert_eq!(r2.messages[0].kind, MessageKind::Summary);
let mut data = serde_json::Map::new();
data.insert("confirm".into(), serde_json::json!("yes"));
let r3 = host
.step(CeremonyRequest {
session_id: Some(r2.session_id),
ceremony: None,
data,
render: None,
})
.unwrap();
assert!(r3.complete);
}
#[test]
fn partial_prefill_asks_only_for_missing() {
let host = CeremonyHost::new(MultiRules);
let mut data = serde_json::Map::new();
data.insert("color".into(), serde_json::json!("blue"));
let resp = host
.step(CeremonyRequest {
session_id: None,
ceremony: Some("multi".into()),
data,
render: None,
})
.unwrap();
assert!(!resp.complete);
assert_eq!(resp.prompts.len(), 1);
assert_eq!(resp.prompts[0].key, "size");
}
#[test]
fn fatal_error_completes_with_error() {
struct FatalRules;
impl CeremonyRules for FatalRules {
fn validate_ceremony_type(&self, _: &str) -> Result<(), String> {
Ok(())
}
fn evaluate(
&self,
_: &str,
_: &mut serde_json::Map<String, serde_json::Value>,
_: &RenderHints,
) -> EvalResult {
EvalResult::Fatal("disk full".into())
}
}
let host = CeremonyHost::new(FatalRules);
let resp = host
.step(CeremonyRequest {
session_id: None,
ceremony: Some("boom".into()),
data: serde_json::Map::new(),
render: None,
})
.unwrap();
assert!(resp.complete);
assert_eq!(resp.error.as_deref(), Some("disk full"));
assert_eq!(resp.messages.len(), 1);
assert_eq!(resp.messages[0].kind, MessageKind::Error);
assert_eq!(host.active_session_count(), 0);
}
}