feat(msg-bus): prepare engine

This commit is contained in:
Edouard Vanbelle
2026-09-10 00:02:30 +02:00
parent 711b00db2a
commit 1b824cb45c
13 changed files with 2650 additions and 17 deletions
+1
View File
@@ -23,6 +23,7 @@ pub mod opaque_auth_handler;
pub mod people_handler;
pub mod photos_handler;
pub mod recent_handler;
pub mod rt_ws;
pub mod search_handler;
pub mod share_handler;
pub mod subject_group_handler;
+552
View File
@@ -0,0 +1,552 @@
//! Realtime bus WebSocket handler — the endpoint every WS session
//! multiplexes over. See `docs/plan/message-bus.md § Wire protocol`.
//!
//! # Wire
//!
//! JSON-RPC 2.0 for control + events (text frames). Binary frames are
//! reserved for the Yjs sync protocol (collab editor, Phase A follow-up)
//! and are IGNORED in MVP.
//!
//! Methods accepted in MVP:
//! - `rt.subscribe { topic }` → `{ subscribed: "<topic>" }` or JSON-RPC error.
//! - `rt.unsubscribe { topic }` → `{ unsubscribed: "<topic>" }`.
//! - `rt.ping` → `{ pong: true }`.
//!
//! Server-initiated notifications:
//! - `rt.event { topic, event, data, actor, ts }` — an event published to
//! a topic the caller is subscribed to.
//!
//! # Auth
//!
//! Route sits under `protected_api` (see `src/interfaces/api/routes.rs`)
//! so `auth_middleware` runs first. Cookie AND `Authorization: Bearer`
//! paths both produce a `CurrentUserId` extension the handler extracts.
//! Browser-side subprotocol bearer (`Sec-WebSocket-Protocol:
//! authorization.bearer.<jwt>`) is a Phase-A follow-up — the MVP relies
//! on the Authorization header, which programmatic clients (the
//! `rt-hurl-helper` smoke test) set directly.
//!
//! # Limits
//!
//! Per-connection outbound `mpsc::Sender` bounded to 512 — full → close.
//! Per-connection subscription cap 128. Frame-size cap not enforced in
//! MVP; the underlying tokio-tungstenite default is 64 MiB which is more
//! than adequate for JSON-RPC control traffic.
use std::collections::HashMap;
use std::sync::Arc;
use axum::extract::State;
use axum::extract::ws::{Message, WebSocket, WebSocketUpgrade};
use axum::response::Response;
use futures::StreamExt;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use tokio::sync::mpsc;
use tokio::task::JoinHandle;
use uuid::Uuid;
use crate::application::ports::authorization_ports::AuthorizationEngine;
use crate::application::ports::realtime_ports::{
AuthzCheck, BusResource, ParseTopicErr, RealtimeBus, RealtimeEvent, Topic, error_code,
};
use crate::common::di::AppState;
use crate::domain::services::authorization::{Permission, Resource, Subject};
use crate::interfaces::middleware::auth::CurrentUserId;
/// Max simultaneous subscriptions on a single WS session. Beyond this the
/// server responds `-32005 sub_limit` and the client is expected to
/// unsubscribe before subscribing to another topic.
const MAX_SUBSCRIPTIONS_PER_CONNECTION: usize = 128;
/// Outbound mpsc capacity per session. When full → we close the WS
/// (client reconnects, refetches). Sized so a subscriber blocked on the
/// socket layer doesn't back-pressure into the bus's broadcast ring.
const OUTBOUND_CHANNEL_CAPACITY: usize = 512;
// ════════════════════════════════════════════════════════════════════════════
// JSON-RPC 2.0 envelope types
// ════════════════════════════════════════════════════════════════════════════
/// Marker constant for the `jsonrpc` field.
const JSONRPC_V2: &str = "2.0";
/// Inbound JSON-RPC envelope — deserialize-tolerant so a client can
/// send `rt.ping` without `params`, or a notification without an `id`.
///
/// Response/notification serialization uses the more strongly-typed
/// [`RpcResponse`] and [`RpcNotification`] types below.
#[derive(Debug, Deserialize)]
struct RpcRequest {
#[serde(rename = "jsonrpc")]
_jsonrpc: Option<String>,
/// `id` is `None` for notifications (which the client-side of MVP
/// never sends). We accept it in the shape but do not treat missing
/// `id` as a permitted request — every `rt.*` method requires
/// `id`-correlated responses in MVP.
id: Option<Value>,
method: Option<String>,
#[serde(default)]
params: Value,
}
/// Server → client response for a request (both success and error use
/// this shape; exactly one of `result`/`error` is populated per spec).
#[derive(Debug, Serialize)]
struct RpcResponse<'a> {
jsonrpc: &'static str,
id: Value,
#[serde(skip_serializing_if = "Option::is_none")]
result: Option<Value>,
#[serde(skip_serializing_if = "Option::is_none")]
error: Option<RpcError<'a>>,
}
#[derive(Debug, Serialize)]
struct RpcError<'a> {
code: i32,
message: &'a str,
#[serde(skip_serializing_if = "Option::is_none")]
data: Option<Value>,
}
/// Server → client notification (id-less). Emitted for `rt.event` (bus
/// fan-out) and `rt.revoked` (subscription eviction).
#[derive(Debug, Serialize)]
struct RpcNotification<'a> {
jsonrpc: &'static str,
method: &'a str,
params: Value,
}
// ════════════════════════════════════════════════════════════════════════════
// Handler entrypoint
// ════════════════════════════════════════════════════════════════════════════
/// `GET /api/rt/ws` — WS upgrade handler. Sits under `protected_api` so
/// [`CurrentUserId`] resolves against a valid session before we reach
/// `on_upgrade`.
///
/// Returns whatever `WebSocketUpgrade::on_upgrade` produces (an HTTP 101
/// Switching Protocols with the WebSocket handshake headers).
pub async fn rt_ws_handler(
ws: WebSocketUpgrade,
CurrentUserId(caller_id): CurrentUserId,
State(state): State<Arc<AppState>>,
) -> Response {
ws.on_upgrade(move |socket| handle_session(socket, caller_id, state))
}
// ════════════════════════════════════════════════════════════════════════════
// Session loop
// ════════════════════════════════════════════════════════════════════════════
/// A per-topic subscription: the join handle for the reader task that
/// drains the bus stream into `out_tx`. Dropping does NOT abort a spawned
/// tokio task — we must call `.abort()` explicitly on unsubscribe.
struct Sub {
reader: JoinHandle<()>,
}
impl Drop for Sub {
fn drop(&mut self) {
// Belt-and-braces: if `remove()` bypasses `.abort()` for some
// future call path, dropping still stops the reader.
self.reader.abort();
}
}
async fn handle_session(mut socket: WebSocket, caller_id: Uuid, state: Arc<AppState>) {
// Outbound queue — every path that produces a text frame for the
// client enqueues here; the writer half of the select drains.
let (out_tx, mut out_rx) = mpsc::channel::<String>(OUTBOUND_CHANNEL_CAPACITY);
// Active subscriptions on this session. Keyed by the wire-form topic
// string so an incoming `rt.unsubscribe` with the same string is
// recognised without re-parsing.
let mut subs: HashMap<String, Sub> = HashMap::new();
loop {
tokio::select! {
// biased: process outbound before inbound so an event burst
// doesn't get overtaken by a control-frame handshake.
biased;
outbound = out_rx.recv() => {
match outbound {
Some(text) => {
if socket.send(Message::Text(text.into())).await.is_err() {
break;
}
}
None => break, // out_tx dropped — unreachable but safe
}
}
incoming = socket.recv() => {
match incoming {
Some(Ok(Message::Text(txt))) => {
if let Some(reply) =
handle_text_frame(&txt, caller_id, &state, &mut subs, &out_tx).await
&& socket.send(Message::Text(reply.into())).await.is_err() {
break;
}
}
Some(Ok(Message::Binary(_))) => {
// Reserved for Yjs sync protocol frames (collab
// editor, Phase A follow-up). Silently ignored in
// MVP so a future client that speaks binary
// frames on the same connection isn't rejected.
}
Some(Ok(Message::Ping(_) | Message::Pong(_))) => {
// Handled by axum's WebSocket state machine.
}
Some(Ok(Message::Close(_))) | Some(Err(_)) | None => break,
}
}
}
}
// Session cleanup: abort every subscription reader task.
subs.clear();
}
// ════════════════════════════════════════════════════════════════════════════
// Frame handling
// ════════════════════════════════════════════════════════════════════════════
/// Parse one inbound text frame as a JSON-RPC 2.0 request and dispatch
/// it. Returns the response string to send back (empty option if the
/// dispatch already enqueued via `out_tx`).
async fn handle_text_frame(
text: &str,
caller_id: Uuid,
state: &Arc<AppState>,
subs: &mut HashMap<String, Sub>,
out_tx: &mpsc::Sender<String>,
) -> Option<String> {
// Parse envelope. On malformed JSON: reply with an id-less error per
// JSON-RPC 2.0 (id = null when the request couldn't be parsed).
let req: RpcRequest = match serde_json::from_str(text) {
Ok(r) => r,
Err(_) => {
return Some(error_response(
Value::Null,
error_code::INVALID_REQUEST,
"invalid_request",
None,
));
}
};
let id = req.id.unwrap_or(Value::Null);
let Some(method) = req.method else {
return Some(error_response(
id,
error_code::INVALID_REQUEST,
"invalid_request",
None,
));
};
match method.as_str() {
"rt.subscribe" => {
Some(handle_subscribe(id, req.params, caller_id, state, subs, out_tx).await)
}
"rt.unsubscribe" => Some(handle_unsubscribe(id, req.params, subs)),
"rt.ping" => Some(success_response(id, serde_json::json!({ "pong": true }))),
_ => Some(error_response(
id,
error_code::METHOD_NOT_FOUND,
"method_not_found",
Some(serde_json::json!({ "method": method })),
)),
}
}
async fn handle_subscribe(
id: Value,
params: Value,
caller_id: Uuid,
state: &Arc<AppState>,
subs: &mut HashMap<String, Sub>,
out_tx: &mpsc::Sender<String>,
) -> String {
// Extract topic.
let topic_str = match params.get("topic").and_then(Value::as_str) {
Some(s) => s.to_owned(),
None => {
return error_response(
id,
error_code::INVALID_PARAMS,
"invalid_params",
Some(serde_json::json!({ "missing": "topic" })),
);
}
};
// Guard against runaway subscribers pinning server memory.
if subs.len() >= MAX_SUBSCRIPTIONS_PER_CONNECTION && !subs.contains_key(&topic_str) {
audit_denied(caller_id, &topic_str, "sub_limit");
return error_response(
id,
error_code::SUB_LIMIT,
"sub_limit",
Some(serde_json::json!({ "topic": topic_str })),
);
}
// Idempotent: re-subscribing to an already-active topic acks with
// no side effects. Client reconnect logic can replay its topic set
// without dedup.
if subs.contains_key(&topic_str) {
return success_response(id, serde_json::json!({ "subscribed": topic_str }));
}
// Parse topic.
let topic = match Topic::parse(&topic_str) {
Ok(t) => t,
Err(ParseTopicErr::BadUuid) | Err(ParseTopicErr::Unknown) => {
// Both parse failures collapse to `topic_forbidden` on the
// wire — the caller cannot distinguish "unknown shape" from
// "shape known but resource doesn't exist" without hinting
// an enumeration oracle.
audit_denied(caller_id, &topic_str, "unknown_topic");
return error_response(
id,
error_code::TOPIC_FORBIDDEN,
"topic_forbidden",
Some(serde_json::json!({ "topic": topic_str })),
);
}
};
// AuthZ dispatch — one match arm per gate class. Adding a new topic
// variant with a new gate shape is a compile error here.
match topic.required_perm() {
AuthzCheck::ResourceRead { resource } => {
let domain_resource = match resource {
BusResource::Folder(uuid) => Resource::Folder(uuid),
};
if state
.authorization
.require(Subject::User(caller_id), Permission::Read, domain_resource)
.await
.is_err()
{
// Anti-enum: "no such resource" and "no read" collapse
// to the same wire code. Audit records the truth.
audit_denied(caller_id, &topic_str, "no_read");
return error_response(
id,
error_code::NO_READ,
"no_read",
Some(serde_json::json!({ "topic": topic_str })),
);
}
}
AuthzCheck::IdentityMatch { user_id } => {
if user_id != caller_id {
audit_denied(caller_id, &topic_str, "identity_mismatch");
return error_response(
id,
error_code::TOPIC_FORBIDDEN,
"topic_forbidden",
Some(serde_json::json!({ "topic": topic_str })),
);
}
}
}
// AuthZ passed — install the subscription and spawn a reader task
// that forwards bus events to the outbound channel as `rt.event`
// notifications.
let stream = RealtimeBus::subscribe(state.bus.as_ref(), &topic);
let topic_wire = topic_str.clone();
let out_tx_task = out_tx.clone();
let reader = tokio::spawn(async move {
let mut stream = stream;
while let Some(event) = stream.next().await {
let notification = event_notification(&topic_wire, &event);
if out_tx_task.send(notification).await.is_err() {
// Session's outbound channel closed — receiver dropped.
break;
}
}
});
subs.insert(topic_str.clone(), Sub { reader });
success_response(id, serde_json::json!({ "subscribed": topic_str }))
}
fn handle_unsubscribe(id: Value, params: Value, subs: &mut HashMap<String, Sub>) -> String {
let Some(topic_str) = params.get("topic").and_then(Value::as_str) else {
return error_response(
id,
error_code::INVALID_PARAMS,
"invalid_params",
Some(serde_json::json!({ "missing": "topic" })),
);
};
// Idempotent: removing a topic the session isn't subscribed to is
// still a success ack, per plan.
subs.remove(topic_str);
success_response(id, serde_json::json!({ "unsubscribed": topic_str }))
}
// ════════════════════════════════════════════════════════════════════════════
// Envelope helpers
// ════════════════════════════════════════════════════════════════════════════
fn success_response(id: Value, result: Value) -> String {
serde_json::to_string(&RpcResponse {
jsonrpc: JSONRPC_V2,
id,
result: Some(result),
error: None,
})
.expect("RpcResponse always serializes")
}
fn error_response(id: Value, code: i32, message: &str, data: Option<Value>) -> String {
serde_json::to_string(&RpcResponse {
jsonrpc: JSONRPC_V2,
id,
result: None,
error: Some(RpcError {
code,
message,
data,
}),
})
.expect("RpcResponse always serializes")
}
/// Build an `rt.event` JSON-RPC notification for a bus event.
///
/// Payload discipline (see plan): thin facts only. The `RealtimeEvent`'s
/// own `#[serde(tag = "event")]` shape provides `event` + variant fields
/// under one flat object; we lift them into `params.data` alongside a
/// `topic` selector for the client.
fn event_notification(topic_wire: &str, event: &RealtimeEvent) -> String {
// Serialize the event to extract `event` (discriminator) and the
// remaining fields as `data`. Two-step to avoid re-inventing the
// enum's discriminator string here.
let event_json = serde_json::to_value(event).expect("RealtimeEvent always serializes");
let (event_name, data) = split_event_discriminator(event_json);
let params = serde_json::json!({
"topic": topic_wire,
"event": event_name,
"data": data,
});
serde_json::to_string(&RpcNotification {
jsonrpc: JSONRPC_V2,
method: "rt.event",
params,
})
.expect("RpcNotification always serializes")
}
/// Given a `RealtimeEvent` serialised as `{ "event": "file_created", ...rest }`,
/// split into `(event_name, rest)`. Falls back to `("unknown", full)` if
/// the shape doesn't match (defensive — shouldn't happen given the enum
/// derive, but a future untagged variant would land here).
fn split_event_discriminator(mut event_json: Value) -> (String, Value) {
if let Some(obj) = event_json.as_object_mut()
&& let Some(Value::String(name)) = obj.remove("event")
{
return (name, Value::Object(obj.clone()));
}
("unknown".to_owned(), event_json)
}
// ════════════════════════════════════════════════════════════════════════════
// Audit
// ════════════════════════════════════════════════════════════════════════════
fn audit_denied(caller_id: Uuid, topic: &str, reason: &'static str) {
tracing::info!(
target: "audit",
event = "realtime.subscribe_denied",
reason = reason,
caller_id = %caller_id,
topic = %topic,
"👮🏻‍♂️ realtime subscribe rejected",
);
}
// ════════════════════════════════════════════════════════════════════════════
// Tests
// ════════════════════════════════════════════════════════════════════════════
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn success_response_shape() {
let s = success_response(
Value::Number(42.into()),
serde_json::json!({ "subscribed": "folder:x" }),
);
let v: Value = serde_json::from_str(&s).unwrap();
assert_eq!(v["jsonrpc"], "2.0");
assert_eq!(v["id"], 42);
assert_eq!(v["result"]["subscribed"], "folder:x");
assert!(v.get("error").is_none());
}
#[test]
fn error_response_shape() {
let s = error_response(
Value::Number(7.into()),
error_code::NO_READ,
"no_read",
Some(serde_json::json!({ "topic": "folder:x" })),
);
let v: Value = serde_json::from_str(&s).unwrap();
assert_eq!(v["jsonrpc"], "2.0");
assert_eq!(v["id"], 7);
assert_eq!(v["error"]["code"], error_code::NO_READ);
assert_eq!(v["error"]["message"], "no_read");
assert_eq!(v["error"]["data"]["topic"], "folder:x");
assert!(v.get("result").is_none());
}
#[test]
fn event_notification_shape() {
let event = RealtimeEvent::FileCreated {
file_id: Uuid::nil(),
name: "notes.md".into(),
parent_id: Uuid::nil(),
actor: Uuid::nil(),
};
let s = event_notification("folder:abc", &event);
let v: Value = serde_json::from_str(&s).unwrap();
assert_eq!(v["jsonrpc"], "2.0");
assert_eq!(v["method"], "rt.event");
assert_eq!(v["params"]["topic"], "folder:abc");
assert_eq!(v["params"]["event"], "file_created");
assert_eq!(v["params"]["data"]["name"], "notes.md");
// The discriminator field must have been lifted OUT of `data` —
// otherwise the client sees `data.event` alongside the
// top-level `event`, which is confusing and violates the plan's
// wire shape.
assert!(v["params"]["data"].get("event").is_none());
}
#[test]
fn split_event_discriminator_extracts_and_removes() {
let input = serde_json::json!({
"event": "file_created",
"file_id": "00000000-0000-0000-0000-000000000000",
"name": "x",
});
let (name, rest) = split_event_discriminator(input);
assert_eq!(name, "file_created");
assert!(rest.get("event").is_none());
assert_eq!(rest["name"], "x");
}
}
+10
View File
@@ -674,6 +674,16 @@ pub fn create_api_routes(app_state: &Arc<AppState>) -> Router<Arc<AppState>> {
.with_state(app_state.clone());
router = router.nest("/users", users_router);
// Realtime bus WebSocket. Auth (session cookie or bearer JWT) via
// the same `auth_middleware` the rest of `/api/*` gets; the handler
// extracts `CurrentUserId` from the extension the middleware
// installs. See `docs/plan/message-bus.md` and the module doc on
// `rt_ws` for the JSON-RPC 2.0 wire.
router = router.route(
"/rt/ws",
get(crate::interfaces::api::handlers::rt_ws::rt_ws_handler).with_state(app_state.clone()),
);
// Collector for any unknown `/api/*` path. Without this, an
// unmatched API URL falls through Axum's matcher to the
// ServeDir fallback and is logged under `http::web` — wrong