Compare commits

...

1 Commits

Author SHA1 Message Date
simianastronaut
91b937c2cf fix(gateway): auto-build web frontend so pairing config check reaches the browser (#2879)
The web dashboard's useAuth hook already checks GET /health for
require_pairing before showing the pairing modal, but the compiled
frontend assets in web/dist/ were never rebuilt after that fix landed
(web/dist is gitignored and the build.rs only created the empty
directory).

Update build.rs to automatically run `npm ci && npm run build` when
web/dist/index.html is missing and npm is available. This ensures
cargo build produces a binary with up-to-date dashboard assets that
respect the server's require_pairing configuration.

The build is best-effort: when Node.js is absent (CI containers,
cross-compilation) the Rust build still succeeds with an empty dist.

Also adds tests verifying the /health endpoint correctly exposes
require_pairing as both true and false.

Closes #2879

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 11:03:03 -04:00
2 changed files with 181 additions and 3 deletions

110
build.rs
View File

@ -1,6 +1,110 @@
use std::path::Path;
use std::process::Command;
fn main() {
let dir = std::path::Path::new("web/dist");
if !dir.exists() {
std::fs::create_dir_all(dir).expect("failed to create web/dist/");
let dist_dir = Path::new("web/dist");
let web_dir = Path::new("web");
// Tell Cargo to re-run this script when web source files change.
println!("cargo:rerun-if-changed=web/src");
println!("cargo:rerun-if-changed=web/index.html");
println!("cargo:rerun-if-changed=web/package.json");
println!("cargo:rerun-if-changed=web/vite.config.ts");
// Attempt to build the web frontend if npm is available and web/dist is
// missing or stale. The build is best-effort: when Node.js is not
// installed (e.g. CI containers, cross-compilation, minimal dev setups)
// we fall back to the existing stub/empty dist directory so the Rust
// build still succeeds.
let needs_build = !dist_dir.join("index.html").exists();
if needs_build && web_dir.join("package.json").exists() {
if let Ok(npm) = which_npm() {
eprintln!("cargo:warning=Building web frontend (web/dist is missing or stale)...");
// npm ci / npm install
let install_status = Command::new(&npm)
.args(["ci", "--ignore-scripts"])
.current_dir(web_dir)
.status();
match install_status {
Ok(s) if s.success() => {}
Ok(s) => {
// Fall back to `npm install` if `npm ci` fails (no lockfile, etc.)
eprintln!("cargo:warning=npm ci exited with {s}, trying npm install...");
let fallback = Command::new(&npm)
.args(["install"])
.current_dir(web_dir)
.status();
if !matches!(fallback, Ok(s) if s.success()) {
eprintln!("cargo:warning=npm install failed — skipping web build");
ensure_dist_dir(dist_dir);
return;
}
}
Err(e) => {
eprintln!("cargo:warning=Could not run npm: {e} — skipping web build");
ensure_dist_dir(dist_dir);
return;
}
}
// npm run build
let build_status = Command::new(&npm)
.args(["run", "build"])
.current_dir(web_dir)
.status();
match build_status {
Ok(s) if s.success() => {
eprintln!("cargo:warning=Web frontend built successfully.");
}
Ok(s) => {
eprintln!(
"cargo:warning=npm run build exited with {s} — web dashboard may be unavailable"
);
}
Err(e) => {
eprintln!(
"cargo:warning=Could not run npm build: {e} — web dashboard may be unavailable"
);
}
}
}
}
ensure_dist_dir(dist_dir);
}
/// Ensure the dist directory exists so `rust-embed` does not fail at compile
/// time even when the web frontend is not built.
fn ensure_dist_dir(dist_dir: &Path) {
if !dist_dir.exists() {
std::fs::create_dir_all(dist_dir).expect("failed to create web/dist/");
}
}
/// Locate the `npm` binary on the system PATH.
fn which_npm() -> Result<String, ()> {
let cmd = if cfg!(target_os = "windows") {
"where"
} else {
"which"
};
Command::new(cmd)
.arg("npm")
.output()
.ok()
.and_then(|output| {
if output.status.success() {
String::from_utf8(output.stdout)
.ok()
.map(|s| s.lines().next().unwrap_or("npm").trim().to_string())
} else {
None
}
})
.ok_or(())
}

View File

@ -2940,4 +2940,78 @@ mod tests {
let err = require_localhost(&peer).unwrap_err();
assert_eq!(err.0, StatusCode::FORBIDDEN);
}
#[tokio::test]
async fn health_endpoint_exposes_require_pairing_false() {
let state = AppState {
config: Arc::new(Mutex::new(Config::default())),
provider: Arc::new(MockProvider::default()),
model: "test-model".into(),
temperature: 0.0,
mem: Arc::new(MockMemory),
auto_save: false,
webhook_secret_hash: None,
pairing: Arc::new(PairingGuard::new(false, &[])),
trust_forwarded_headers: false,
rate_limiter: Arc::new(GatewayRateLimiter::new(100, 100, 100)),
idempotency_store: Arc::new(IdempotencyStore::new(Duration::from_secs(300), 1000)),
whatsapp: None,
whatsapp_app_secret: None,
linq: None,
linq_signing_secret: None,
nextcloud_talk: None,
nextcloud_talk_webhook_secret: None,
wati: None,
observer: Arc::new(crate::observability::NoopObserver),
tools_registry: Arc::new(Vec::new()),
cost_tracker: None,
event_tx: tokio::sync::broadcast::channel(16).0,
shutdown_tx: tokio::sync::watch::channel(false).0,
};
let response = handle_health(State(state)).await.into_response();
assert_eq!(response.status(), StatusCode::OK);
let body = response.into_body().collect().await.unwrap().to_bytes();
let parsed: serde_json::Value = serde_json::from_slice(&body).unwrap();
assert_eq!(parsed["status"], "ok");
assert_eq!(parsed["require_pairing"], false);
}
#[tokio::test]
async fn health_endpoint_exposes_require_pairing_true() {
let state = AppState {
config: Arc::new(Mutex::new(Config::default())),
provider: Arc::new(MockProvider::default()),
model: "test-model".into(),
temperature: 0.0,
mem: Arc::new(MockMemory),
auto_save: false,
webhook_secret_hash: None,
pairing: Arc::new(PairingGuard::new(true, &[])),
trust_forwarded_headers: false,
rate_limiter: Arc::new(GatewayRateLimiter::new(100, 100, 100)),
idempotency_store: Arc::new(IdempotencyStore::new(Duration::from_secs(300), 1000)),
whatsapp: None,
whatsapp_app_secret: None,
linq: None,
linq_signing_secret: None,
nextcloud_talk: None,
nextcloud_talk_webhook_secret: None,
wati: None,
observer: Arc::new(crate::observability::NoopObserver),
tools_registry: Arc::new(Vec::new()),
cost_tracker: None,
event_tx: tokio::sync::broadcast::channel(16).0,
shutdown_tx: tokio::sync::watch::channel(false).0,
};
let response = handle_health(State(state)).await.into_response();
assert_eq!(response.status(), StatusCode::OK);
let body = response.into_body().collect().await.unwrap().to_bytes();
let parsed: serde_json::Value = serde_json::from_slice(&body).unwrap();
assert_eq!(parsed["status"], "ok");
assert_eq!(parsed["require_pairing"], true);
}
}