cnats: NATS-native chat with Leptos SSR and Kanidm SSO

Initial import plus deployment packaging: multi-stage Dockerfile
(cargo-leptos build -> debian-slim runtime), .dockerignore, and a
dev-only docker-compose (app + local NATS). Production deployment
lives in the infrastructure repo.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-08 21:53:56 +02:00
commit 6b97ec3b0f
18 changed files with 5884 additions and 0 deletions
+76
View File
@@ -0,0 +1,76 @@
use leptos::prelude::*;
use serde::{Deserialize, Serialize};
/// Rooms available in the UI. Each maps to the NATS subject
/// `chat.room.<name>`, so any other NATS client on the bus can join in.
pub const ROOMS: &[(&str, &str)] = &[
("lobby", "general traffic"),
("dev", "build & ship"),
("ops", "incidents & infra"),
("random", "off the record"),
];
pub const DEFAULT_ROOM: &str = "lobby";
pub fn is_valid_room(room: &str) -> bool {
ROOMS.iter().any(|(name, _)| *name == room)
}
pub fn room_subject(room: &str) -> String {
format!("chat.room.{room}")
}
/// A single chat message as it travels over NATS (JSON-encoded payload).
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct ChatMessage {
pub id: String,
pub room: String,
pub username: String,
pub display_name: String,
pub text: String,
/// Pre-formatted UTC wall-clock time, e.g. "14:03:27".
pub time: String,
}
/// Publishes a message to the room's NATS subject. Requires a signed-in
/// session; the sender identity always comes from the session, never from
/// the client.
#[server]
pub async fn send_message(room: String, text: String) -> Result<(), ServerFnError> {
use crate::auth::{User, SESSION_USER_KEY};
use crate::server::AppState;
let text = text.trim().to_string();
if text.is_empty() || text.len() > 2000 {
return Err(ServerFnError::new("message must be 1..=2000 characters"));
}
if !is_valid_room(&room) {
return Err(ServerFnError::new("unknown room"));
}
let session: tower_sessions::Session = leptos_axum::extract().await?;
let Some(user) = session
.get::<User>(SESSION_USER_KEY)
.await
.map_err(|e| ServerFnError::new(e.to_string()))?
else {
return Err(ServerFnError::new("not signed in"));
};
let state = expect_context::<AppState>();
let msg = ChatMessage {
id: uuid::Uuid::new_v4().to_string(),
room: room.clone(),
username: user.username,
display_name: user.display_name,
text,
time: chrono::Utc::now().format("%H:%M:%S").to_string(),
};
let payload = serde_json::to_vec(&msg).map_err(|e| ServerFnError::new(e.to_string()))?;
state
.nats
.publish(room_subject(&room), payload.into())
.await
.map_err(|e| ServerFnError::new(format!("nats publish failed: {e}")))?;
Ok(())
}