Files
cnats/src/call.rs
T
bl 650ed50c21 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.
2026-07-27 23:52:27 +02:00

87 lines
3.0 KiB
Rust

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(())
}