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 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;