Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions apps/staged/justfile
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,35 @@ dev repo="":
{{ if repo != "" { "export STAGED_REPO=" + repo } else { "" } }}
pnpm exec tauri dev --config "$TAURI_CONFIG"

# Run with the HTTPS web server enabled for phone/browser access.
# Requires PEM cert/key files and a hostname covered by the certificate.
dev-web repo="":
#!/usr/bin/env bash
set -euo pipefail

[[ -d node_modules ]] || pnpm install

if [[ -z "${STAGED_WEB_CERT_PATH:-}" || -z "${STAGED_WEB_KEY_PATH:-}" || -z "${STAGED_WEB_HOST:-}" ]]; then
printf '%s\n' \
'Error: `just dev-web` serves browser access over HTTPS.' \
'Provide PEM certificate/key files and a hostname covered by the certificate:' \
'' \
' STAGED_WEB_CERT_PATH=/path/to/cert.pem \' \
' STAGED_WEB_KEY_PATH=/path/to/key.pem \' \
' STAGED_WEB_HOST=hostname.example.com \' \
' just dev-web' >&2
exit 1
fi

VITE_PORT=$(python3 -c "import hashlib,os; h=int(hashlib.sha256(os.getcwd().encode()).hexdigest(),16); print(10000 + h % 55000)")
export VITE_PORT
export STAGED_WEB_SERVER=1
TAURI_CONFIG="{\"build\":{\"devUrl\":\"https://${STAGED_WEB_HOST}:${VITE_PORT}\",\"beforeDevCommand\":\"exec ./node_modules/.bin/vite --port ${VITE_PORT} --strictPort --host 0.0.0.0\"}}"

echo "Starting on https://${STAGED_WEB_HOST}:${VITE_PORT} (HTTPS web server on :5175)"
{{ if repo != "" { "export STAGED_REPO=" + repo } else { "" } }}
pnpm exec tauri dev --config "$TAURI_CONFIG"

