From 650ed50c218ff126b4a222ab540bf2370be8d71f Mon Sep 17 00:00:00 2001 From: Bendik Aagaard Lynghaug Date: Mon, 27 Jul 2026 23:52:27 +0200 Subject: [PATCH] Kanidm group-gated rooms and minimal mesh calling Rooms can now require a Kanidm group (via the `groups` OIDC claim, mapped by `oauth2 update-claim-map` server-side) - dev/ops require `developers`, enforced at every message path (send, history, SSE). Adds a minimal WebRTC mesh call feature scoped to the lobby room, signaled over a separate `call.room.*` NATS subject kept out of the chat archive: public STUN only, no TURN, no SFU - small groups on friendly networks, by design. --- Cargo.lock | 3 + Cargo.toml | 30 +++++ src/app.rs | 209 ++++++++++++++++++++++++++++- src/auth.rs | 5 + src/call.rs | 86 ++++++++++++ src/chat.rs | 42 ++++-- src/lib.rs | 4 + src/main.rs | 1 + src/server/oidc.rs | 41 ++++++ src/server/sse.rs | 79 +++++++++-- src/webrtc.rs | 328 +++++++++++++++++++++++++++++++++++++++++++++ style/main.css | 73 ++++++++++ 12 files changed, 875 insertions(+), 26 deletions(-) create mode 100644 src/call.rs create mode 100644 src/webrtc.rs diff --git a/Cargo.lock b/Cargo.lock index 9450f74..cb22a4d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -350,10 +350,12 @@ dependencies = [ "anyhow", "async-nats", "axum", + "base64 0.22.1", "chrono", "console_error_panic_hook", "dotenvy", "futures", + "js-sys", "leptos", "leptos_axum", "leptos_meta", @@ -371,6 +373,7 @@ dependencies = [ "url", "uuid", "wasm-bindgen", + "wasm-bindgen-futures", "web-sys", ] diff --git a/Cargo.toml b/Cargo.toml index c4739aa..da7a6f3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -29,6 +29,12 @@ sqlx = { version = "0.8", default-features = false, features = [ "macros", ], optional = true } openidconnect = { version = "4", optional = true } +# For pulling the `groups` custom claim out of the already-verified ID +# token's raw JWT payload - openidconnect's Core* type aliases default to +# EmptyAdditionalClaims, and reworking that generic stack for one extra +# field isn't worth it. The token's signature is already checked by +# id_token.claims(...) before this ever runs. +base64 = { version = "0.22", optional = true } futures = { version = "0.3", optional = true } chrono = { version = "0.4", features = ["serde"], optional = true } uuid = { version = "1", features = ["v4"], optional = true } @@ -39,12 +45,33 @@ tracing-subscriber = { version = "0.3", features = ["env-filter"], optional = tr # --- browser only --- wasm-bindgen = { version = "0.2", optional = true } +wasm-bindgen-futures = { version = "0.4", optional = true } +js-sys = { version = "0.3", optional = true } console_error_panic_hook = { version = "0.1", optional = true } web-sys = { version = "0.3", features = [ "EventSource", "MessageEvent", "HtmlElement", "Element", + # --- WebRTC mesh calling --- + "RtcPeerConnection", + "RtcConfiguration", + "RtcIceServer", + "RtcSdpType", + "RtcSessionDescriptionInit", + "RtcIceCandidate", + "RtcIceCandidateInit", + "RtcPeerConnectionIceEvent", + "RtcRtpSender", + "RtcTrackEvent", + "RtcRtpTransceiver", + "RtcOfferOptions", + "MediaStream", + "MediaStreamConstraints", + "MediaStreamTrack", + "MediaDevices", + "Navigator", + "HtmlVideoElement", ], optional = true } [features] @@ -52,6 +79,8 @@ default = [] hydrate = [ "leptos/hydrate", "dep:wasm-bindgen", + "dep:wasm-bindgen-futures", + "dep:js-sys", "dep:console_error_panic_hook", "dep:web-sys", ] @@ -69,6 +98,7 @@ ssr = [ "dep:url", "dep:sqlx", "dep:openidconnect", + "dep:base64", "dep:futures", "dep:chrono", "dep:uuid", diff --git a/src/app.rs b/src/app.rs index d489cd8..f70846e 100644 --- a/src/app.rs +++ b/src/app.rs @@ -7,7 +7,10 @@ use leptos_router::{ }; use crate::auth::{current_user, User}; -use crate::chat::{is_valid_room, room_subject, ChatMessage, SendMessage, DEFAULT_ROOM, ROOMS}; +use crate::chat::{ + is_authorized_for_room, is_valid_room, room_subject, ChatMessage, SendMessage, DEFAULT_ROOM, + ROOMS, +}; #[cfg(feature = "hydrate")] use crate::chat::room_history; @@ -75,7 +78,23 @@ fn ChatPage() -> impl IntoView { {move || { user.get() .map(|res| match res { - Ok(Some(u)) => view! { }.into_any(), + Ok(Some(u)) => { + // `room`'s own Memo only knows about is_valid_room (no + // user context available that early) - fall back to + // DEFAULT_ROOM here too if this user isn't authorized + // for the room the URL asked for, same as an unknown + // room name already does. + let u2 = u.clone(); + let effective_room = Memo::new(move |_| { + let r = room.get(); + if is_authorized_for_room(&u2, &r) { + r + } else { + DEFAULT_ROOM.to_string() + } + }); + view! { }.into_any() + } _ => view! { }.into_any(), }) }} @@ -83,6 +102,28 @@ fn ChatPage() -> impl IntoView { } } +// --------------------------------------------------------------------------- +// brand mark - the "voice pulse" ornament, currentColor so it always +// matches whatever text color surrounds it (sidebar wordmark vs. the much +// larger gate title) +// --------------------------------------------------------------------------- + +#[component] +fn PulseMark() -> impl IntoView { + view! { + + + + } +} + // --------------------------------------------------------------------------- // unauthenticated: the gate // --------------------------------------------------------------------------- @@ -94,8 +135,10 @@ fn LoginGate() -> impl IntoView {
"MESSAGE BUS · AUTH REQUIRED"

+ "CN" "ATS"

+

"chat over the bus"

"Realtime chat carried on NATS subjects. Identity issued by your Kanidm realm — no separate passwords, no local accounts."

@@ -210,11 +253,13 @@ fn ChatShell(room: Memo, user: User) -> impl IntoView { }; let me = user.username.clone(); + let call_me = me.clone(); view! {