Skip to main content

codex_web/
server.rs

1use std::collections::HashSet;
2use std::io::Error;
3use std::net::Ipv4Addr;
4use std::sync::Arc;
5
6use http::HeaderValue;
7use http::header::CACHE_CONTROL;
8use http::header::CONTENT_SECURITY_POLICY;
9use http::header::CONTENT_TYPE;
10use http::header::COOKIE;
11use http::header::HOST;
12use http::header::ORIGIN;
13use http::header::SET_COOKIE;
14use serde_json::Value;
15use serde_json::json;
16use tokio::io::AsyncReadExt;
17use tokio::io::AsyncWriteExt;
18use tokio::net::TcpListener;
19use tokio::net::TcpStream;
20use tokio::task::JoinSet;
21use tokio_util::sync::CancellationToken;
22use topcoat::Result;
23use topcoat::context::Cx;
24use topcoat::context::app_context;
25use topcoat::router::Body;
26use topcoat::router::IntoResponse;
27use topcoat::router::Response;
28use topcoat::router::Router;
29use topcoat::router::RouterBuilderDiscoverExt;
30use topcoat::router::forbidden;
31use topcoat::router::headers;
32use topcoat::router::not_found;
33use topcoat::router::page;
34use topcoat::router::path_param;
35use topcoat::router::route;
36use topcoat::router::to_bytes;
37use topcoat::router::unauthorized;
38use topcoat::router::uri;
39
40use crate::WebOptions;
41use crate::auth::WebAuth;
42use crate::bridge::AppServerBridge;
43use crate::components::DashboardDocumentData;
44use crate::components::DocumentData;
45use crate::components::bootstrap_document;
46use crate::components::dashboard_document;
47use crate::components::document;
48use crate::components::thread_title;
49use crate::components::transcript_fragment;
50use crate::dashboard;
51use crate::directory;
52use crate::directory::DirectoryListRequest;
53use crate::network::authority;
54use crate::network::bind_listeners;
55use crate::server_support::canonicalize_cwd;
56use crate::server_support::chronological_turns;
57use crate::server_support::generate_secret;
58use crate::server_support::mcp_callback_url;
59use crate::server_support::query_value;
60use crate::server_support::thread_with_initial_turns;
61use crate::stream;
62
63const MAX_RPC_BODY_BYTES: usize = 64 * 1024 * 1024;
64const MAX_AUTH_BODY_BYTES: usize = 4 * 1024;
65const APP_JS: &str = include_str!("../assets/app.js");
66const APP_CSS: &str = include_str!("../assets/app.css");
67const BOOTSTRAP_JS: &str = include_str!("../assets/bootstrap.js");
68const HOME_JS: &str = include_str!("../assets/home.js");
69
70struct WebState {
71    auth: WebAuth,
72    allowed_authorities: HashSet<String>,
73    instance_id: String,
74    cwd: String,
75    bridge: Arc<AppServerBridge>,
76    events: Arc<stream::LiveEventHub>,
77    shutdown: CancellationToken,
78    mcp_callback_port: Option<u16>,
79}
80
81#[topcoat::router::path_param]
82struct ThreadId(str);
83
84#[topcoat::router::path_param]
85struct InstanceId(str);
86
87#[topcoat::router::path_param]
88struct CallbackId(str);
89
90#[topcoat::router::path_param]
91struct SessionId(str);
92
93pub async fn run(options: WebOptions) -> std::io::Result<()> {
94    let explicit_cwd = options.cwd.is_some();
95    let cwd = options
96        .cwd
97        .map(canonicalize_cwd)
98        .transpose()?
99        .unwrap_or(std::env::current_dir()?.canonicalize()?);
100    let auth = WebAuth::load(options.reset_token)?;
101    let (listeners, _port) = bind_listeners(options.port).await?;
102    let addresses = listeners
103        .iter()
104        .filter_map(|listener| listener.local_addr().ok())
105        .collect::<Vec<_>>();
106    let mut config_overrides = options.config_overrides;
107    let mcp_callback_port = if let Some(callback_url) = mcp_callback_url(&addresses) {
108        let callback_listener = TcpListener::bind((Ipv4Addr::LOCALHOST, 0)).await?;
109        let callback_port = callback_listener.local_addr()?.port();
110        drop(callback_listener);
111        config_overrides.push(format!("mcp_oauth_callback_port={callback_port}"));
112        config_overrides.push(format!("mcp_oauth_callback_url={callback_url:?}"));
113        Some(callback_port)
114    } else {
115        None
116    };
117    let bridge = AppServerBridge::start(
118        &options.app_server_command,
119        &cwd,
120        &config_overrides,
121        options.strict_config,
122    )
123    .await?;
124    let shutdown_token = CancellationToken::new();
125    let event_hub = stream::LiveEventHub::start(Arc::clone(&bridge)).await;
126    let mut allowed_authorities = addresses
127        .iter()
128        .map(|address| authority(*address))
129        .collect::<HashSet<_>>();
130    if let Some(port) = addresses.first().map(std::net::SocketAddr::port) {
131        allowed_authorities.insert(format!("localhost:{port}"));
132    }
133    let state = Arc::new(WebState {
134        auth,
135        allowed_authorities,
136        instance_id: generate_secret()[..16].to_string(),
137        cwd: directory::path_string(&cwd)?,
138        bridge: Arc::clone(&bridge),
139        events: event_hub,
140        shutdown: shutdown_token.clone(),
141        mcp_callback_port,
142    });
143    let router = Router::builder()
144        .discover()
145        .app_context(Arc::clone(&state))
146        .build();
147    let service = topcoat::router::RouterService::new(router);
148    let mut servers = JoinSet::new();
149    for listener in listeners {
150        let service = service.clone();
151        let shutdown_token = shutdown_token.clone();
152        servers.spawn(async move {
153            topcoat::serve_until(listener, service, async move {
154                shutdown_token.cancelled().await;
155            })
156            .await
157        });
158    }
159
160    let startup_path = if explicit_cwd {
161        let return_to = format!("/new?cwd={}", urlencoding::encode(&state.cwd));
162        format!("/?returnTo={}", urlencoding::encode(&return_to))
163    } else {
164        "/".to_string()
165    };
166    let urls = addresses
167        .iter()
168        .map(|address| {
169            format!(
170                "http://{}{}#bootstrap={}",
171                authority(*address),
172                startup_path,
173                state.auth.bootstrap_token()
174            )
175        })
176        .collect::<Vec<_>>();
177    println!("\nCodex Web\n");
178    for url in &urls {
179        println!("  {url}");
180    }
181    println!("\nThis private link grants the server user's Codex and filesystem access.\n");
182
183    if options.open_browser
184        && let Some(url) = urls.iter().find(|url| url.starts_with("http://127.0.0.1:"))
185        && let Err(error) = webbrowser::open(url)
186    {
187        tracing::warn!(%error, "failed to open Codex Web in a browser");
188    }
189
190    tokio::select! {
191        signal = tokio::signal::ctrl_c() => signal?,
192        result = servers.join_next() => {
193            match result {
194                Some(Ok(Ok(()))) | None => {}
195                Some(Ok(Err(error))) => return Err(error),
196                Some(Err(error)) => return Err(Error::other(error)),
197            }
198        }
199    }
200    shutdown_token.cancel();
201    // Event streams are intentionally long-lived, so a graceful listener drain can wait forever
202    // for browser connections after Ctrl-C. Stop accepting work, then cancel those connection
203    // tasks so the CLI returns promptly.
204    servers.abort_all();
205    while let Some(result) = servers.join_next().await {
206        match result {
207            Ok(Ok(())) => {}
208            Ok(Err(error)) => tracing::warn!(%error, "Codex Web listener stopped"),
209            Err(error) if error.is_cancelled() => {}
210            Err(error) => tracing::warn!(%error, "Codex Web listener task failed"),
211        }
212    }
213    bridge.shutdown().await;
214    Ok(())
215}
216
217#[page("/")]
218async fn home(cx: &Cx) -> Result {
219    public_state(cx)?;
220    bootstrap_document(cx).await
221}
222
223#[page("/i/{instance_id}")]
224async fn dashboard_page(cx: &Cx) -> Result {
225    let state = authorized_state(cx)?;
226    let data = dashboard::load(&state.bridge).await;
227    let base_path = base_path(state);
228    dashboard_document(
229        cx,
230        DashboardDocumentData {
231            base_path: &base_path,
232            projects: &data.projects,
233            threads: &data.threads,
234            account: data.account.as_ref(),
235            truncated: data.truncated,
236        },
237    )
238    .await
239}
240
241#[page("/i/{instance_id}/new")]
242async fn new_thread_page(cx: &Cx) -> Result {
243    let state = authorized_state(cx)?;
244    let selected_cwd = query_value(uri(cx).query().unwrap_or_default(), "cwd")
245        .map(|encoded| urlencoding::decode(&encoded).map(std::borrow::Cow::into_owned))
246        .transpose()
247        .map_err(Error::other)?
248        .map(|path| directory::canonical_directory(&path))
249        .transpose()?
250        .map(|path| directory::path_string(&path))
251        .transpose()?;
252    render_page(cx, state, None, selected_cwd.as_deref()).await
253}
254
255#[page("/i/{instance_id}/thread/{thread_id}")]
256async fn thread_page(cx: &Cx) -> Result {
257    let state = authorized_state(cx)?;
258    render_page(cx, state, Some(path_param::<ThreadId>(cx)), None).await
259}
260
261#[route(GET "/assets/app.js")]
262async fn app_js(cx: &Cx) -> Result<Response> {
263    public_state(cx)?;
264    static_asset(cx, "text/javascript; charset=utf-8", APP_JS)
265}
266
267#[route(GET "/assets/app.css")]
268async fn app_css(cx: &Cx) -> Result<Response> {
269    public_state(cx)?;
270    static_asset(cx, "text/css; charset=utf-8", APP_CSS)
271}
272
273#[route(GET "/assets/bootstrap.js")]
274async fn bootstrap_js(cx: &Cx) -> Result<Response> {
275    public_state(cx)?;
276    static_asset(cx, "text/javascript; charset=utf-8", BOOTSTRAP_JS)
277}
278
279#[route(GET "/assets/home.js")]
280async fn home_js(cx: &Cx) -> Result<Response> {
281    public_state(cx)?;
282    static_asset(cx, "text/javascript; charset=utf-8", HOME_JS)
283}
284
285#[route(POST "/auth/exchange")]
286async fn exchange_token(cx: &Cx, body: Body) -> Result<Response> {
287    let state = public_state(cx)?;
288    authorize_mutation(cx, state)?;
289    let bytes = to_bytes(body, MAX_AUTH_BODY_BYTES)
290        .await
291        .map_err(Error::other)?;
292    let payload: Value = serde_json::from_slice(&bytes)?;
293    let token = payload
294        .get("token")
295        .and_then(Value::as_str)
296        .ok_or_else(unauthorized)?;
297    if !state.auth.verify_bootstrap(token) {
298        return Err(unauthorized().into());
299    }
300    let base_path = base_path(state);
301    let mut response = json_response(
302        cx,
303        json!({
304            "authenticated": true,
305            "basePath": base_path,
306        }),
307    )?;
308    response.headers_mut().insert(
309        SET_COOKIE,
310        HeaderValue::from_str(&state.auth.session_cookie(&base_path)?).map_err(Error::other)?,
311    );
312    Ok(response)
313}
314
315#[route(GET "/i/{instance_id}/events")]
316async fn event_stream(cx: &Cx) -> Result<Response> {
317    let state = authorized_state(cx)?;
318    let last_event_id = headers(cx)
319        .get("last-event-id")
320        .and_then(|value| value.to_str().ok())
321        .and_then(|value| value.parse().ok())
322        .or_else(|| {
323            query_value(uri(cx).query().unwrap_or_default(), "after")
324                .and_then(|value| value.parse().ok())
325        });
326    Ok(stream::response(Arc::clone(&state.events), last_event_id).await)
327}
328
329#[route(GET "/oauth/callback/{callback_id}")]
330async fn mcp_oauth_callback(cx: &Cx) -> Result<Response> {
331    let state = public_state(cx)?;
332    let callback_port = state
333        .mcp_callback_port
334        .ok_or_else(|| Error::other("remote MCP OAuth callback is not configured"))?;
335    let callback_id = path_param::<CallbackId>(cx);
336    let query = uri(cx).query().unwrap_or_default();
337    if !valid_callback_id(callback_id) || query.bytes().any(|byte| byte.is_ascii_control()) {
338        return Err(not_found().into());
339    }
340    let callback_path = format!("/oauth/callback/{callback_id}?{query}");
341    let mut stream = TcpStream::connect((Ipv4Addr::LOCALHOST, callback_port))
342        .await
343        .map_err(Error::other)?;
344    stream
345        .write_all(
346            format!(
347                "GET {callback_path} HTTP/1.1\r\nHost: 127.0.0.1:{callback_port}\r\nConnection: close\r\n\r\n"
348            )
349            .as_bytes(),
350        )
351        .await
352        .map_err(Error::other)?;
353    let mut encoded = Vec::new();
354    stream
355        .take(64 * 1024)
356        .read_to_end(&mut encoded)
357        .await
358        .map_err(Error::other)?;
359    let forwarded = String::from_utf8_lossy(&encoded);
360    let body = forwarded
361        .split_once("\r\n\r\n")
362        .map(|(_, body)| body)
363        .unwrap_or("MCP OAuth callback returned an invalid response")
364        .to_string();
365    let mut response = body.into_response(cx)?;
366    response.headers_mut().insert(
367        CONTENT_TYPE,
368        HeaderValue::from_static("text/plain; charset=utf-8"),
369    );
370    response
371        .headers_mut()
372        .insert(CACHE_CONTROL, HeaderValue::from_static("no-store"));
373    response.headers_mut().insert(
374        CONTENT_SECURITY_POLICY,
375        HeaderValue::from_static("default-src 'none'; frame-ancestors 'none'"),
376    );
377    Ok(response)
378}
379
380#[route(GET "/i/{instance_id}/api/session/{session_id}")]
381async fn session_fragment(cx: &Cx) -> Result<Response> {
382    let state = authorized_state(cx)?;
383    let session_id = path_param::<SessionId>(cx);
384    let base_seq = state.events.watermark();
385    let active_request = async {
386        if session_id == "new" {
387            None
388        } else {
389            let paginated = state
390                .bridge
391                .request(
392                    "thread/resume",
393                    json!({
394                        "threadId": session_id,
395                        "excludeTurns": true,
396                        "initialTurnsPage": {
397                            "limit": 30,
398                            "sortDirection": "desc",
399                            "itemsView": "full"
400                        }
401                    }),
402                )
403                .await;
404            match paginated {
405                Ok(response) => Some(response),
406                Err(_) => state
407                    .bridge
408                    .request("thread/resume", json!({ "threadId": session_id }))
409                    .await
410                    .ok(),
411            }
412        }
413    };
414    let (active, approvals) =
415        tokio::join!(active_request, state.bridge.outstanding_server_requests(),);
416    let active_thread = active.as_ref().and_then(|response| response.get("thread"));
417    let hydrated_thread = active.as_ref().and_then(thread_with_initial_turns);
418    let display_thread = hydrated_thread.as_ref().or(active_thread);
419    let fragment = transcript_fragment(cx, display_thread, &approvals).await?;
420    let active_turn_id = display_thread
421        .and_then(|active_thread| active_thread.get("turns"))
422        .and_then(Value::as_array)
423        .and_then(|turns| {
424            turns
425                .iter()
426                .rev()
427                .find(|turn| turn.get("status").and_then(Value::as_str) == Some("inProgress"))
428        })
429        .and_then(|turn| turn.get("id"))
430        .and_then(Value::as_str)
431        .unwrap_or_default();
432    let payload = json!({
433        "html": fragment.render(cx),
434        "baseSeq": base_seq,
435        "instanceId": state.instance_id,
436        "nextCursor": active.as_ref().and_then(|response| response.pointer("/initialTurnsPage/nextCursor")),
437        "threadId": display_thread
438            .and_then(|active_thread| active_thread.get("id"))
439            .and_then(Value::as_str)
440            .unwrap_or_default(),
441        "activeTurnId": active_turn_id,
442        "title": display_thread.map(thread_title).unwrap_or("New task"),
443        "cwd": display_thread
444            .and_then(|active_thread| active_thread.get("cwd"))
445            .and_then(Value::as_str)
446            .unwrap_or_default(),
447        "model": active
448            .as_ref()
449            .and_then(|response| response.get("model"))
450            .and_then(Value::as_str),
451        "reasoningEffort": active
452            .as_ref()
453            .and_then(|response| response.get("reasoningEffort"))
454            .and_then(Value::as_str),
455        "permissionMode": permission_mode(active.as_ref()),
456    });
457    let mut response = serde_json::to_vec(&payload)?.into_response(cx)?;
458    response
459        .headers_mut()
460        .insert(CONTENT_TYPE, HeaderValue::from_static("application/json"));
461    response
462        .headers_mut()
463        .insert(CACHE_CONTROL, HeaderValue::from_static("no-store"));
464    Ok(response)
465}
466
467#[route(GET "/i/{instance_id}/api/session/{session_id}/turns")]
468async fn earlier_turns(cx: &Cx) -> Result<Response> {
469    let state = authorized_state(cx)?;
470    let session_id = path_param::<SessionId>(cx);
471    let cursor = query_value(uri(cx).query().unwrap_or_default(), "cursor");
472    let page = state
473        .bridge
474        .request(
475            "thread/turns/list",
476            json!({
477                "threadId": session_id,
478                "cursor": cursor,
479                "limit": 30,
480                "sortDirection": "desc",
481                "itemsView": "full"
482            }),
483        )
484        .await
485        .map_err(Error::other)?;
486    let thread_value = json!({ "turns": chronological_turns(page.get("data")) });
487    let fragment = transcript_fragment(cx, Some(&thread_value), &[]).await?;
488    json_response(
489        cx,
490        json!({
491            "html": fragment.render(cx),
492            "nextCursor": page.get("nextCursor").cloned().unwrap_or(Value::Null)
493        }),
494    )
495}
496
497#[route(POST "/i/{instance_id}/api/shutdown")]
498async fn shutdown_process(cx: &Cx) -> Result<Response> {
499    let state = authorized_state(cx)?;
500    authorize_mutation(cx, state)?;
501    state.shutdown.cancel();
502    json_response(cx, json!({ "stopping": true }))
503}
504
505#[route(POST "/i/{instance_id}/api/rpc")]
506async fn rpc(cx: &Cx, body: Body) -> Result<Response> {
507    let state = authorized_state(cx)?;
508    authorize_mutation(cx, state)?;
509    let bytes = to_bytes(body, MAX_RPC_BODY_BYTES)
510        .await
511        .map_err(Error::other)?;
512    let mut message: Value = serde_json::from_slice(&bytes)?;
513    if message.get("method").and_then(Value::as_str) == Some("thread/start")
514        && let Some(cwd) = message
515            .pointer("/params/cwd")
516            .and_then(Value::as_str)
517            .map(ToOwned::to_owned)
518    {
519        let canonical = directory::canonical_directory(&cwd)?;
520        if let Some(params) = message.get_mut("params").and_then(Value::as_object_mut) {
521            params.insert(
522                "cwd".to_string(),
523                Value::String(directory::path_string(&canonical)?),
524            );
525        }
526    }
527    let envelope = if let Some(method) = message.get("method").and_then(Value::as_str) {
528        state
529            .bridge
530            .request_envelope(
531                method,
532                message.get("params").cloned().unwrap_or_else(|| json!({})),
533            )
534            .await
535            .map_err(Error::other)?
536    } else {
537        state.bridge.respond(message).await.map_err(Error::other)?;
538        json!({ "result": {} })
539    };
540    let mut response = serde_json::to_vec(&envelope)?.into_response(cx)?;
541    response
542        .headers_mut()
543        .insert(CONTENT_TYPE, HeaderValue::from_static("application/json"));
544    response
545        .headers_mut()
546        .insert(CACHE_CONTROL, HeaderValue::from_static("no-store"));
547    Ok(response)
548}
549
550#[route(POST "/i/{instance_id}/api/fs/list")]
551async fn list_directory(cx: &Cx, body: Body) -> Result<Response> {
552    let state = authorized_state(cx)?;
553    authorize_mutation(cx, state)?;
554    let bytes = to_bytes(body, MAX_AUTH_BODY_BYTES)
555        .await
556        .map_err(Error::other)?;
557    let request: DirectoryListRequest = serde_json::from_slice(&bytes)?;
558    match tokio::task::spawn_blocking(move || directory::list(request)).await {
559        Ok(Ok(listing)) => {
560            let payload = serde_json::to_value(listing)?;
561            json_response(cx, payload)
562        }
563        Ok(Err(error)) => json_response(cx, json!({ "error": error.to_string() })),
564        Err(error) => Err(Error::other(error).into()),
565    }
566}
567
568async fn render_page(
569    cx: &Cx,
570    state: &WebState,
571    thread_id: Option<&str>,
572    selected_cwd: Option<&str>,
573) -> Result {
574    let base_seq = state.events.watermark();
575    let threads_request = state.bridge.request(
576        "thread/list",
577        json!({ "limit": 100, "sortKey": "recency_at", "sortDirection": "desc" }),
578    );
579    let models_request = state.bridge.request("model/list", json!({ "limit": 100 }));
580    let collaboration_modes_request = state.bridge.request("collaborationMode/list", json!({}));
581    let mcp_servers_request = state.bridge.request(
582        "mcpServerStatus/list",
583        json!({ "limit": 100, "detail": "toolsAndAuthOnly" }),
584    );
585    let active_request = async {
586        match thread_id {
587            Some(thread_id) => state
588                .bridge
589                .request(
590                    "thread/resume",
591                    json!({
592                        "threadId": thread_id,
593                        "excludeTurns": true,
594                        "initialTurnsPage": {
595                            "limit": 30,
596                            "sortDirection": "desc",
597                            "itemsView": "full"
598                        }
599                    }),
600                )
601                .await
602                .ok(),
603            None => None,
604        }
605    };
606    let approvals_request = state.bridge.outstanding_server_requests();
607    let (threads, models, collaboration_modes, mcp_servers, active, approvals) = tokio::join!(
608        threads_request,
609        models_request,
610        collaboration_modes_request,
611        mcp_servers_request,
612        active_request,
613        approvals_request,
614    );
615    let threads = threads.unwrap_or_else(|_| json!({ "data": [] }));
616    let models = models.unwrap_or_else(|_| json!({ "data": [] }));
617    let collaboration_modes = collaboration_modes.unwrap_or_else(|_| json!({ "data": [] }));
618    let mcp_servers = mcp_servers.unwrap_or_else(|_| json!({ "data": [] }));
619    let hydrated_thread = active.as_ref().and_then(thread_with_initial_turns);
620    let active_thread = hydrated_thread
621        .as_ref()
622        .or_else(|| active.as_ref().and_then(|response| response.get("thread")));
623    let workspace_cwd = page_workspace_cwd(active_thread, selected_cwd, &state.cwd);
624    let base_path = base_path(state);
625    document(
626        cx,
627        DocumentData {
628            base_path: &base_path,
629            instance_id: &state.instance_id,
630            base_seq,
631            threads: threads
632                .get("data")
633                .and_then(Value::as_array)
634                .map(Vec::as_slice)
635                .unwrap_or_default(),
636            active_thread,
637            active_model: active
638                .as_ref()
639                .and_then(|response| response.get("model"))
640                .and_then(Value::as_str)
641                .unwrap_or_default(),
642            active_effort: active
643                .as_ref()
644                .and_then(|response| response.get("reasoningEffort"))
645                .and_then(Value::as_str)
646                .unwrap_or_default(),
647            active_permission_mode: permission_mode(active.as_ref()),
648            models: models
649                .get("data")
650                .and_then(Value::as_array)
651                .map(Vec::as_slice)
652                .unwrap_or_default(),
653            collaboration_modes: collaboration_modes
654                .get("data")
655                .and_then(Value::as_array)
656                .map(Vec::as_slice)
657                .unwrap_or_default(),
658            approvals: &approvals,
659            workspace_cwd,
660            mcp_servers: mcp_servers
661                .get("data")
662                .and_then(Value::as_array)
663                .map(Vec::as_slice)
664                .unwrap_or_default(),
665            initial_next_cursor: active
666                .as_ref()
667                .and_then(|response| response.pointer("/initialTurnsPage/nextCursor"))
668                .and_then(Value::as_str),
669        },
670    )
671    .await
672}
673
674fn page_workspace_cwd<'a>(
675    active_thread: Option<&'a Value>,
676    selected_cwd: Option<&'a str>,
677    daemon_cwd: &'a str,
678) -> &'a str {
679    active_thread
680        .and_then(|thread| thread.get("cwd"))
681        .and_then(Value::as_str)
682        .or(selected_cwd)
683        .unwrap_or(daemon_cwd)
684}
685
686fn permission_mode(response: Option<&Value>) -> &'static str {
687    let profile_id = response
688        .and_then(|response| response.pointer("/activePermissionProfile/id"))
689        .and_then(Value::as_str);
690    match profile_id {
691        Some(":danger-full-access") => "full-access",
692        Some(":read-only") => "read-only",
693        Some(":workspace") => "workspace",
694        _ => {
695            let approval = response
696                .and_then(|response| response.get("approvalPolicy"))
697                .and_then(Value::as_str);
698            let sandbox = response
699                .and_then(|response| response.pointer("/sandbox/type"))
700                .and_then(Value::as_str);
701            match (approval, sandbox) {
702                (Some("never"), Some("danger-full-access")) => "full-access",
703                (_, Some("read-only")) => "read-only",
704                _ => "workspace",
705            }
706        }
707    }
708}
709
710fn authorized_state(cx: &Cx) -> Result<&WebState> {
711    let state = instance_state(cx)?;
712    if !is_authorized(cx, state) {
713        return Err(not_found().into());
714    }
715    Ok(state)
716}
717
718fn instance_state(cx: &Cx) -> Result<&WebState> {
719    let state = public_state(cx)?;
720    if path_param::<InstanceId>(cx) != state.instance_id {
721        return Err(not_found().into());
722    }
723    Ok(state)
724}
725
726fn base_path(state: &WebState) -> String {
727    format!("/i/{}", state.instance_id)
728}
729
730fn valid_callback_id(callback_id: &str) -> bool {
731    callback_id.len() == 12
732        && callback_id
733            .bytes()
734            .all(|byte| byte.is_ascii_alphanumeric() || byte == b'-' || byte == b'_')
735}
736
737fn public_state(cx: &Cx) -> Result<&WebState> {
738    let state: &Arc<WebState> = app_context(cx);
739    let host = headers(cx)
740        .get(HOST)
741        .and_then(|value| value.to_str().ok())
742        .ok_or_else(forbidden)?;
743    if !state.allowed_authorities.contains(host) {
744        return Err(forbidden().into());
745    }
746    Ok(state)
747}
748
749fn is_authorized(cx: &Cx, state: &WebState) -> bool {
750    state.auth.authorize_cookie_header(
751        headers(cx)
752            .get(COOKIE)
753            .and_then(|value| value.to_str().ok()),
754    )
755}
756
757fn authorize_mutation(cx: &Cx, state: &WebState) -> Result<()> {
758    let host = headers(cx)
759        .get(HOST)
760        .and_then(|value| value.to_str().ok())
761        .ok_or_else(forbidden)?;
762    let origin = headers(cx)
763        .get(ORIGIN)
764        .and_then(|value| value.to_str().ok())
765        .ok_or_else(forbidden)?;
766    if !state.allowed_authorities.contains(host) || origin != format!("http://{host}") {
767        return Err(forbidden().into());
768    }
769    Ok(())
770}
771
772fn static_asset(cx: &Cx, content_type: &'static str, body: &'static str) -> Result<Response> {
773    let mut response = body.into_response(cx)?;
774    response
775        .headers_mut()
776        .insert(CONTENT_TYPE, HeaderValue::from_static(content_type));
777    response
778        .headers_mut()
779        .insert(CACHE_CONTROL, HeaderValue::from_static("no-store"));
780    Ok(response)
781}
782
783fn json_response(cx: &Cx, payload: Value) -> Result<Response> {
784    let mut response = serde_json::to_vec(&payload)?.into_response(cx)?;
785    response
786        .headers_mut()
787        .insert(CONTENT_TYPE, HeaderValue::from_static("application/json"));
788    response
789        .headers_mut()
790        .insert(CACHE_CONTROL, HeaderValue::from_static("no-store"));
791    Ok(response)
792}
793
794#[cfg(test)]
795#[path = "server_tests.rs"]
796mod tests;