Skip to main content

mlua_swarm_server/operator_ws/
login.rs

1//! REST-like Operator session resource.
2//!
3//! Provides the `POST/GET/DELETE /v1/operators` + `WS /v1/operators/:sid/ws`
4//! route family — the sole WS Operator session route. `session.rs` /
5//! `protocol.rs` are unchanged by this module.
6//!
7//! ## Login flow
8//!
9//! ```text
10//! POST /v1/operators { roles?: ["main-ai"] }
11//!   → 409 if any role already owns a live entry (roles alias exclusivity,
12//!     v1.md §Auth session flow)
13//!   → { sid: "S-<hex>", token: "<10-hex>", roles: [...] }
14//!
15//! WS /v1/operators/:sid/ws
16//!   Authorization: Bearer <token>   (mandatory — no empty-string default)
17//!   → 401 missing/empty Bearer, 404 unknown sid, 401 token mismatch
18//!   → registers a `WSOperatorSession` into the engine's 3 registries
19//!     (senior_bridge / spawn_hook / operator) + role aliases, same pattern
20//!     as `handler::handle_socket`. Reconnect (same sid, matching token)
21//!     reuses the existing `WSOperatorSession` via `replace_tx`.
22//!
23//! DELETE /v1/operators/:sid   (Bearer required)
24//!   → unregisters the 3 registries + role aliases + `operator_sessions`
25//!     entry + releases `roles_to_sid` ownership.
26//!
27//! GET /v1/operators/:sid   (Bearer required)
28//!   → { sid, roles, connected }
29//! ```
30//!
31//! `OperatorSessionEntry` is the login-flow record (`AppState.operator_sessions`),
32//! distinct from `mlua_swarm::OperatorSession` (the engine-side
33//! `attach`/session-token record) and from `WSOperatorSession` (the 3-trait WS
34//! session, `session.rs`) — this module owns the mapping `sid → (token, roles,
35//! Option<WSOperatorSession>)` that the login flow is built on.
36
37use axum::{
38    extract::{
39        ws::{Message, WebSocket, WebSocketUpgrade},
40        Path, State,
41    },
42    http::{HeaderMap, StatusCode},
43    response::{IntoResponse, Response},
44    Json,
45};
46use futures_util::{sink::SinkExt, stream::StreamExt};
47use mlua_swarm::{Operator, SeniorBridge, SessionId, SpawnHook};
48use serde::{Deserialize, Serialize};
49use serde_json::json;
50use std::sync::Arc;
51use tokio::sync::{mpsc, Mutex};
52
53use super::protocol::{ClientMsg, PendingReply, ServerMsg};
54use super::session::WSOperatorSession;
55use crate::AppState;
56
57/// Login-flow record for a minted Operator session. Held in
58/// `AppState.operator_sessions`, keyed by `sid`. `ws_session` starts `None`
59/// (login only mints sid+token) and is set on first successful WS connect;
60/// on reconnect the same `WSOperatorSession` is reused (`replace_tx`) rather
61/// than re-registered.
62pub struct OperatorSessionEntry {
63    /// Server-minted session id (typed [`SessionId`] since issue #14).
64    pub sid: SessionId,
65    /// Bearer auth token (10-hex-char) required on the WS upgrade and admin routes.
66    pub token: String,
67    /// Role aliases claimed by this session (roles-exclusivity set).
68    pub roles: Vec<String>,
69    /// The live 3-trait session object once a WS has connected; `None` before first connect.
70    pub ws_session: Mutex<Option<Arc<WSOperatorSession>>>,
71}
72
73// ─── POST /v1/operators (mint) ──────────────────────────────────────────────
74
75/// Body for `POST /v1/operators`.
76#[derive(Debug, Deserialize, Default)]
77pub struct OperatorsCreateReq {
78    /// Role aliases to claim exclusively (empty = no exclusivity claimed).
79    #[serde(default)]
80    pub roles: Vec<String>,
81}
82
83/// Response for `POST /v1/operators`.
84#[derive(Debug, Serialize)]
85pub struct OperatorsCreateResp {
86    /// Newly minted session id (typed [`SessionId`]; serializes as the
87    /// plain `S-<hex>` string — the wire shape is unchanged).
88    pub sid: SessionId,
89    /// Bearer auth token required on the WS upgrade and admin routes.
90    pub token: String,
91    /// Echoes the granted role aliases.
92    pub roles: Vec<String>,
93}
94
95/// `POST /v1/operators`. Mints `sid` (`S-<hex>` — the shared `SessionId`
96/// shape; issue #11) + a 10-hex-char token
97/// (`mlua_swarm::types::secure_hex(5)` — OS-RNG hex, unguessable across
98/// calls and restarts, which is the point: this token is the sole bearer
99/// secret on the short-handle path). When `roles` is non-empty, checks
100/// `AppState.roles_to_sid` for conflicts under a single lock (check + insert
101/// atomic w.r.t. concurrent mints) and returns `409 CONFLICT` with the
102/// conflicting role names on collision. Empty `roles` never conflicts (= no
103/// exclusivity is claimed).
104pub async fn operators_create(
105    State(state): State<AppState>,
106    Json(req): Json<OperatorsCreateReq>,
107) -> Response {
108    let roles = req.roles;
109    // The sid is the operator-session identity, so it mints in the same
110    // `SessionId` shape (`S-<hex>`) as the engine-side session id — one
111    // session-id form across the system (issue #11 observation 2; the old
112    // `op-<uuid>` shape collided with the operator-backend registry prefix).
113    // It is an identifier, not a secret: `token` (secure_hex) is the sole
114    // bearer credential on this path.
115    let sid = SessionId::new();
116    let token = mlua_swarm::types::secure_hex(5);
117
118    {
119        let mut map = state.roles_to_sid.lock().await;
120        let conflicts: Vec<String> = roles
121            .iter()
122            .filter(|r| map.contains_key(r.as_str()))
123            .cloned()
124            .collect();
125        if !conflicts.is_empty() {
126            return (
127                StatusCode::CONFLICT,
128                Json(json!({"error": "roles conflict", "conflicts": conflicts})),
129            )
130                .into_response();
131        }
132        for r in &roles {
133            map.insert(r.clone(), sid.clone());
134        }
135    }
136
137    let entry = Arc::new(OperatorSessionEntry {
138        sid: sid.clone(),
139        token: token.clone(),
140        roles: roles.clone(),
141        ws_session: Mutex::new(None),
142    });
143    state
144        .operator_sessions
145        .lock()
146        .await
147        .insert(sid.clone(), entry);
148
149    (
150        StatusCode::OK,
151        Json(OperatorsCreateResp { sid, token, roles }),
152    )
153        .into_response()
154}
155
156// ─── WS /v1/operators/:sid/ws (Bearer required) ─────────────────────────────
157
158/// Extracts `Authorization: Bearer <token>`; missing header, wrong scheme, or
159/// an empty token all resolve to a `401` response. `Authorization` is
160/// mandatory on the WS path — there is no empty-string default.
161fn extract_bearer_token_required(headers: &HeaderMap) -> Result<String, Box<Response>> {
162    let token = headers
163        .get(axum::http::header::AUTHORIZATION)
164        .and_then(|v| v.to_str().ok())
165        .and_then(|s| s.strip_prefix("Bearer "))
166        .map(|s| s.trim().to_string())
167        .filter(|s| !s.is_empty());
168    token.ok_or_else(|| {
169        Box::new((StatusCode::UNAUTHORIZED, "missing or empty Bearer token").into_response())
170    })
171}
172
173/// `GET /v1/operators/:sid/ws` (WS upgrade). Bearer mandatory. `404` on
174/// unknown sid, `401` on token mismatch. On successful upgrade, registers (or
175/// reuses, on reconnect) a `WSOperatorSession` under `sid` — same 3-registry
176/// pattern as `handler::handle_socket`, plus role-alias registration for
177/// every role minted alongside this sid.
178pub async fn operators_ws_connect(
179    State(state): State<AppState>,
180    Path(sid): Path<String>,
181    headers: HeaderMap,
182    ws: WebSocketUpgrade,
183) -> Response {
184    let bearer = match extract_bearer_token_required(&headers) {
185        Ok(t) => t,
186        Err(resp) => return *resp,
187    };
188    // A string that doesn't even parse as a SessionId can't be a known sid.
189    let Ok(sid) = SessionId::parse(sid) else {
190        return (StatusCode::NOT_FOUND, "unknown sid").into_response();
191    };
192
193    let entry = {
194        let map = state.operator_sessions.lock().await;
195        map.get(&sid).cloned()
196    };
197    let entry = match entry {
198        Some(e) => e,
199        None => return (StatusCode::NOT_FOUND, "unknown sid").into_response(),
200    };
201    if !mlua_swarm::types::ct_eq(entry.token.as_bytes(), bearer.as_bytes()) {
202        return (StatusCode::UNAUTHORIZED, "token mismatch").into_response();
203    }
204
205    ws.on_upgrade(move |socket| handle_operator_socket(socket, state, entry))
206}
207
208/// Bidirectional pump for a single WS connection, bound to an
209/// `OperatorSessionEntry`. Owns the full wire protocol pump (write task /
210/// read task / `ClientMsg` dispatch / disconnect) for this session.
211async fn handle_operator_socket(
212    socket: WebSocket,
213    state: AppState,
214    entry: Arc<OperatorSessionEntry>,
215) {
216    let (tx, mut rx) = mpsc::unbounded_channel::<ServerMsg>();
217
218    let existing_ws = entry.ws_session.lock().await.clone();
219    let session = match existing_ws {
220        Some(ws_session) => {
221            // Reconnect: reuse the existing WSOperatorSession on this entry; only swap out `tx`.
222            ws_session.replace_tx(tx.clone()).await;
223            ws_session
224        }
225        None => {
226            let ws_session = Arc::new(WSOperatorSession::new_with_base_url(
227                entry.sid.clone(),
228                tx.clone(),
229                state.base_url.clone(),
230            ));
231            state
232                .engine
233                .register_senior_bridge(
234                    entry.sid.clone(),
235                    ws_session.clone() as Arc<dyn SeniorBridge>,
236                )
237                .await;
238            state
239                .engine
240                .register_spawn_hook(entry.sid.clone(), ws_session.clone() as Arc<dyn SpawnHook>)
241                .await;
242            state
243                .engine
244                .register_operator(entry.sid.clone(), ws_session.clone() as Arc<dyn Operator>)
245                .await;
246            if let Some(factory) = &state.ws_operator_factory {
247                factory
248                    .register_operator(entry.sid.clone(), ws_session.clone() as Arc<dyn Operator>);
249            }
250            // Role exclusivity was already resolved at login (POST) time. Here
251            // we just bind the same session into the three registries + factory
252            // under its role aliases (same shape as handler::handle_socket's
253            // ?roles= path).
254            for role in &entry.roles {
255                if let Some(factory) = &state.ws_operator_factory {
256                    factory
257                        .register_operator(role.clone(), ws_session.clone() as Arc<dyn Operator>);
258                }
259                state
260                    .engine
261                    .register_operator(role.clone(), ws_session.clone() as Arc<dyn Operator>)
262                    .await;
263            }
264            *entry.ws_session.lock().await = Some(ws_session.clone());
265            ws_session
266        }
267    };
268
269    let (mut ws_sink, mut ws_stream) = socket.split();
270
271    // write task: mpsc → WebSocket
272    let write_task = tokio::spawn(async move {
273        while let Some(msg) = rx.recv().await {
274            let txt = match serde_json::to_string(&msg) {
275                Ok(s) => s,
276                Err(_) => continue,
277            };
278            if ws_sink.send(Message::Text(txt)).await.is_err() {
279                break;
280            }
281        }
282        let _ = ws_sink.close().await;
283    });
284
285    // read task: WS message → ClientMsg parse → session.resolve_pending
286    let session_for_read = session.clone();
287    let read_result: Result<(), String> = async {
288        while let Some(item) = ws_stream.next().await {
289            match item {
290                Ok(Message::Text(t)) => {
291                    let parsed: ClientMsg = match serde_json::from_str(&t) {
292                        Ok(p) => p,
293                        Err(_) => continue,
294                    };
295                    match parsed {
296                        ClientMsg::Answer { req_id, value } => {
297                            session_for_read
298                                .resolve_pending(&req_id, PendingReply::Answer(value))
299                                .await;
300                        }
301                        ClientMsg::HookAck { req_id, ok, reason } => {
302                            session_for_read
303                                .resolve_pending(&req_id, PendingReply::HookAck { ok, reason })
304                                .await;
305                        }
306                        ClientMsg::SpawnAck {
307                            req_id,
308                            value,
309                            ok,
310                            error,
311                        } => {
312                            session_for_read
313                                .resolve_pending(
314                                    &req_id,
315                                    PendingReply::SpawnAck { value, ok, error },
316                                )
317                                .await;
318                        }
319                        ClientMsg::SpawnHalt {
320                            req_id,
321                            value,
322                            reason,
323                        } => {
324                            session_for_read
325                                .resolve_pending(&req_id, PendingReply::SpawnHalt { value, reason })
326                                .await;
327                        }
328                    }
329                }
330                Ok(Message::Ping(_)) | Ok(Message::Pong(_)) => {}
331                Ok(Message::Close(_)) | Err(_) => break,
332                _ => {}
333            }
334        }
335        Ok(())
336    }
337    .await;
338
339    // Disconnect: tx → None (the session itself stays in operator_sessions
340    // and the three registries, waiting for a reconnect; teardown happens
341    // only through DELETE).
342    session.clear_tx().await;
343    write_task.abort();
344    let _ = read_result;
345}
346
347// ─── DELETE /v1/operators/:sid (Bearer required) ────────────────────────────
348
349/// `DELETE /v1/operators/:sid`. Bearer mandatory. `404` on unknown sid, `401`
350/// on token mismatch. Drops the 3 engine registries + role aliases +
351/// `ws_operator_factory` bindings + `operator_sessions` entry, and releases
352/// this sid's ownership in `roles_to_sid` (re-opening the role names for a
353/// future mint).
354pub async fn operators_delete(
355    State(state): State<AppState>,
356    Path(sid): Path<String>,
357    headers: HeaderMap,
358) -> Response {
359    let bearer = match extract_bearer_token_required(&headers) {
360        Ok(t) => t,
361        Err(resp) => return *resp,
362    };
363    let Ok(sid) = SessionId::parse(sid) else {
364        return (StatusCode::NOT_FOUND, "unknown sid").into_response();
365    };
366
367    let entry = {
368        let map = state.operator_sessions.lock().await;
369        map.get(&sid).cloned()
370    };
371    let entry = match entry {
372        Some(e) => e,
373        None => return (StatusCode::NOT_FOUND, "unknown sid").into_response(),
374    };
375    if !mlua_swarm::types::ct_eq(entry.token.as_bytes(), bearer.as_bytes()) {
376        return (StatusCode::UNAUTHORIZED, "token mismatch").into_response();
377    }
378
379    state.engine.unregister_senior_bridge(sid.as_str()).await;
380    state.engine.unregister_spawn_hook(sid.as_str()).await;
381    state.engine.unregister_operator(sid.as_str()).await;
382    if let Some(factory) = &state.ws_operator_factory {
383        factory.unregister_operator(sid.as_str());
384    }
385    for role in &entry.roles {
386        state.engine.unregister_operator(role).await;
387        if let Some(factory) = &state.ws_operator_factory {
388            factory.unregister_operator(role);
389        }
390    }
391
392    if let Some(session) = entry.ws_session.lock().await.take() {
393        session.clear_tx().await;
394    }
395
396    state.operator_sessions.lock().await.remove(&sid);
397
398    {
399        let mut map = state.roles_to_sid.lock().await;
400        for role in &entry.roles {
401            if map.get(role) == Some(&sid) {
402                map.remove(role);
403            }
404        }
405    }
406
407    StatusCode::NO_CONTENT.into_response()
408}
409
410// ─── GET /v1/operators/:sid (Bearer required) ───────────────────────────────
411
412/// Response for `GET /v1/operators/:sid`.
413#[derive(Debug, Serialize)]
414pub struct OperatorsInfoResp {
415    /// Echoes the requested session id.
416    pub sid: SessionId,
417    /// Role aliases held by this session.
418    pub roles: Vec<String>,
419    /// Whether a WS is currently attached (not merely that the session ever connected).
420    pub connected: bool,
421}
422
423/// `GET /v1/operators/:sid`. Bearer mandatory. `404` on unknown sid, `401` on
424/// token mismatch. `connected` reflects whether `ws_session` is currently
425/// `Some` (= a WS is live, not merely that the session was ever connected).
426pub async fn operators_info(
427    State(state): State<AppState>,
428    Path(sid): Path<String>,
429    headers: HeaderMap,
430) -> Response {
431    let bearer = match extract_bearer_token_required(&headers) {
432        Ok(t) => t,
433        Err(resp) => return *resp,
434    };
435    let Ok(sid) = SessionId::parse(sid) else {
436        return (StatusCode::NOT_FOUND, "unknown sid").into_response();
437    };
438
439    let entry = {
440        let map = state.operator_sessions.lock().await;
441        map.get(&sid).cloned()
442    };
443    let entry = match entry {
444        Some(e) => e,
445        None => return (StatusCode::NOT_FOUND, "unknown sid").into_response(),
446    };
447    if !mlua_swarm::types::ct_eq(entry.token.as_bytes(), bearer.as_bytes()) {
448        return (StatusCode::UNAUTHORIZED, "token mismatch").into_response();
449    }
450
451    let connected = entry.ws_session.lock().await.is_some();
452    (
453        StatusCode::OK,
454        Json(OperatorsInfoResp {
455            sid: entry.sid.clone(),
456            roles: entry.roles.clone(),
457            connected,
458        }),
459    )
460        .into_response()
461}
462
463#[cfg(test)]
464mod tests {
465    use super::*;
466    use axum::http::HeaderValue;
467
468    fn headers_with_bearer(token: &str) -> HeaderMap {
469        let mut h = HeaderMap::new();
470        h.insert(
471            axum::http::header::AUTHORIZATION,
472            HeaderValue::from_str(&format!("Bearer {token}")).unwrap(),
473        );
474        h
475    }
476
477    #[test]
478    fn extract_bearer_token_required_accepts_valid() {
479        let h = headers_with_bearer("abc123");
480        assert_eq!(extract_bearer_token_required(&h).unwrap(), "abc123");
481    }
482
483    #[test]
484    fn extract_bearer_token_required_rejects_missing_header() {
485        let h = HeaderMap::new();
486        assert!(extract_bearer_token_required(&h).is_err());
487    }
488
489    #[test]
490    fn extract_bearer_token_required_rejects_empty_token() {
491        let h = headers_with_bearer("");
492        assert!(extract_bearer_token_required(&h).is_err());
493    }
494
495    #[test]
496    fn extract_bearer_token_required_rejects_wrong_scheme() {
497        let mut h = HeaderMap::new();
498        h.insert(
499            axum::http::header::AUTHORIZATION,
500            HeaderValue::from_static("Basic dXNlcjpwYXNz"),
501        );
502        assert!(extract_bearer_token_required(&h).is_err());
503    }
504}