use super::*;
use std::sync::Arc;
use boatramp_core::session::Cursor;
use boatramp_core::sql::SqlValue;
use boatramp_core::time::now_unix_ms;
use crate::session_store::{SessionStore, StoreError};
const SESSION_POLL_INTERVAL: std::time::Duration = std::time::Duration::from_millis(250);
const SESSION_HEARTBEAT: std::time::Duration = std::time::Duration::from_secs(15);
fn seal_principal(value: Option<&SqlValue>) -> Option<Vec<u8>> {
value.map(|v| format!("{v:?}").into_bytes())
}
const MAX_SESSION_ID_LEN: usize = 256;
fn valid_session_id(id: &str) -> bool {
!id.is_empty()
&& id.len() <= MAX_SESSION_ID_LEN
&& id
.bytes()
.all(|b| b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_' | b'.' | b'~'))
}
fn store_id(preview: Option<&str>, id: &str) -> String {
match preview {
Some(pid) => format!("_preview/{pid}/{id}"),
None => id.to_string(),
}
}
fn stream_scope(site: &str, preview: Option<&str>) -> String {
match preview {
Some(pid) => format!("{site}/_preview/{pid}"),
None => site.to_string(),
}
}
pub(super) fn session_store(inner: &HandlerRuntimeInner) -> SessionStore {
inner
.session_store
.get_or_init(|| {
SessionStore::new(
inner.kv.clone(),
boatramp_core::session::SessionLimits::default(),
)
})
.clone()
}
fn query_param<'a>(uri: &'a axum::http::Uri, key: &str) -> Option<&'a str> {
uri.query().and_then(|q| {
q.split('&').find_map(|pair| {
let (k, v) = pair.split_once('=')?;
(k == key).then_some(v)
})
})
}
fn request_bearer(headers: &HeaderMap) -> Option<String> {
headers
.get(header::AUTHORIZATION)
.and_then(|v| v.to_str().ok())
.and_then(|s| {
s.strip_prefix("Bearer ")
.or_else(|| s.strip_prefix("bearer "))
})
.map(str::to_string)
}
async fn resolve_session_principal(
inner: &HandlerRuntimeInner,
session: &boatramp_core::config::SessionConfig,
bearer: Option<&str>,
domain_context: Option<&str>,
) -> Result<Option<SqlValue>, String> {
let imports_db = session.imports.iter().any(|i| i == "sql")
|| session.imports.iter().any(|i| i.starts_with("sql:"));
let posture = crate::tenant_resolve::TenantPosture {
require_declaration: inner
.require_tenancy_declaration
.get()
.copied()
.unwrap_or(true),
allow_cross_tenant: inner.allow_cross_tenant_db.get().copied().unwrap_or(false),
};
let resolved = crate::tenant_resolve::resolve_host_tenancy(
session.tenancy.as_ref(),
imports_db,
posture,
crate::tenant_resolve::TenantSourceInputs {
bearer,
domain_context,
token_cfg: session.token_claims.as_ref(),
},
)
.await
.map_err(|e| e.to_string())?;
Ok(resolved.and_then(|h| h.value().cloned()))
}
fn session_scope(project: &str, site: &str, preview: Option<&str>) -> String {
let base = match preview {
Some(id) => format!("{site}/_preview/{id}"),
None => site.to_string(),
};
boatramp_core::project::ProjectRef::new(project).qualified(&base)
}
#[allow(clippy::too_many_arguments)]
pub(super) async fn serve_session_open(
inner: &Arc<HandlerRuntimeInner>,
site_handlers: &boatramp_core::config::HandlersSiteConfig,
project: &str,
site: &str,
session: &boatramp_core::config::SessionConfig,
request: Request,
client_ip: IpAddr,
preview: Option<&str>,
) -> Response {
use axum::response::sse::{Event, KeepAlive, Sse};
let Some(id) = query_param(request.uri(), "id").filter(|s| valid_session_id(s)) else {
return (
StatusCode::BAD_REQUEST,
"session open requires a valid `id` query parameter\n",
)
.into_response();
};
let id = store_id(preview, id);
let after: Cursor = request
.headers()
.get("last-event-id")
.and_then(|v| v.to_str().ok())
.or_else(|| query_param(request.uri(), "cursor"))
.and_then(|s| s.parse().ok())
.unwrap_or(0);
let bearer = request_bearer(request.headers());
let domain_context = request
.extensions()
.get::<crate::DomainContext>()
.map(|c| c.0.clone());
let principal = match resolve_session_principal(
inner,
session,
bearer.as_deref(),
domain_context.as_deref(),
)
.await
{
Ok(value) => seal_principal(value.as_ref()),
Err(err) => {
tracing::warn!(site, route = %session.route, %err, "session tenancy refused");
return (StatusCode::FORBIDDEN, "session tenancy refused\n").into_response();
}
};
let store = session_store(inner);
let now = now_unix_ms();
match store
.open_or_verify(project, &id, &session.route, principal, now)
.await
{
Ok(()) => {}
Err(StoreError::PrincipalMismatch) => {
return (StatusCode::FORBIDDEN, "session principal mismatch\n").into_response();
}
Err(StoreError::ProjectSessionsFull) => {
return (
StatusCode::SERVICE_UNAVAILABLE,
"project session limit reached\n",
)
.into_response();
}
Err(err) => {
tracing::warn!(site, %err, "opening session failed");
return handler_unavailable();
}
}
let _ = store.ack(project, &id, after, now).await;
let scope = stream_scope(site, preview);
let site_permit = match crate::stream::acquire_stream_permit(inner, &scope, site_handlers) {
Ok(permit) => permit,
Err(()) => {
return (
StatusCode::SERVICE_UNAVAILABLE,
"site stream connection limit reached\n",
)
.into_response()
}
};
let ip_guard = match crate::stream::acquire_stream_ip_slot(inner, &scope, client_ip) {
Ok(guard) => guard,
Err(()) => {
return (
StatusCode::TOO_MANY_REQUESTS,
"per-client stream connection limit reached\n",
)
.into_response()
}
};
struct Producer {
store: SessionStore,
project: String,
id: String,
cursor: Cursor,
pending: std::collections::VecDeque<Event>,
done: bool,
_site_permit: tokio::sync::OwnedSemaphorePermit,
_ip_guard: crate::stream::IpStreamGuard,
}
let producer = Producer {
store,
project: project.to_string(),
id,
cursor: after,
pending: std::collections::VecDeque::new(),
done: false,
_site_permit: site_permit,
_ip_guard: ip_guard,
};
let body = futures::stream::unfold(producer, |mut p| async move {
loop {
if let Some(event) = p.pending.pop_front() {
return Some((Ok::<Event, std::convert::Infallible>(event), p));
}
if p.done {
return None;
}
match p
.store
.poll(&p.project, &p.id, p.cursor, now_unix_ms())
.await
{
Ok(poll) => {
for frame in poll.frames {
p.cursor = frame.cursor;
p.pending
.push_back(frame_event(frame.cursor, &frame.payload));
}
if let Some(reason) = poll.closed {
p.pending.push_back(close_event(&reason));
p.done = true;
continue;
}
if !p.pending.is_empty() {
continue; }
if poll.expired {
p.pending.push_back(close_event("idle"));
p.done = true;
continue;
}
tokio::time::sleep(SESSION_POLL_INTERVAL).await;
}
Err(_) => return None,
}
}
});
Sse::new(body)
.keep_alive(
KeepAlive::new()
.interval(SESSION_HEARTBEAT)
.text("keep-alive"),
)
.into_response()
}
fn frame_event(cursor: Cursor, payload: &[u8]) -> axum::response::sse::Event {
use base64::Engine;
axum::response::sse::Event::default()
.id(cursor.to_string())
.event("frame")
.data(base64::engine::general_purpose::STANDARD.encode(payload))
}
fn close_event(reason: &str) -> axum::response::sse::Event {
axum::response::sse::Event::default()
.event("close")
.data(reason)
}
#[allow(clippy::too_many_arguments)]
pub(super) async fn dispatch_session_post(
inner: &Arc<HandlerRuntimeInner>,
deploy: &DeployStore,
manifest: &Manifest,
site_handlers: &boatramp_core::config::HandlersSiteConfig,
project: &str,
site: &str,
session: &boatramp_core::config::SessionConfig,
request: Request,
client_ip: IpAddr,
preview: Option<&str>,
) -> Response {
let (parts, body) = request.into_parts();
let Some(id) = query_param(&parts.uri, "id").filter(|s| valid_session_id(s)) else {
return (
StatusCode::BAD_REQUEST,
"session frame requires a valid `id` query parameter\n",
)
.into_response();
};
let id = store_id(preview, id);
let permit_scope = stream_scope(site, preview);
let _site_permit =
match crate::stream::acquire_stream_permit(inner, &permit_scope, site_handlers) {
Ok(permit) => permit,
Err(()) => {
return (
StatusCode::SERVICE_UNAVAILABLE,
"site stream connection limit reached\n",
)
.into_response()
}
};
let _ip_guard = match crate::stream::acquire_stream_ip_slot(inner, &permit_scope, client_ip) {
Ok(guard) => guard,
Err(()) => {
return (
StatusCode::TOO_MANY_REQUESTS,
"per-client stream connection limit reached\n",
)
.into_response()
}
};
let ack: Option<Cursor> = query_param(&parts.uri, "ack").and_then(|s| s.parse().ok());
let idem_key = parts
.headers
.get("idempotency-key")
.and_then(|v| v.to_str().ok())
.map(str::to_string)
.or_else(|| query_param(&parts.uri, "idem").map(str::to_string));
let bearer = request_bearer(&parts.headers);
let domain_context = parts
.extensions
.get::<crate::DomainContext>()
.map(|c| c.0.clone());
let caller_tenant = match resolve_session_principal(
inner,
session,
bearer.as_deref(),
domain_context.as_deref(),
)
.await
{
Ok(value) => value,
Err(err) => {
tracing::warn!(site, route = %session.route, %err, "session tenancy refused");
return (StatusCode::FORBIDDEN, "session tenancy refused\n").into_response();
}
};
let principal = seal_principal(caller_tenant.as_ref());
let store = session_store(inner);
let now = now_unix_ms();
match store
.open_or_verify(project, &id, &session.route, principal, now)
.await
{
Ok(()) => {}
Err(StoreError::PrincipalMismatch) => {
return (StatusCode::FORBIDDEN, "session principal mismatch\n").into_response();
}
Err(StoreError::ProjectSessionsFull) => {
return (
StatusCode::SERVICE_UNAVAILABLE,
"project session limit reached\n",
)
.into_response();
}
Err(err) => {
tracing::warn!(site, %err, "opening session failed");
return handler_unavailable();
}
}
if let Some(cursor) = ack {
let _ = store.ack(project, &id, cursor, now).await;
}
match store.is_closed(project, &id).await {
Ok(false) => {}
Ok(true) | Err(StoreError::NotFound) => {
return (StatusCode::GONE, "session is closed\n").into_response();
}
Err(err) => {
tracing::warn!(site, %err, "checking session state failed");
return handler_unavailable();
}
}
let max_frame = store.limits().max_frame_bytes;
let frame = match axum::body::to_bytes(body, max_frame).await {
Ok(bytes) => bytes.to_vec(),
Err(_) => {
return (StatusCode::PAYLOAD_TOO_LARGE, "session frame too large\n").into_response();
}
};
if let Some(key) = &idem_key {
match store.seen(project, &id, key).await {
Ok(true) => {
return (StatusCode::OK, "duplicate frame ignored\n").into_response();
}
Ok(false) => {}
Err(StoreError::NotFound) => {
return (StatusCode::GONE, "session is closed\n").into_response();
}
Err(err) => {
tracing::warn!(site, %err, "checking session inbound dedup failed");
return handler_unavailable();
}
}
}
let scope = session_scope(project, site, preview);
let fn_config = boatramp_core::function::FunctionConfig {
imports: session.imports.clone(),
limits: session.limits.clone(),
env: session.env.clone(),
invoke_targets: session.invoke_targets.clone(),
tenancy: session.tenancy.clone(),
token_claims: session.token_claims.clone(),
..Default::default()
};
let project_ref = boatramp_core::project::ProjectRef::new(project);
let bindings = match crate::function_runtime::build_function_bindings(
inner,
project_ref,
&scope,
site,
&fn_config,
0,
&crate::function_runtime::FnTenant::Inherited(caller_tenant),
bearer.as_deref(),
domain_context.as_deref(),
)
.await
{
Ok(bindings) => bindings.with_session(crate::session_driver::controller(
store.clone(),
project,
&id,
)),
Err(err) => {
tracing::warn!(site, route = %session.route, %err, "session bindings refused");
return handler_unavailable();
}
};
let Some(entry) = manifest.files.get(&session.component) else {
tracing::warn!(site, component = %session.component, "session component missing from deployment");
return handler_unavailable();
};
let wasm = match read_blob_fully(deploy, &entry.hash).await {
Ok(bytes) => bytes,
Err(response) => return response,
};
let resumed = store.resumed(project, &id).await.ok().flatten();
let batch = boatramp_handlers::SessionBatch {
id: id.clone(),
resumed,
frames: vec![frame],
};
let limits = crate::function_runtime::function_limits(session.limits.as_ref());
let start = std::time::Instant::now();
let result = inner
.engine
.dispatch_session(&entry.hash, &wasm, batch, bindings, limits)
.await;
inner.metrics.observe(
site,
metrics::Trigger::Http,
&session.route,
&entry.hash,
metrics::Outcome::from_result(&result),
start.elapsed(),
);
match result {
Ok(()) => {
if let Some(key) = &idem_key {
let _ = store.record_inbound(project, &id, key, now_unix_ms()).await;
}
(StatusCode::ACCEPTED, "frame accepted\n").into_response()
}
Err(err) => {
tracing::warn!(site, route = %session.route, %err, "session re-entry failed");
handler_error_response(&err)
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn seal_is_deterministic_total_and_distinguishes_values() {
assert_eq!(seal_principal(None), None);
for v in [
SqlValue::Null,
SqlValue::Boolean(true),
SqlValue::Integer(42),
SqlValue::Real(1.5),
SqlValue::Text("acme".into()),
SqlValue::Blob(vec![0, 255, 7]),
] {
let a = seal_principal(Some(&v));
let b = seal_principal(Some(&v));
assert_eq!(a, b, "seal must be deterministic for {v:?}");
assert!(a.is_some());
}
assert_ne!(
seal_principal(Some(&SqlValue::Integer(1))),
seal_principal(Some(&SqlValue::Integer(2)))
);
assert_ne!(
seal_principal(Some(&SqlValue::Integer(1))),
seal_principal(Some(&SqlValue::Text("1".into())))
);
assert_ne!(seal_principal(Some(&SqlValue::Null)), seal_principal(None));
}
#[test]
fn query_param_extracts_by_key() {
let uri: axum::http::Uri = "http://x/s?id=abc&cursor=7&ack=3".parse().unwrap();
assert_eq!(query_param(&uri, "id"), Some("abc"));
assert_eq!(query_param(&uri, "cursor"), Some("7"));
assert_eq!(query_param(&uri, "ack"), Some("3"));
assert_eq!(query_param(&uri, "missing"), None);
let bare: axum::http::Uri = "http://x/s".parse().unwrap();
assert_eq!(query_param(&bare, "id"), None);
}
}