# Build the app for production
build:
./scripts/prepare-acp-tools-resource.sh
Expand Down
1 change: 1 addition & 0 deletions apps/staged/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
"type": "module",
"scripts": {
"dev": "vite",
"dev:web": "vite --host 0.0.0.0",
"build": "vite build",
"preview": "vite preview",
"check": "svelte-check --tsconfig ./tsconfig.app.json --fail-on-warnings && tsc -p tsconfig.node.json",
Expand Down
33 changes: 2 additions & 31 deletions apps/staged/src-tauri/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 0 additions & 5 deletions apps/staged/src-tauri/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -65,14 +65,9 @@ tauri-plugin-store = "2.4.2"
# MCP server for project sessions
rmcp = { version = "0.17", features = ["server", "transport-streamable-http-server"] }
axum = { version = "0.8", features = ["ws"] }
axum-extra = { version = "0.10", features = ["cookie"] }
tower-http = { version = "0.6", features = ["fs", "cors"] }
rustls = { version = "0.23", default-features = false, features = ["aws_lc_rs", "std", "tls12"] }
tokio-rustls = { version = "0.26", default-features = false, features = ["aws_lc_rs", "tls12"] }
rand = "0.9"
hex = "0.4"
subtle = "2.6"
time = "0.3"
pikchr = "0.1.4"

# Pikchr preview MCP tool: render Pikchr source to SVG (same C engine as the
Expand Down
10 changes: 3 additions & 7 deletions apps/staged/src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2144,21 +2144,17 @@ pub fn run() {
let (event_tx, _) = tokio::sync::broadcast::channel::<web_server::WebEvent>(256);
app.manage(event_tx.clone());

// Web server startup is stubbed out in this build.
// TODO(web): restore web server startup from the `mobile-web` branch.
// Start the Axum web server only when opted-in via environment variable.
// This avoids exposing an HTTP server on all interfaces for users who
// don't need browser-based access.
let web_server_enabled = std::env::var("STAGED_WEB_SERVER")
.map(|v| v == "1" || v.eq_ignore_ascii_case("true"))
.unwrap_or(false);

if web_server_enabled {
let auth_token = web_server::generate_token();
web_server::start(web_server::WebAppState {
app_handle: app.handle().clone(),
event_tx,
auth_token,
sessions: std::sync::Arc::new(std::sync::Mutex::new(
std::collections::HashSet::new(),
)),
});
}

Expand Down
164 changes: 50 additions & 114 deletions apps/staged/src-tauri/src/web_server.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,37 +5,24 @@
//!
//! - `POST /api/invoke/{command}` — dispatches to the same logic as Tauri commands
//! - `GET /api/events` — WebSocket that broadcasts Tauri events as JSON
//! - `POST /api/auth` — accepts bearer token and sets session cookie
//! - `GET /*` — static files from `../dist` (the built Svelte frontend)
//!
//! All `/api/*` routes (except `/api/auth`) require authentication via either
//! an `Authorization: Bearer <token>` header or a valid `staged_session` cookie.

// The full implementation is preserved here but start() is currently stubbed out,
// so most items appear unused to the compiler.
#![allow(dead_code, unused_imports)]

use std::collections::HashSet;
use std::io;
use std::net::SocketAddr;
use std::path::PathBuf;
use std::sync::{Arc, Mutex};
use std::time::Duration;

use axum::extract::ws::{Message, WebSocket};
use axum::extract::{Path, Query, Request, State, WebSocketUpgrade};
use axum::extract::{Path, Query, State, WebSocketUpgrade};
use axum::http::StatusCode;
use axum::middleware::{self, Next};
use axum::response::{IntoResponse, Json, Response};
use axum::routing::{get, post};
use axum::serve::Listener;
use axum::Router;
use axum_extra::extract::cookie::{Cookie, CookieJar};
use rand::Rng;
use rustls::pki_types::pem::PemObject;
use rustls::pki_types::{CertificateDer, PrivateKeyDer};
use serde_json::Value;
use subtle::ConstantTimeEq;
use tokio::net::{TcpListener, TcpStream};
use tokio::sync::broadcast;
use tokio_rustls::server::TlsStream;
Expand All @@ -62,10 +49,6 @@ use crate::store::{self, Store};
pub struct WebAppState {
pub app_handle: tauri::AppHandle,
pub event_tx: broadcast::Sender<WebEvent>,
/// Hex-encoded 256-bit token required to authenticate web clients.
pub auth_token: String,
/// Set of valid session IDs, one per authenticated client.
pub sessions: Arc<Mutex<HashSet<String>>>,
}

/// A serialized event for WebSocket broadcast.
Expand Down Expand Up @@ -110,12 +93,6 @@ pub fn emit_to_all<S: serde::Serialize + Clone>(
// Server startup
// =============================================================================

/// Generate a cryptographically random hex-encoded token (256-bit).
pub fn generate_token() -> String {
let bytes: [u8; 32] = rand::rng().random();
hex::encode(bytes)
}

const CERT_PATH_ENV: &str = "STAGED_WEB_CERT_PATH";
const KEY_PATH_ENV: &str = "STAGED_WEB_KEY_PATH";

Expand Down Expand Up @@ -189,99 +166,58 @@ impl Listener for TlsListener {

/// Start the Axum web server in a background tokio task.
///
/// Stubbed — logs a warning and returns. The full implementation (TLS listener,
/// Axum router with static file serving) is intentionally disabled in this build.
/// All route handlers, auth middleware, and the `dispatch()` match block are kept
/// compiling so they stay in sync with the rest of the codebase.
///
/// TODO(web): restore full web server startup from the `mobile-web` branch.
pub fn start(_state: WebAppState) {
log::warn!("Web server requested but this build has the web server stubbed out");
}

// =============================================================================
// Authentication
// =============================================================================

const SESSION_COOKIE_NAME: &str = "staged_session";
const SESSION_MAX_AGE_DAYS: i64 = 7;

/// Constant-time string comparison to prevent timing side-channel attacks.
fn constant_time_eq(a: &str, b: &str) -> bool {
a.as_bytes().ct_eq(b.as_bytes()).into()
}

/// Middleware that rejects unauthenticated requests to protected routes.
///
/// Accepts either:
/// - `Authorization: Bearer <token>` header matching the server's auth token
/// - `staged_session` cookie matching the server's session ID
async fn require_auth(
State(state): State<WebAppState>,
jar: CookieJar,
request: Request,
next: Next,
) -> Response {
// Check Authorization header (constant-time comparison to prevent timing attacks)
if let Some(auth_header) = request.headers().get("authorization") {
if let Ok(value) = auth_header.to_str() {
if let Some(token) = value.strip_prefix("Bearer ") {
if constant_time_eq(token, &state.auth_token) {
return next.run(request).await;
}
/// This should be called from the Tauri `setup` hook after all managed state
/// has been registered.
pub fn start(state: WebAppState) {
tauri::async_runtime::spawn(async move {
let dist_dir = std::env::current_exe()
.ok()
.and_then(|p| p.parent().map(|p| p.to_path_buf()))
// In dev, the exe is in src-tauri/target/debug; dist is at ../../dist relative to src-tauri
.map(|p| {
// Try multiple candidate paths for the built frontend
let candidates = vec![
p.join("../dist"), // production bundle
p.join("../../../../dist"), // dev (target/debug -> src-tauri -> apps/staged -> dist)
PathBuf::from("../dist"), // relative to cwd
];
candidates
.into_iter()
.find(|c| c.exists())
.unwrap_or_else(|| PathBuf::from("../dist"))
})
.unwrap_or_else(|| PathBuf::from("../dist"));

let app = Router::new()
.route("/api/invoke/{command}", post(invoke_command))
.route("/api/events", get(ws_events))
.fallback_service(ServeDir::new(&dist_dir).append_index_html_on_directories(true))
.layer(CorsLayer::permissive())
.with_state(state);

let addr = "0.0.0.0:5175";
let tls_acceptor = match load_tls_acceptor() {
Ok(acceptor) => acceptor,
Err(e) => {
log::error!("[web_server] {e}");
return;
}
}
}

// Check session cookie against the set of valid sessions
if let Some(cookie) = jar.get(SESSION_COOKIE_NAME) {
let is_valid = {
let sessions = state.sessions.lock().unwrap_or_else(|e| e.into_inner());
let cookie_val = cookie.value();
sessions.iter().any(|s| constant_time_eq(cookie_val, s))
};
if is_valid {
return next.run(request).await;
log::info!(
"[web_server] starting HTTPS on {addr}, serving static files from {}",
dist_dir.display()
);
let listener = match tokio::net::TcpListener::bind(addr).await {
Ok(l) => l,
Err(e) => {
log::error!("[web_server] failed to bind {addr}: {e}");
return;
}
};
if let Err(e) = axum::serve(TlsListener::new(listener, tls_acceptor), app).await {
log::error!("[web_server] server error: {e}");
}
}

(StatusCode::UNAUTHORIZED, "Authentication required").into_response()
}

/// POST /api/auth — validate the bearer token and issue a session cookie.
///
/// Expects JSON body: `{ "token": "<auth_token>" }`
async fn authenticate(
State(state): State<WebAppState>,
jar: CookieJar,
Json(body): Json<Value>,
) -> Response {
let token = body
.get("token")
.and_then(|v| v.as_str())
.unwrap_or_default();

if !constant_time_eq(token, &state.auth_token) {
return (StatusCode::UNAUTHORIZED, "Invalid token").into_response();
}

// Generate a unique session ID for this client and register it.
let new_session_id = generate_token();
state
.sessions
.lock()
.unwrap_or_else(|e| e.into_inner())
.insert(new_session_id.clone());

let cookie = Cookie::build((SESSION_COOKIE_NAME, new_session_id))
.path("/")
.http_only(true)
.secure(true)
.max_age(time::Duration::days(SESSION_MAX_AGE_DAYS))
.same_site(axum_extra::extract::cookie::SameSite::Lax)
.build();

(jar.add(cookie), Json(serde_json::json!({ "ok": true }))).into_response()
});
}

// =============================================================================
Expand Down
Loading