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.
This commit is contained in:
+86
@@ -0,0 +1,86 @@
|
||||
use leptos::prelude::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Call signaling for a room, kept entirely separate from `chat::ChatMessage`
|
||||
/// - deliberately a different NATS subject namespace (`call.room.<room>`,
|
||||
/// not `chat.room.<room>`) so `server::store`'s JetStream/Postgres archive
|
||||
/// (scoped to `chat.room.*` only) never sees it. Ephemeral SDP/ICE has no
|
||||
/// business being durably stored.
|
||||
pub fn call_subject(room: &str) -> String {
|
||||
format!("call.room.{room}")
|
||||
}
|
||||
|
||||
/// `from`/`to` are peer ids - currently just the signed-in username (same
|
||||
/// identity chat messages use). `to: None` is a room-wide broadcast (only
|
||||
/// `Join`/`Leave` use this); everything else is directed at one peer, with
|
||||
/// every other browser in the room ignoring it client-side. Mesh calls at
|
||||
/// this scale (~4 people) don't need per-peer NATS subjects - broadcast +
|
||||
/// client-side filter is the simplest thing that works.
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct CallSignal {
|
||||
pub room: String,
|
||||
pub from: String,
|
||||
pub to: Option<String>,
|
||||
pub kind: CallSignalKind,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
#[serde(tag = "kind", content = "data")]
|
||||
pub enum CallSignalKind {
|
||||
/// Announces presence to the room; existing participants respond by
|
||||
/// initiating an offer to the new peer.
|
||||
Join,
|
||||
Leave,
|
||||
Offer(String),
|
||||
Answer(String),
|
||||
/// A single trickled ICE candidate, JSON-encoded
|
||||
/// (`RTCIceCandidateInit`, produced client-side).
|
||||
IceCandidate(String),
|
||||
}
|
||||
|
||||
/// Publishes a call-signaling message to the room's signaling subject.
|
||||
/// Requires a signed-in, room-authorized session - same two checks as
|
||||
/// `chat::send_message`, and deliberately not shared via a helper since
|
||||
/// the existing chat functions already establish that each enforcement
|
||||
/// point re-does this small check inline rather than factoring it out.
|
||||
#[server]
|
||||
pub async fn send_signal(
|
||||
room: String,
|
||||
to: Option<String>,
|
||||
kind: CallSignalKind,
|
||||
) -> Result<(), ServerFnError> {
|
||||
use crate::auth::{User, SESSION_USER_KEY};
|
||||
use crate::chat::is_authorized_for_room;
|
||||
use crate::server::AppState;
|
||||
|
||||
if !crate::chat::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"));
|
||||
};
|
||||
if !is_authorized_for_room(&user, &room) {
|
||||
return Err(ServerFnError::new("not authorized for this room"));
|
||||
}
|
||||
|
||||
let state = expect_context::<AppState>();
|
||||
let signal = CallSignal {
|
||||
room: room.clone(),
|
||||
from: user.username,
|
||||
to,
|
||||
kind,
|
||||
};
|
||||
let payload = serde_json::to_vec(&signal).map_err(|e| ServerFnError::new(e.to_string()))?;
|
||||
state
|
||||
.nats
|
||||
.publish(call_subject(&room), payload.into())
|
||||
.await
|
||||
.map_err(|e| ServerFnError::new(format!("nats publish failed: {e}")))?;
|
||||
Ok(())
|
||||
}
|
||||
Reference in New Issue
Block a user