mirror of
https://github.com/ruvnet/RuView
synced 2026-08-10 20:31:42 +00:00
fix(homecore-ui): resolve code-review findings — SSRF guard, CORS/trace coverage, §6 honesty, crash guards
Addresses the high-effort review of PR #1082: - SECURITY: cal_proxy rejects path-traversal/confused-deputy SSRF (`.`/`..` segments, backslash, %2e%2e/%2f, absolute) on raw+decoded forms → 400, before attaching the server-side calibration bearer. - CORRECTNESS: /api/homecore/* + /api/cal/* now covered by the shared CORS allowlist (build_cors_layer, exported from homecore-api) + TraceLayer — previously merged outside router()'s layers (no CORS, no tracing). - §6 HONESTY (no fabricated data): dashboard renders '—' for null metrics (not "null%"/"null°C"); cogs Hailo pill reflects the REAL appliance probe (not hardcoded "connected"); room anomaly threshold passed through / null, not a fabricated 0.5. - ROBUSTNESS: cogs asArray(hef) guards a non-array manifest field; calibration progress guards target<=0 (no NaN%/Infinity%); restart clears the poll timer. - CLEANUP: mock.js is now a cached DYNAMIC import (demo-only) — never bundled in production (§2.2). - New ui/tests/unit-fixes.mjs pins the above; ADR-131 + CHANGELOG updated. Co-Authored-By: claude-flow <ruv@ruv.net>
This commit is contained in:
File diff suppressed because one or more lines are too long
@@ -426,3 +426,19 @@ Staged so each wave is independently shippable behind the gateway, lands real da
|
|||||||
| **W6 — Appliance + federation + Hailo** | ◑ appliance host metrics from `/proc` + port probes DONE (live `/proc` data verified); Hailo stats + federation remain `503` (need the accelerator stat source / coordinator). |
|
| **W6 — Appliance + federation + Hailo** | ◑ appliance host metrics from `/proc` + port probes DONE (live `/proc` data verified); Hailo stats + federation remain `503` (need the accelerator stat source / coordinator). |
|
||||||
|
|
||||||
**Status:** the gateway is **compiled and tested on Rust 1.89** (`cargo test -p homecore-server` = 12/12) and was **run live** (curl proof in §10). The one remaining caveat is intrinsic, not an environment limit: **W3/W5/W6-Hailo/federation depend on services/hardware that are not in this repo** (recorder/automation HTTP wrappers, real SEED nodes, the Hailo stat source), so they return honest typed `503`s and the UI shows error states — exactly as §2.2/§11.2 prescribe. W1/W2/W4/W6-appliance are functional now.
|
**Status:** the gateway is **compiled and tested on Rust 1.89** (`cargo test -p homecore-server` = 12/12) and was **run live** (curl proof in §10). The one remaining caveat is intrinsic, not an environment limit: **W3/W5/W6-Hailo/federation depend on services/hardware that are not in this repo** (recorder/automation HTTP wrappers, real SEED nodes, the Hailo stat source), so they return honest typed `503`s and the UI shows error states — exactly as §2.2/§11.2 prescribe. W1/W2/W4/W6-appliance are functional now.
|
||||||
|
|
||||||
|
### 12.2 Security review (PR #1082)
|
||||||
|
|
||||||
|
A high-effort public-PR review of the merged gateway + front-end surfaced the following, all fixed and pinned by tests (`cargo test -p homecore-server` is now **18/18**):
|
||||||
|
|
||||||
|
| # | Severity | Finding | Fix |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 1 | **HIGH** | **Path-traversal / confused-deputy SSRF** in the `/api/cal/*` reverse-proxy. The wildcard path was interpolated into the upstream URL while `proxy()` attaches the privileged server-side calibration bearer, so `/api/cal/v1/../../x` (or `..%2f`, `%2e%2e`, leading `/`, `\`, double-encoded `%252e`) could escape the `…/api/` scope **with the token**. | `validate_proxy_path()` decode-then-checks and rejects absolute / backslash / dot-segment / encoded-traversal paths with a typed **400 before the URL is built** (GET **and** POST); legit `v1/...` paths still pass. |
|
||||||
|
| 2 | Correctness | **CORS + tracing didn't cover gateway routes** — `/api/homecore/*` + `/api/cal/*` were `.merge()`d outside `homecore-api::router()`'s layers. | The audited HC-05 `build_cors_layer()` + `TraceLayer` are now applied to the whole merged app in `main.rs`. |
|
||||||
|
| 3 | Honesty (§6) | **Fabricated data** — hardcoded `anomaly.threshold: 0.5` in the adapter; dashboard rendered `"null%"`/`"null°C"`; COG Hailo pill hardcoded `"connected"`; `rooms.js` defaulted a null threshold to `0.8`. | Threshold passes through the real upstream value or emits `null` (withheld); dashboard renders `—`; the Hailo pill reflects the real appliance probe; the UI treats a null threshold as withheld. |
|
||||||
|
| 4 | Robustness | A string `hef` (forwarded verbatim) threw on `.forEach`/`.join`; `frames/target` could be `NaN%`/`Infinity%`; calibration Restart leaked the baseline `setTimeout` poll. | `asArray()` coercion; `target > 0` guard; cancellable poll cleared on Restart / panel teardown. |
|
||||||
|
| 5 | Perf | Sequential per-bank RoomState fetches; blocking `std::net::TcpStream::connect_timeout` probes on an async handler; `mock.js` statically bundled. | Concurrent `futures::join_all`; async `tokio::net::TcpStream` + `timeout`; demo-only dynamic `import()` of `mock.js`. |
|
||||||
|
|
||||||
|
**Known limitations carried forward (not regressions):**
|
||||||
|
- **`reqwest` rustls-only is a workspace-wide concern.** `homecore-server` opts into `rustls-tls` only, but cargo feature-unification means any sibling crate enabling the default `native-tls` re-introduces OpenSSL into the final binary. A true "no OpenSSL on the appliance" guarantee requires aligning **every** reqwest-pulling crate on rustls-only — out of scope for this PR; documented at the dependency in `Cargo.toml`.
|
||||||
|
- **DEV-mode auth.** When `HOMECORE_TOKENS` is unset, the token store falls back to `allow_any_non_empty()` (any non-empty bearer accepted) on `0.0.0.0`. This is pre-existing and intentionally **unchanged** here; the loud boot `warn!` is retained. Provision real tokens (`HOMECORE_TOKENS=…`) before exposing the server to a network.
|
||||||
|
|||||||
Generated
+1
@@ -3595,6 +3595,7 @@ dependencies = [
|
|||||||
"anyhow",
|
"anyhow",
|
||||||
"axum",
|
"axum",
|
||||||
"clap",
|
"clap",
|
||||||
|
"futures",
|
||||||
"homecore",
|
"homecore",
|
||||||
"homecore-api",
|
"homecore-api",
|
||||||
"homecore-assist",
|
"homecore-assist",
|
||||||
|
|||||||
@@ -42,7 +42,11 @@ pub fn router(state: SharedState) -> Router {
|
|||||||
.with_state(state)
|
.with_state(state)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn build_cors_layer() -> CorsLayer {
|
/// Build the audited CORS allowlist layer (HC-05). Exposed so the
|
||||||
|
/// integration binary can apply the SAME allowlist to routes merged in
|
||||||
|
/// outside `router()` (e.g. the ADR-131 BFF gateway), instead of leaving
|
||||||
|
/// `/api/homecore/*` and `/api/cal/*` with no CORS coverage at all.
|
||||||
|
pub fn build_cors_layer() -> CorsLayer {
|
||||||
let raw = std::env::var("HOMECORE_CORS_ORIGINS").ok();
|
let raw = std::env::var("HOMECORE_CORS_ORIGINS").ok();
|
||||||
let origins: Vec<HeaderValue> = match raw {
|
let origins: Vec<HeaderValue> = match raw {
|
||||||
Some(v) if !v.trim().is_empty() => v
|
Some(v) if !v.trim().is_empty() => v
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ pub mod state;
|
|||||||
pub mod tokens;
|
pub mod tokens;
|
||||||
pub mod ws;
|
pub mod ws;
|
||||||
|
|
||||||
pub use app::{router, AppState};
|
pub use app::{build_cors_layer, router, AppState};
|
||||||
pub use error::{ApiError, ApiResult};
|
pub use error::{ApiError, ApiResult};
|
||||||
pub use state::SharedState;
|
pub use state::SharedState;
|
||||||
pub use tokens::LongLivedTokenStore;
|
pub use tokens::LongLivedTokenStore;
|
||||||
|
|||||||
@@ -37,12 +37,21 @@ clap = { version = "4", features = ["derive", "env"] }
|
|||||||
anyhow = "1"
|
anyhow = "1"
|
||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
axum = { version = "0.7", features = ["macros"] }
|
axum = { version = "0.7", features = ["macros"] }
|
||||||
# Static-file serving for the HOMECORE-UI dashboard (ADR-131) mounted at /homecore.
|
# Static-file serving for the HOMECORE-UI dashboard (ADR-131) mounted at
|
||||||
tower-http = { version = "0.6", features = ["fs", "trace"] }
|
# /homecore, request tracing, and the CORS allowlist applied to BOTH the
|
||||||
|
# homecore-api routes AND the merged BFF gateway routes (ADR-131 §11).
|
||||||
|
tower-http = { version = "0.6", features = ["fs", "trace", "cors"] }
|
||||||
# BFF gateway (ADR-131 §11): reverse-proxy the calibration API + aggregate
|
# BFF gateway (ADR-131 §11): reverse-proxy the calibration API + aggregate
|
||||||
# upstreams. rustls to avoid an OpenSSL system dep on the appliance.
|
# upstreams. rustls is requested here, but NOTE this is a WORKSPACE-WIDE
|
||||||
|
# concern: cargo feature-unification means a sibling crate that enables
|
||||||
|
# reqwest's default `native-tls` re-introduces OpenSSL into the final binary
|
||||||
|
# regardless of this opt-out. A real "no OpenSSL on the appliance" guarantee
|
||||||
|
# requires every crate that pulls reqwest to align on rustls-only (tracked in
|
||||||
|
# CHANGELOG / ADR-131 security note).
|
||||||
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
|
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
|
||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
|
# Concurrent fan-out of per-bank RoomState fetches in the gateway (§11 perf).
|
||||||
|
futures = "0.3"
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
# Drive the assembled router in integration tests via ServiceExt::oneshot.
|
# Drive the assembled router in integration tests via ServiceExt::oneshot.
|
||||||
|
|||||||
@@ -111,6 +111,82 @@ fn upstream_unavailable(detail: &str) -> Response {
|
|||||||
fn upstream_timeout(detail: &str) -> Response {
|
fn upstream_timeout(detail: &str) -> Response {
|
||||||
typed(StatusCode::GATEWAY_TIMEOUT, "upstream_timeout", detail)
|
typed(StatusCode::GATEWAY_TIMEOUT, "upstream_timeout", detail)
|
||||||
}
|
}
|
||||||
|
fn bad_request(detail: &str) -> Response {
|
||||||
|
typed(StatusCode::BAD_REQUEST, "bad_request", detail)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reject a proxied wildcard path that could escape the `/api/` scope on the
|
||||||
|
/// upstream calibration service (path-traversal / confused-deputy SSRF —
|
||||||
|
/// ADR-131 §11 security review). The privileged server-side calibration bearer
|
||||||
|
/// is attached by `proxy()`, so a client must NOT be able to redirect that
|
||||||
|
/// credential outside `…/api/`.
|
||||||
|
///
|
||||||
|
/// Returns `Err(400)` when the path (or its percent-decoded form):
|
||||||
|
/// * is absolute (`/…`) — would replace the `…/api/` base entirely,
|
||||||
|
/// * contains a backslash (`\`) — Windows/alt-separator traversal,
|
||||||
|
/// * has any segment equal to `.` or `..` — dot-segment traversal,
|
||||||
|
/// * still carries `%2e%2e` / `%2f` (single-decode is enough — we reject on
|
||||||
|
/// the decoded form AND on a residual encoded marker, so double-encoding
|
||||||
|
/// like `%252e` decodes once to `%2e` and is caught here).
|
||||||
|
///
|
||||||
|
/// Legitimate `v1/...` paths (the only shape the UI sends) pass unchanged.
|
||||||
|
fn validate_proxy_path(path: &str) -> Result<(), Response> {
|
||||||
|
// 1. Reject on the raw form first (cheap; catches backslash + leading `/`).
|
||||||
|
if path.starts_with('/') {
|
||||||
|
return Err(bad_request("proxied path must be relative (leading '/' not allowed)"));
|
||||||
|
}
|
||||||
|
if path.contains('\\') {
|
||||||
|
return Err(bad_request("proxied path must not contain a backslash"));
|
||||||
|
}
|
||||||
|
// 2. Percent-decode once and re-check; reject if decoding is invalid.
|
||||||
|
let decoded = percent_decode_once(path)
|
||||||
|
.ok_or_else(|| bad_request("proxied path has invalid percent-encoding"))?;
|
||||||
|
if decoded.starts_with('/') || decoded.contains('\\') {
|
||||||
|
return Err(bad_request("proxied path resolves to an absolute/traversal path"));
|
||||||
|
}
|
||||||
|
// 3. Reject any `.`/`..` segment on BOTH the raw and decoded forms so an
|
||||||
|
// encoded `%2e%2e%2f` cannot slip a dot-segment past the split.
|
||||||
|
for form in [path, decoded.as_str()] {
|
||||||
|
for seg in form.split(['/', '\\']) {
|
||||||
|
if seg == "." || seg == ".." {
|
||||||
|
return Err(bad_request("proxied path must not contain '.' or '..' segments"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Defence in depth: a residual encoded traversal marker survived the
|
||||||
|
// single decode (e.g. originally double-encoded). Reject it outright.
|
||||||
|
let lower = form.to_ascii_lowercase();
|
||||||
|
if lower.contains("%2e") || lower.contains("%2f") || lower.contains("%5c") {
|
||||||
|
return Err(bad_request("proxied path must not contain encoded traversal markers"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Minimal single-pass percent-decoder (no external dep). Returns `None` on a
|
||||||
|
/// malformed escape so callers can fail closed.
|
||||||
|
fn percent_decode_once(s: &str) -> Option<String> {
|
||||||
|
let bytes = s.as_bytes();
|
||||||
|
let mut out: Vec<u8> = Vec::with_capacity(bytes.len());
|
||||||
|
let mut i = 0;
|
||||||
|
while i < bytes.len() {
|
||||||
|
match bytes[i] {
|
||||||
|
b'%' => {
|
||||||
|
if i + 2 >= bytes.len() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let hi = (bytes[i + 1] as char).to_digit(16)?;
|
||||||
|
let lo = (bytes[i + 2] as char).to_digit(16)?;
|
||||||
|
out.push((hi * 16 + lo) as u8);
|
||||||
|
i += 3;
|
||||||
|
}
|
||||||
|
b => {
|
||||||
|
out.push(b);
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
String::from_utf8(out).ok()
|
||||||
|
}
|
||||||
|
|
||||||
/// Routes whose upstream is a SEED device / appliance daemon not present
|
/// Routes whose upstream is a SEED device / appliance daemon not present
|
||||||
/// in this repo. Honest 503 until the corresponding §12 wave lands.
|
/// in this repo. Honest 503 until the corresponding §12 wave lands.
|
||||||
@@ -140,6 +216,9 @@ async fn cal_proxy_get(
|
|||||||
if let Err(r) = require_auth(&headers, &st).await {
|
if let Err(r) = require_auth(&headers, &st).await {
|
||||||
return r;
|
return r;
|
||||||
}
|
}
|
||||||
|
if let Err(r) = validate_proxy_path(&path) {
|
||||||
|
return r;
|
||||||
|
}
|
||||||
let base = match &st.cfg.calibration_url {
|
let base = match &st.cfg.calibration_url {
|
||||||
Some(u) => u,
|
Some(u) => u,
|
||||||
None => return upstream_unavailable("calibration service not configured (set --calibration-url / HOMECORE_CALIBRATION_URL)"),
|
None => return upstream_unavailable("calibration service not configured (set --calibration-url / HOMECORE_CALIBRATION_URL)"),
|
||||||
@@ -160,6 +239,9 @@ async fn cal_proxy_post(
|
|||||||
if let Err(r) = require_auth(&headers, &st).await {
|
if let Err(r) = require_auth(&headers, &st).await {
|
||||||
return r;
|
return r;
|
||||||
}
|
}
|
||||||
|
if let Err(r) = validate_proxy_path(&path) {
|
||||||
|
return r;
|
||||||
|
}
|
||||||
let base = match &st.cfg.calibration_url {
|
let base = match &st.cfg.calibration_url {
|
||||||
Some(u) => u,
|
Some(u) => u,
|
||||||
None => return upstream_unavailable("calibration service not configured (set --calibration-url / HOMECORE_CALIBRATION_URL)"),
|
None => return upstream_unavailable("calibration service not configured (set --calibration-url / HOMECORE_CALIBRATION_URL)"),
|
||||||
@@ -235,13 +317,22 @@ async fn rooms(State(st): State<GatewayState>, headers: HeaderMap) -> Response {
|
|||||||
Ok(v) => bank_names(&v),
|
Ok(v) => bank_names(&v),
|
||||||
Err(r) => return r,
|
Err(r) => return r,
|
||||||
};
|
};
|
||||||
let mut out: Vec<Value> = Vec::new();
|
// Fetch every bank's RoomState concurrently (§11 perf): one slow bank no
|
||||||
for bank in banks {
|
// longer serialises behind the others. Order is preserved by collecting in
|
||||||
let url = format!("{base}/api/v1/room/state?bank={bank}");
|
// the original bank order.
|
||||||
if let Ok(v) = fetch_json(&st, &url).await {
|
let fetches = banks.into_iter().map(|bank| {
|
||||||
out.push(adapt_room_state(&bank, &v));
|
let st = &st;
|
||||||
|
let base = base.as_str();
|
||||||
|
async move {
|
||||||
|
let url = format!("{base}/api/v1/room/state?bank={bank}");
|
||||||
|
fetch_json(st, &url).await.ok().map(|v| adapt_room_state(&bank, &v))
|
||||||
}
|
}
|
||||||
}
|
});
|
||||||
|
let out: Vec<Value> = futures::future::join_all(fetches)
|
||||||
|
.await
|
||||||
|
.into_iter()
|
||||||
|
.flatten()
|
||||||
|
.collect();
|
||||||
Json(out).into_response()
|
Json(out).into_response()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -296,7 +387,11 @@ fn adapt_room_state(bank: &str, v: &Value) -> Value {
|
|||||||
Some(r) if !r.is_null() => json!({
|
Some(r) if !r.is_null() => json!({
|
||||||
"value": r.get("value").cloned().unwrap_or(Value::Null),
|
"value": r.get("value").cloned().unwrap_or(Value::Null),
|
||||||
"confidence": r.get("confidence").cloned().unwrap_or(Value::Null),
|
"confidence": r.get("confidence").cloned().unwrap_or(Value::Null),
|
||||||
"threshold": 0.5,
|
// §6 invariant 3 (honesty): pass through the REAL anomaly threshold
|
||||||
|
// from the upstream RoomState if present; if absent, emit null
|
||||||
|
// (withheld) — never fabricate a constant. The UI treats null as
|
||||||
|
// withheld, not a fake default.
|
||||||
|
"threshold": r.get("threshold").cloned().unwrap_or(Value::Null),
|
||||||
}),
|
}),
|
||||||
_ => Value::Null,
|
_ => Value::Null,
|
||||||
};
|
};
|
||||||
@@ -387,17 +482,24 @@ async fn appliance(State(st): State<GatewayState>, headers: HeaderMap) -> Respon
|
|||||||
let ram = mem_used_pct();
|
let ram = mem_used_pct();
|
||||||
let cpu = cpu_load_pct();
|
let cpu = cpu_load_pct();
|
||||||
let uptime = uptime_secs();
|
let uptime = uptime_secs();
|
||||||
let services: Vec<Value> = [
|
// Probe the appliance services concurrently with a non-blocking async
|
||||||
|
// connect under a timeout (§11 perf): previously a sequential blocking
|
||||||
|
// `std::net::TcpStream::connect_timeout` stalled the whole async handler
|
||||||
|
// for up to `N * timeout` and parked a Tokio worker thread per probe.
|
||||||
|
let probes = [
|
||||||
("ruview-mcp-brain", 9876u16),
|
("ruview-mcp-brain", 9876u16),
|
||||||
("cognitum-rvf-agent", 9004),
|
("cognitum-rvf-agent", 9004),
|
||||||
("ruvector-hailo-worker", 50051),
|
("ruvector-hailo-worker", 50051),
|
||||||
]
|
]
|
||||||
.iter()
|
.into_iter()
|
||||||
.map(|(name, port)| {
|
.map(|(name, port)| {
|
||||||
let up = tcp_open("127.0.0.1", *port, st.cfg.timeout);
|
let timeout = st.cfg.timeout;
|
||||||
json!({ "name": name, "port": port, "status": if up { "running" } else { "unreachable" } })
|
async move {
|
||||||
})
|
let up = tcp_open("127.0.0.1", port, timeout).await;
|
||||||
.collect();
|
json!({ "name": name, "port": port, "status": if up { "running" } else { "unreachable" } })
|
||||||
|
}
|
||||||
|
});
|
||||||
|
let services: Vec<Value> = futures::future::join_all(probes).await;
|
||||||
Json(json!({
|
Json(json!({
|
||||||
"cpu_pct": cpu,
|
"cpu_pct": cpu,
|
||||||
"ram_pct": ram,
|
"ram_pct": ram,
|
||||||
@@ -453,13 +555,15 @@ fn cpu_load_pct() -> Option<f64> {
|
|||||||
Some(((load / ncpu * 100.0).min(100.0) * 10.0).round() / 10.0)
|
Some(((load / ncpu * 100.0).min(100.0) * 10.0).round() / 10.0)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn tcp_open(host: &str, port: u16, timeout: Duration) -> bool {
|
/// Non-blocking liveness probe: succeeds iff a TCP connection to
|
||||||
use std::net::ToSocketAddrs;
|
/// `host:port` completes within `timeout`. Async so it never parks a Tokio
|
||||||
let addr = match (host, port).to_socket_addrs().ok().and_then(|mut a| a.next()) {
|
/// worker thread (unlike the blocking `std::net` connect it replaced).
|
||||||
Some(a) => a,
|
async fn tcp_open(host: &str, port: u16, timeout: Duration) -> bool {
|
||||||
None => return false,
|
let addr = format!("{host}:{port}");
|
||||||
};
|
matches!(
|
||||||
std::net::TcpStream::connect_timeout(&addr, timeout).is_ok()
|
tokio::time::timeout(timeout, tokio::net::TcpStream::connect(&addr)).await,
|
||||||
|
Ok(Ok(_))
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
@@ -565,7 +669,84 @@ mod tests {
|
|||||||
assert_eq!(ui["presence"]["value"], "occupied");
|
assert_eq!(ui["presence"]["value"], "occupied");
|
||||||
assert_eq!(ui["breathing_bpm"]["value"], 12.0);
|
assert_eq!(ui["breathing_bpm"]["value"], 12.0);
|
||||||
assert!(ui["heart_bpm"].is_null(), "None heartbeat must map to null (not trained)");
|
assert!(ui["heart_bpm"].is_null(), "None heartbeat must map to null (not trained)");
|
||||||
assert_eq!(ui["anomaly"]["threshold"], 0.5);
|
// §6 invariant 3: upstream RoomState carries no threshold here, so the
|
||||||
|
// adapter must emit null (withheld) — NOT a fabricated constant.
|
||||||
|
assert!(
|
||||||
|
ui["anomaly"]["threshold"].is_null(),
|
||||||
|
"absent upstream threshold must surface as null, never a hardcoded value"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn adapt_room_state_passes_through_real_anomaly_threshold() {
|
||||||
|
// When the upstream RoomState DOES carry a real threshold, it must be
|
||||||
|
// forwarded verbatim (no fabrication, no override).
|
||||||
|
let cal = json!({
|
||||||
|
"anomaly": {"kind":"Anomaly","value":0.2,"confidence":0.5,"threshold":0.73},
|
||||||
|
});
|
||||||
|
let ui = adapt_room_state("bedroom_1", &cal);
|
||||||
|
assert_eq!(ui["anomaly"]["threshold"], 0.73, "real threshold must pass through");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn validate_proxy_path_allows_legit_v1_paths() {
|
||||||
|
// The only shape the UI sends must pass unchanged.
|
||||||
|
for ok in [
|
||||||
|
"v1/room/state",
|
||||||
|
"v1/calibration/baselines",
|
||||||
|
"v1/enroll/status",
|
||||||
|
"v1/room/state?bank=living_room", // query is split off before this fn
|
||||||
|
] {
|
||||||
|
// strip any query the caller would have removed; we only validate path
|
||||||
|
let p = ok.split('?').next().unwrap();
|
||||||
|
assert!(validate_proxy_path(p).is_ok(), "{p} should be allowed");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn validate_proxy_path_rejects_traversal_variants() {
|
||||||
|
for bad in [
|
||||||
|
"v1/../../x", // dot-segment traversal
|
||||||
|
"../etc/passwd", // parent escape
|
||||||
|
"/etc/passwd", // absolute
|
||||||
|
"v1\\..\\..\\x", // backslash traversal
|
||||||
|
"..%2f..%2fx", // encoded slash
|
||||||
|
"%2e%2e/x", // encoded dot-dot
|
||||||
|
"v1/%2e%2e%2fadmin", // mixed encoded traversal
|
||||||
|
"%252e%252e/x", // double-encoded (residual %2e after one decode)
|
||||||
|
] {
|
||||||
|
assert!(validate_proxy_path(bad).is_err(), "{bad} must be rejected");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn cal_proxy_rejects_traversal_with_400_before_upstream() {
|
||||||
|
// `gw()` has calibration_url=None: a path that reached URL-building
|
||||||
|
// would 503 ("not configured"). A 400 here proves the traversal is
|
||||||
|
// rejected BEFORE any upstream request is even attempted.
|
||||||
|
for (method, path) in [
|
||||||
|
("GET", "/api/cal/v1/../../x"),
|
||||||
|
("GET", "/api/cal/..%2f..%2fx"),
|
||||||
|
("GET", "/api/cal/%2e%2e/x"),
|
||||||
|
("POST", "/api/cal/v1/../../x"),
|
||||||
|
] {
|
||||||
|
let (status, body) = send(gateway_router(gw()), method, path).await;
|
||||||
|
assert_eq!(status, StatusCode::BAD_REQUEST, "{method} {path} must be 400");
|
||||||
|
assert!(body.contains("bad_request"), "{method} {path} typed 400 body");
|
||||||
|
assert!(
|
||||||
|
!body.contains("upstream_unavailable"),
|
||||||
|
"{method} {path} must NOT reach the upstream-config branch"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn cal_proxy_allows_legit_path_through_to_upstream_config() {
|
||||||
|
// A legitimate v1 path passes validation and then hits the
|
||||||
|
// "not configured" 503 (proving it was NOT blocked as traversal).
|
||||||
|
let (status, body) = send(gateway_router(gw()), "GET", "/api/cal/v1/room/state").await;
|
||||||
|
assert_eq!(status, StatusCode::SERVICE_UNAVAILABLE);
|
||||||
|
assert!(body.contains("upstream_unavailable"), "legit path should reach upstream branch");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ use tracing::{info, warn};
|
|||||||
|
|
||||||
use homecore::{Context, EntityId, HomeCore, ServiceCall, ServiceError, ServiceName};
|
use homecore::{Context, EntityId, HomeCore, ServiceCall, ServiceError, ServiceName};
|
||||||
use homecore::service::FnHandler;
|
use homecore::service::FnHandler;
|
||||||
use homecore_api::{router, LongLivedTokenStore, SharedState};
|
use homecore_api::{build_cors_layer, router, LongLivedTokenStore, SharedState};
|
||||||
use homecore_assist::pipeline::default_pipeline;
|
use homecore_assist::pipeline::default_pipeline;
|
||||||
use homecore_assist::RegexIntentRecognizer;
|
use homecore_assist::RegexIntentRecognizer;
|
||||||
use homecore_automation::AutomationEngine;
|
use homecore_automation::AutomationEngine;
|
||||||
@@ -37,6 +37,7 @@ use homecore_recorder::Recorder;
|
|||||||
|
|
||||||
use axum::Router;
|
use axum::Router;
|
||||||
use tower_http::services::ServeDir;
|
use tower_http::services::ServeDir;
|
||||||
|
use tower_http::trace::TraceLayer;
|
||||||
|
|
||||||
mod gateway;
|
mod gateway;
|
||||||
use gateway::{GatewayConfig, GatewayState};
|
use gateway::{GatewayConfig, GatewayState};
|
||||||
@@ -221,7 +222,16 @@ async fn main() -> Result<()> {
|
|||||||
timeout: std::time::Duration::from_millis(cli.gateway_timeout_ms),
|
timeout: std::time::Duration::from_millis(cli.gateway_timeout_ms),
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
let app = build_app(api_state, &cli.ui_dir).merge(gateway::gateway_router(gw));
|
// Merge the HA-compat API + UI mount with the BFF gateway, THEN apply the
|
||||||
|
// audited CORS allowlist + request tracing to the WHOLE surface. The
|
||||||
|
// gateway routes (`/api/homecore/*`, `/api/cal/*`) are merged in outside
|
||||||
|
// `router()`'s own layers, so without this outer layer they would have NO
|
||||||
|
// CORS coverage and would not be traced (ADR-131 §11 review). Applying CORS
|
||||||
|
// again to the homecore-api routes is idempotent.
|
||||||
|
let app = build_app(api_state, &cli.ui_dir)
|
||||||
|
.merge(gateway::gateway_router(gw))
|
||||||
|
.layer(build_cors_layer())
|
||||||
|
.layer(TraceLayer::new_for_http());
|
||||||
let listener = tokio::net::TcpListener::bind(cli.bind).await?;
|
let listener = tokio::net::TcpListener::bind(cli.bind).await?;
|
||||||
info!("HOMECORE-API listening on http://{} (HA-compat /api + /api/websocket)", cli.bind);
|
info!("HOMECORE-API listening on http://{} (HA-compat /api + /api/websocket)", cli.bind);
|
||||||
info!(
|
info!(
|
||||||
@@ -461,4 +471,60 @@ mod ui_tests {
|
|||||||
let (status, _) = get(app, "/homecore/").await;
|
let (status, _) = get(app, "/homecore/").await;
|
||||||
assert_eq!(status, StatusCode::NOT_FOUND, "empty --ui-dir disables the mount");
|
assert_eq!(status, StatusCode::NOT_FOUND, "empty --ui-dir disables the mount");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Build the SAME merged + layered surface `main()` serves: API + UI mount
|
||||||
|
/// + BFF gateway, with the audited CORS allowlist + tracing applied to the
|
||||||
|
/// whole thing. Used to prove the gateway routes are CORS-covered.
|
||||||
|
fn full_app(state: SharedState) -> Router {
|
||||||
|
use crate::gateway::{GatewayConfig, GatewayState};
|
||||||
|
let gw = GatewayState::new(
|
||||||
|
state.clone(),
|
||||||
|
GatewayConfig {
|
||||||
|
calibration_url: None,
|
||||||
|
calibration_token: None,
|
||||||
|
apps_dir: std::path::PathBuf::from("/nonexistent-apps-dir"),
|
||||||
|
timeout: std::time::Duration::from_millis(200),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
build_app(state, "")
|
||||||
|
.merge(crate::gateway::gateway_router(gw))
|
||||||
|
.layer(homecore_api::build_cors_layer())
|
||||||
|
.layer(TraceLayer::new_for_http())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn gateway_routes_are_cors_covered_after_merge() {
|
||||||
|
// A CORS preflight from the Vite dev origin must succeed (echo the
|
||||||
|
// allowed origin) for a GATEWAY route — proving the outer CORS layer
|
||||||
|
// covers the merged routes, not just the homecore-api ones.
|
||||||
|
let app = full_app(test_state());
|
||||||
|
let resp = app
|
||||||
|
.oneshot(
|
||||||
|
Request::builder()
|
||||||
|
.method("OPTIONS")
|
||||||
|
.uri("/api/homecore/appliance")
|
||||||
|
.header("origin", "http://localhost:5173")
|
||||||
|
.header("access-control-request-method", "GET")
|
||||||
|
.header("access-control-request-headers", "authorization")
|
||||||
|
.body(Body::empty())
|
||||||
|
.unwrap(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
// CORS preflight handled by the layer → 2xx with the origin echoed back.
|
||||||
|
assert!(
|
||||||
|
resp.status().is_success(),
|
||||||
|
"gateway preflight should succeed, got {}",
|
||||||
|
resp.status()
|
||||||
|
);
|
||||||
|
let allow_origin = resp
|
||||||
|
.headers()
|
||||||
|
.get("access-control-allow-origin")
|
||||||
|
.and_then(|v| v.to_str().ok())
|
||||||
|
.unwrap_or("");
|
||||||
|
assert_eq!(
|
||||||
|
allow_origin, "http://localhost:5173",
|
||||||
|
"gateway route must echo the allowlisted dev origin"
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,7 +10,14 @@
|
|||||||
//
|
//
|
||||||
// Gateway route map: ADR-131 §11.2.
|
// Gateway route map: ADR-131 §11.2.
|
||||||
|
|
||||||
import * as mock from './mock.js';
|
// DEV-ONLY fixtures. Loaded via DYNAMIC import so a production bundle that
|
||||||
|
// never enters demo mode never pulls mock.js into the graph (§2.2). Cached
|
||||||
|
// after first use so repeated demo calls don't re-import.
|
||||||
|
let _mock = null;
|
||||||
|
async function loadMock() {
|
||||||
|
if (!_mock) _mock = await import('./mock.js');
|
||||||
|
return _mock;
|
||||||
|
}
|
||||||
|
|
||||||
const demoFlags = {};
|
const demoFlags = {};
|
||||||
|
|
||||||
@@ -50,8 +57,10 @@ export const api = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
// demo-gated data accessor: real gateway GET in prod, mock fixture in demo.
|
// demo-gated data accessor: real gateway GET in prod, mock fixture in demo.
|
||||||
|
// The mock module is dynamically imported ONLY on the demo branch, so prod
|
||||||
|
// never loads it. `mockFn` receives the loaded module.
|
||||||
async _data(key, path, mockFn) {
|
async _data(key, path, mockFn) {
|
||||||
if (demoMode()) { demoFlags[key] = true; return mockFn(); }
|
if (demoMode()) { demoFlags[key] = true; return mockFn(await loadMock()); }
|
||||||
delete demoFlags[key];
|
delete demoFlags[key];
|
||||||
return this._get(path);
|
return this._get(path);
|
||||||
},
|
},
|
||||||
@@ -68,28 +77,28 @@ export const api = {
|
|||||||
async setState(entityId, state, attributes) { return this._post(`/api/states/${entityId}`, { state, attributes: attributes || {} }); },
|
async setState(entityId, state, attributes) { return this._post(`/api/states/${entityId}`, { state, attributes: attributes || {} }); },
|
||||||
|
|
||||||
// ── gateway /api/homecore/* + /api/events (§11.2) ─────────────────
|
// ── gateway /api/homecore/* + /api/events (§11.2) ─────────────────
|
||||||
async appliance() { return this._data('appliance', '/api/homecore/appliance', () => mock.applianceHealth()); },
|
async appliance() { return this._data('appliance', '/api/homecore/appliance', (m) => m.applianceHealth()); },
|
||||||
async seeds() { return this._data('fleet', '/api/homecore/seeds', () => mock.seeds()); },
|
async seeds() { return this._data('fleet', '/api/homecore/seeds', (m) => m.seeds()); },
|
||||||
async seed(id) { return this._data('fleet', '/api/homecore/seeds/' + encodeURIComponent(id), () => mock.seed(id)); },
|
async seed(id) { return this._data('fleet', '/api/homecore/seeds/' + encodeURIComponent(id), (m) => m.seed(id)); },
|
||||||
async esp32Warnings() {
|
async esp32Warnings() {
|
||||||
if (demoMode()) { demoFlags.fleet = true; return mock.esp32Warnings(); }
|
if (demoMode()) { demoFlags.fleet = true; return (await loadMock()).esp32Warnings(); }
|
||||||
const seeds = await this._get('/api/homecore/seeds');
|
const seeds = await this._get('/api/homecore/seeds');
|
||||||
return seeds.flatMap((s) => (s.warnings || []).map((issue) => ({ node_id: s.device_id, seed: s.device_id, issue })));
|
return seeds.flatMap((s) => (s.warnings || []).map((issue) => ({ node_id: s.device_id, seed: s.device_id, issue })));
|
||||||
},
|
},
|
||||||
async cogs() { return this._data('cogs', '/api/homecore/cogs', () => mock.cogs()); },
|
async cogs() { return this._data('cogs', '/api/homecore/cogs', (m) => m.cogs()); },
|
||||||
async cogUpdates() { return this._data('cogs', '/api/homecore/cogs/updates', () => mock.cogUpdates()); },
|
async cogUpdates() { return this._data('cogs', '/api/homecore/cogs/updates', (m) => m.cogUpdates()); },
|
||||||
async hailo() { return this._data('cogs', '/api/homecore/hailo', () => ({ worker: 'connected', cogs: mock.cogs().filter((c) => c.arch === 'hailo10') })); },
|
async hailo() { return this._data('cogs', '/api/homecore/hailo', (m) => ({ worker: 'connected', cogs: m.cogs().filter((c) => c.arch === 'hailo10') })); },
|
||||||
async roomStates() { return this._data('rooms', '/api/homecore/rooms', () => mock.roomStates()); },
|
async roomStates() { return this._data('rooms', '/api/homecore/rooms', (m) => m.roomStates()); },
|
||||||
async federation() { return this._data('fleet', '/api/homecore/federation', () => mock.federation()); },
|
async federation() { return this._data('fleet', '/api/homecore/federation', (m) => m.federation()); },
|
||||||
async witnessLog(page = 0, size = 12) { return this._data('audit', `/api/homecore/witness?page=${page}&size=${size}`, () => mock.witnessLog(page, size)); },
|
async witnessLog(page = 0, size = 12) { return this._data('audit', `/api/homecore/witness?page=${page}&size=${size}`, (m) => m.witnessLog(page, size)); },
|
||||||
async privacyModes() { return this._data('audit', '/api/homecore/privacy', () => mock.privacyModes()); },
|
async privacyModes() { return this._data('audit', '/api/homecore/privacy', (m) => m.privacyModes()); },
|
||||||
async setPrivacy(seed, modeValue) { if (demoMode()) return { seed, mode: modeValue }; return this._post('/api/homecore/privacy', { seed, mode: modeValue }); },
|
async setPrivacy(seed, modeValue) { if (demoMode()) return { seed, mode: modeValue }; return this._post('/api/homecore/privacy', { seed, mode: modeValue }); },
|
||||||
async eventHistory(n = 40) { return this._data('events', `/api/events?limit=${n}`, () => mock.recentEvents(n)); },
|
async eventHistory(n = 40) { return this._data('events', `/api/events?limit=${n}`, (m) => m.recentEvents(n)); },
|
||||||
recentEvents(n) { return this.eventHistory(n); }, // back-compat alias (async)
|
recentEvents(n) { return this.eventHistory(n); }, // back-compat alias (async)
|
||||||
async settings() { return this._data('settings', '/api/homecore/settings', () => mock.settings()); },
|
async settings() { return this._data('settings', '/api/homecore/settings', (m) => m.settings()); },
|
||||||
async automations() { return this._data('automations', '/api/homecore/automations', () => []); },
|
async automations() { return this._data('automations', '/api/homecore/automations', () => []); },
|
||||||
async saveAutomation(a) { if (demoMode()) return a; return this._post('/api/homecore/automations', a); },
|
async saveAutomation(a) { if (demoMode()) return a; return this._post('/api/homecore/automations', a); },
|
||||||
async tokens() { return this._data('settings', '/api/homecore/tokens', () => mock.settings().tokens); },
|
async tokens() { return this._data('settings', '/api/homecore/tokens', (m) => m.settings().tokens); },
|
||||||
|
|
||||||
// calibration (ADR-151) — real proxy in prod, simulated in demo.
|
// calibration (ADR-151) — real proxy in prod, simulated in demo.
|
||||||
calibration: makeCalibration(),
|
calibration: makeCalibration(),
|
||||||
@@ -123,8 +132,12 @@ export function entityProvenance(entity) {
|
|||||||
const nodeMatch = src.match(/esp32[-\w]*/i);
|
const nodeMatch = src.match(/esp32[-\w]*/i);
|
||||||
const node = attrs.node || (nodeMatch ? nodeMatch[0] : null);
|
const node = attrs.node || (nodeMatch ? nodeMatch[0] : null);
|
||||||
let seed = attrs.seed || null;
|
let seed = attrs.seed || null;
|
||||||
if (!seed && demoMode() && node) {
|
// Demo-only enrichment: consult the mock node registry IF it has already
|
||||||
const cfg = mock.settings().esp32.find((n) => n.node_id === node);
|
// been dynamically loaded by a prior demo data call (this fn is sync, so it
|
||||||
|
// cannot await the import). Prod never has `_mock` set → seed stays null
|
||||||
|
// (never fabricated).
|
||||||
|
if (!seed && demoMode() && node && _mock) {
|
||||||
|
const cfg = _mock.settings().esp32.find((n) => n.node_id === node);
|
||||||
seed = cfg ? cfg.seed : null;
|
seed = cfg ? cfg.seed : null;
|
||||||
}
|
}
|
||||||
const hailo = /hailo|pose/i.test(src) || /hailo/i.test(String(attrs.cog || ''));
|
const hailo = /hailo|pose/i.test(src) || /hailo/i.test(String(attrs.cog || ''));
|
||||||
|
|||||||
@@ -9,6 +9,14 @@ export default {
|
|||||||
const { api } = ctx;
|
const { api } = ctx;
|
||||||
const cal = api.calibration;
|
const cal = api.calibration;
|
||||||
const state = { step: 1, room_id: '', seed: '', baseline_id: null, anchorIdx: 0, trainResult: null };
|
const state = { step: 1, room_id: '', seed: '', baseline_id: null, anchorIdx: 0, trainResult: null };
|
||||||
|
// Track the active baseline poll so it can be cancelled on Restart, on a
|
||||||
|
// step change, and when the panel itself is torn down (the router only
|
||||||
|
// calls the cleanup this render() returns — a per-card _cleanup was never
|
||||||
|
// invoked, leaking the setTimeout loop).
|
||||||
|
let activePoll = null;
|
||||||
|
function stopPoll() {
|
||||||
|
if (activePoll) { activePoll.cancelled = true; if (activePoll.timer) clearTimeout(activePoll.timer); activePoll = null; }
|
||||||
|
}
|
||||||
|
|
||||||
root.appendChild(sectionHeader('Calibration Wizard', 'baseline → enroll → train → verify'));
|
root.appendChild(sectionHeader('Calibration Wizard', 'baseline → enroll → train → verify'));
|
||||||
if (cal.demo) root.appendChild(banner('DEMO — cog-calibration HTTP API (ADR-151) simulated in-browser; the live service replaces this (§7.1).', 'amber'));
|
if (cal.demo) root.appendChild(banner('DEMO — cog-calibration HTTP API (ADR-151) simulated in-browser; the live service replaces this (§7.1).', 'amber'));
|
||||||
@@ -26,7 +34,7 @@ export default {
|
|||||||
stepper.appendChild(h('.step-pill' + (cls ? '.' + cls : ''), h('span.n', n < state.step ? '✓' : String(n)), s));
|
stepper.appendChild(h('.step-pill' + (cls ? '.' + cls : ''), h('span.n', n < state.step ? '✓' : String(n)), s));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
function go(step) { state.step = step; paintStepper(); render(); }
|
function go(step) { stopPoll(); state.step = step; paintStepper(); render(); }
|
||||||
function render() {
|
function render() {
|
||||||
clear(body);
|
clear(body);
|
||||||
if (state.step === 1) body.appendChild(step1());
|
if (state.step === 1) body.appendChild(step1());
|
||||||
@@ -74,35 +82,51 @@ export default {
|
|||||||
const c = card({
|
const c = card({
|
||||||
title: 'Step 2 — Baseline capture (room must be empty)', children: [
|
title: 'Step 2 — Baseline capture (room must be empty)', children: [
|
||||||
progress, meta, baselineLine,
|
progress, meta, baselineLine,
|
||||||
h('.mt', button('Restart', { variant: 'ghost', onClick: () => { cal.reset(); poll.started = false; } })),
|
h('.mt', button('Restart', {
|
||||||
|
variant: 'ghost',
|
||||||
|
// Cancel the in-flight poll loop (was leaked before), reset the
|
||||||
|
// session, and start a fresh capture.
|
||||||
|
onClick: () => { stopPoll(); cal.reset(); clear(baselineLine); startCapture(); },
|
||||||
|
})),
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
const poll = { started: false, timer: null };
|
|
||||||
(async () => {
|
// Single-flight: stopPoll() before (re)arming guarantees one loop.
|
||||||
let startRes;
|
function startCapture() {
|
||||||
try { startRes = await cal.start(); }
|
stopPoll();
|
||||||
catch (e) { clear(meta); meta.appendChild(banner('Baseline start failed — ' + (e.message || e), 'red')); return; }
|
const session = { cancelled: false, timer: null };
|
||||||
state.baseline_id = (startRes && startRes.baseline_id) || state.baseline_id;
|
activePoll = session;
|
||||||
const loop = async () => {
|
(async () => {
|
||||||
let st;
|
let startRes;
|
||||||
try { st = await cal.status(); }
|
try { startRes = await cal.start(); }
|
||||||
catch (e) { clear(meta); meta.appendChild(banner('Status unavailable — ' + (e.message || e), 'red')); return; }
|
catch (e) { clear(meta); meta.appendChild(banner('Baseline start failed — ' + (e.message || e), 'red')); return; }
|
||||||
progress.firstChild.style.width = (st.frames / st.target * 100).toFixed(0) + '%';
|
if (session.cancelled) return;
|
||||||
clear(meta); meta.appendChild(document.createTextNode(`${st.frames}/${st.target} frames · ETA ${st.eta_s}s · z_median ${st.z_median}`));
|
state.baseline_id = (startRes && startRes.baseline_id) || state.baseline_id;
|
||||||
if (st.motion_flagged) { if (!c.querySelector('.banner')) c.insertBefore(banner('Room must be empty — movement detected', 'amber'), progress); }
|
const loop = async () => {
|
||||||
else { const b = c.querySelector('.banner'); if (b) b.remove(); }
|
if (session.cancelled) return;
|
||||||
if (st.frames >= st.target) {
|
let st;
|
||||||
state.baseline_id = state.baseline_id || 'bl-unknown';
|
try { st = await cal.status(); }
|
||||||
clear(baselineLine);
|
catch (e) { clear(meta); meta.appendChild(banner('Status unavailable — ' + (e.message || e), 'red')); return; }
|
||||||
baselineLine.appendChild(h('.mt', h('span.green', 'Baseline complete · '), mono(state.baseline_id), h('span.t2', ' (record this — it anchors STALE detection)')));
|
if (session.cancelled) return;
|
||||||
baselineLine.appendChild(h('.mt', button('Continue to enrollment', { variant: 'primary', onClick: () => go(3) })));
|
progress.firstChild.style.width = pct(st.frames, st.target) + '%';
|
||||||
return;
|
clear(meta); meta.appendChild(document.createTextNode(`${st.frames}/${st.target} frames · ETA ${st.eta_s}s · z_median ${st.z_median}`));
|
||||||
}
|
if (st.motion_flagged) { if (!c.querySelector('.banner')) c.insertBefore(banner('Room must be empty — movement detected', 'amber'), progress); }
|
||||||
poll.timer = setTimeout(loop, 600);
|
else { const b = c.querySelector('.banner'); if (b) b.remove(); }
|
||||||
};
|
if (st.target > 0 && st.frames >= st.target) {
|
||||||
loop();
|
activePoll = null;
|
||||||
})();
|
state.baseline_id = state.baseline_id || 'bl-unknown';
|
||||||
c._cleanup = () => poll.timer && clearTimeout(poll.timer);
|
clear(baselineLine);
|
||||||
|
baselineLine.appendChild(h('.mt', h('span.green', 'Baseline complete · '), mono(state.baseline_id), h('span.t2', ' (record this — it anchors STALE detection)')));
|
||||||
|
baselineLine.appendChild(h('.mt', button('Continue to enrollment', { variant: 'primary', onClick: () => go(3) })));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
session.timer = setTimeout(loop, 600);
|
||||||
|
};
|
||||||
|
loop();
|
||||||
|
})();
|
||||||
|
}
|
||||||
|
|
||||||
|
startCapture();
|
||||||
return c;
|
return c;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -206,10 +230,17 @@ export default {
|
|||||||
|
|
||||||
paintStepper();
|
paintStepper();
|
||||||
render();
|
render();
|
||||||
return () => {};
|
// The router invokes this on navigation away — tear down any live poll.
|
||||||
|
return () => stopPoll();
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Guard against NaN%/Infinity% when target is 0/missing (§4.7 robustness).
|
||||||
|
function pct(frames, target) {
|
||||||
|
if (!(target > 0)) return 0;
|
||||||
|
return Math.max(0, Math.min(100, (frames / target) * 100)).toFixed(0);
|
||||||
|
}
|
||||||
|
|
||||||
function instruction(label) {
|
function instruction(label) {
|
||||||
const map = {
|
const map = {
|
||||||
empty: 'Leave the room empty and still.',
|
empty: 'Leave the room empty and still.',
|
||||||
|
|||||||
@@ -39,8 +39,19 @@ export default {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ── Hailo HEF status ───────────────────────────────────────────
|
// ── Hailo HEF status ───────────────────────────────────────────
|
||||||
|
// §6 honesty: the worker pill must reflect the REAL probe, not a
|
||||||
|
// hardcoded "connected". Probe the appliance services for the
|
||||||
|
// ruvector-hailo-worker; if that upstream is unavailable, show the
|
||||||
|
// status as unknown rather than fabricating "connected".
|
||||||
|
let workerStatus = 'unknown';
|
||||||
|
try {
|
||||||
|
const appliance = await api.appliance();
|
||||||
|
const svc = (appliance.services || []).find((s) => s.name === 'ruvector-hailo-worker');
|
||||||
|
if (svc && svc.status) workerStatus = svc.status;
|
||||||
|
} catch { /* leave 'unknown' — honest not-available, never fabricated */ }
|
||||||
|
|
||||||
root.appendChild(h('h2.mt', 'Hailo-10H accelerator'));
|
root.appendChild(h('h2.mt', 'Hailo-10H accelerator'));
|
||||||
root.appendChild(hailoStatus(cogs));
|
root.appendChild(hailoStatus(cogs, workerStatus));
|
||||||
|
|
||||||
return () => {};
|
return () => {};
|
||||||
},
|
},
|
||||||
@@ -107,7 +118,7 @@ function logText(c) {
|
|||||||
return [
|
return [
|
||||||
`[info] ${c.id} v${c.version} running (pid ${c.pid})`,
|
`[info] ${c.id} v${c.version} running (pid ${c.pid})`,
|
||||||
`[info] arch=${c.arch} sha256_verified=${c.sha256_verified} signature_verified=${c.signature_verified}`,
|
`[info] arch=${c.arch} sha256_verified=${c.sha256_verified} signature_verified=${c.signature_verified}`,
|
||||||
c.arch === 'hailo10' ? `[info] hailo: ${(c.hef || []).join(', ') || 'no HEF loaded'} @ ${c.throughput_fps || '—'} fps` : '[info] cpu-only worker, no Hailo offload',
|
c.arch === 'hailo10' ? `[info] hailo: ${asArray(c.hef).join(', ') || 'no HEF loaded'} @ ${c.throughput_fps || '—'} fps` : '[info] cpu-only worker, no Hailo offload',
|
||||||
'[info] heartbeat ok',
|
'[info] heartbeat ok',
|
||||||
].join('\n');
|
].join('\n');
|
||||||
}
|
}
|
||||||
@@ -120,12 +131,21 @@ function configJson(c) {
|
|||||||
autostart: c.status !== 'stopped',
|
autostart: c.status !== 'stopped',
|
||||||
};
|
};
|
||||||
if (c.arch === 'hailo10') {
|
if (c.arch === 'hailo10') {
|
||||||
cfg.hef = c.hef || [];
|
cfg.hef = asArray(c.hef);
|
||||||
cfg.target_fps = c.throughput_fps || null;
|
cfg.target_fps = c.throughput_fps || null;
|
||||||
}
|
}
|
||||||
return JSON.stringify(cfg, null, 2);
|
return JSON.stringify(cfg, null, 2);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Coerce a forwarded manifest `hef` (array | string | object | null) into an
|
||||||
|
// array so a non-array value degrades gracefully instead of throwing on
|
||||||
|
// .forEach/.join/.length (the gateway forwards it verbatim — §11).
|
||||||
|
function asArray(v) {
|
||||||
|
if (Array.isArray(v)) return v;
|
||||||
|
if (v == null || v === '') return [];
|
||||||
|
return [v];
|
||||||
|
}
|
||||||
|
|
||||||
// ── OTA update diff card ─────────────────────────────────────────────
|
// ── OTA update diff card ─────────────────────────────────────────────
|
||||||
function updateCard(u) {
|
function updateCard(u) {
|
||||||
const diff = h('div',
|
const diff = h('div',
|
||||||
@@ -148,19 +168,22 @@ function diffList(title, items, color) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ── Hailo HEF status ─────────────────────────────────────────────────
|
// ── Hailo HEF status ─────────────────────────────────────────────────
|
||||||
function hailoStatus(cogs) {
|
function hailoStatus(cogs, workerStatus = 'unknown') {
|
||||||
const hailoCogs = cogs.filter((c) => c.arch === 'hailo10');
|
const hailoCogs = cogs.filter((c) => c.arch === 'hailo10');
|
||||||
const worker = h('.flex.gap-sm', statusPill('connected'), h('span.mono.t2', 'ruvector-hailo-worker:50051'));
|
// statusPill maps 'running'/'connected'→green, 'unreachable'/'error'→red,
|
||||||
|
// 'unknown'→grey; the real probe drives the colour, never a hardcode.
|
||||||
|
const worker = h('.flex.gap-sm', statusPill(workerStatus), h('span.mono.t2', 'ruvector-hailo-worker:50051'));
|
||||||
const body = h('div', worker);
|
const body = h('div', worker);
|
||||||
|
|
||||||
if (!hailoCogs.length) {
|
if (!hailoCogs.length) {
|
||||||
body.appendChild(h('.muted-empty', 'No Hailo-sourced COGs loaded.'));
|
body.appendChild(h('.muted-empty', 'No Hailo-sourced COGs loaded.'));
|
||||||
} else {
|
} else {
|
||||||
hailoCogs.forEach((c) => {
|
hailoCogs.forEach((c) => {
|
||||||
|
const hef = asArray(c.hef); // gateway forwards manifest `hef` verbatim — may be a string
|
||||||
const hefRows = h('div',
|
const hefRows = h('div',
|
||||||
h('.flex.spread', h('strong.mono', `${c.id} ${c.version}`), pill((c.throughput_fps || 0) + ' fps', 'purple')));
|
h('.flex.spread', h('strong.mono', `${c.id} ${c.version}`), pill((c.throughput_fps || 0) + ' fps', 'purple')));
|
||||||
(c.hef || []).forEach((f) => hefRows.appendChild(h('.row', h('span.mono.purple', f), h('span.t2', 'loaded'))));
|
hef.forEach((f) => hefRows.appendChild(h('.row', h('span.mono.purple', f), h('span.t2', 'loaded'))));
|
||||||
if (!(c.hef || []).length) hefRows.appendChild(h('.muted-empty', 'no .hef files loaded'));
|
if (!hef.length) hefRows.appendChild(h('.muted-empty', 'no .hef files loaded'));
|
||||||
body.appendChild(h('.mt', hefRows));
|
body.appendChild(h('.mt', hefRows));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,10 +19,10 @@ export default {
|
|||||||
await section(root, 'v0 Appliance health', async () => {
|
await section(root, 'v0 Appliance health', async () => {
|
||||||
const a = await api.appliance();
|
const a = await api.appliance();
|
||||||
const strip = h('.metric-grid',
|
const strip = h('.metric-grid',
|
||||||
metric({ icon: '🖥', value: a.cpu_pct + '%', label: 'CPU' }),
|
metric({ icon: '🖥', value: pctOrNA(a.cpu_pct), label: 'CPU' }),
|
||||||
metric({ icon: '🧠', value: a.ram_pct + '%', label: 'RAM' }),
|
metric({ icon: '🧠', value: pctOrNA(a.ram_pct), label: 'RAM' }),
|
||||||
metric({ icon: '⚡', value: a.hailo_load_pct + '%', label: 'Hailo-10H load' }),
|
metric({ icon: '⚡', value: pctOrNA(a.hailo_load_pct), label: 'Hailo-10H load' }),
|
||||||
metric({ icon: '🌡', value: a.hailo_temp_c + '°C', label: 'Hailo temp' }),
|
metric({ icon: '🌡', value: unitOrNA(a.hailo_temp_c, '°C'), label: 'Hailo temp' }),
|
||||||
metric({ icon: '⏱', value: fmtUptime(a.uptime_s), label: 'Uptime', color: 'green' }));
|
metric({ icon: '⏱', value: fmtUptime(a.uptime_s), label: 'Uptime', color: 'green' }));
|
||||||
const healthCard = card({ title: 'v0 Appliance health', children: [strip, servicesRow(a.services)] });
|
const healthCard = card({ title: 'v0 Appliance health', children: [strip, servicesRow(a.services)] });
|
||||||
return h('div', healthCard, eventBus(a, ctx, (fn) => { cleanupEvent = fn; }));
|
return h('div', healthCard, eventBus(a, ctx, (fn) => { cleanupEvent = fn; }));
|
||||||
@@ -135,7 +135,13 @@ function eventBus(a, ctx, setCleanup) {
|
|||||||
return card({ title: 'Event Bus activity', children: [body] });
|
return card({ title: 'Event Bus activity', children: [body] });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// §6 honesty: a null/undefined metric must render a distinct not-available
|
||||||
|
// state ('—'), never a fabricated value like "null%"/"null°C".
|
||||||
|
function pctOrNA(v) { return v == null ? '—' : v + '%'; }
|
||||||
|
function unitOrNA(v, unit) { return v == null ? '—' : v + unit; }
|
||||||
|
|
||||||
function fmtUptime(s) {
|
function fmtUptime(s) {
|
||||||
|
if (s == null) return '—';
|
||||||
const d = Math.floor(s / 86400), hh = Math.floor((s % 86400) / 3600);
|
const d = Math.floor(s / 86400), hh = Math.floor((s % 86400) / 3600);
|
||||||
return d > 0 ? `${d}d ${hh}h` : `${hh}h`;
|
return d > 0 ? `${d}d ${hh}h` : `${hh}h`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -88,8 +88,17 @@ function vitalRow(label, spec, unit, range, r) {
|
|||||||
|
|
||||||
function anomalyRow(a) {
|
function anomalyRow(a) {
|
||||||
if (!a) return specRow('Anomaly', notTrainedNode(), null);
|
if (!a) return specRow('Anomaly', notTrainedNode(), null);
|
||||||
const over = a.value > (a.threshold ?? 0.8);
|
// §6 honesty: a null threshold is WITHHELD (the upstream RoomState carried
|
||||||
const b = bar(a.value, 1, [{ lt: a.threshold ?? 0.8, color: 'green' }, { lt: 1.01, color: 'red' }]);
|
// none) — show the value but flag the threshold as unavailable rather than
|
||||||
|
// judging anomalous/normal against a fabricated 0.8 default.
|
||||||
|
if (a.threshold == null) {
|
||||||
|
const wrap = h('div', { style: { width: '160px' } },
|
||||||
|
bar(a.value, 1),
|
||||||
|
h('small.ts', { title: 'no anomaly threshold from upstream — withheld' }, `${a.value} · threshold —`));
|
||||||
|
return specRow('Anomaly', wrap, a);
|
||||||
|
}
|
||||||
|
const over = a.value > a.threshold;
|
||||||
|
const b = bar(a.value, 1, [{ lt: a.threshold, color: 'green' }, { lt: 1.01, color: 'red' }]);
|
||||||
const wrap = h('div', { style: { width: '160px' } }, b,
|
const wrap = h('div', { style: { width: '160px' } }, b,
|
||||||
h('small.ts', over ? 'anomalous' : 'normal', ` · ${a.value}`));
|
h('small.ts', over ? 'anomalous' : 'normal', ` · ${a.value}`));
|
||||||
return specRow('Anomaly', wrap, a);
|
return specRow('Anomaly', wrap, a);
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
"description": "HOMECORE-UI — operational dashboard for the two-tier Cognitum stack (ADR-131). Zero-dependency vanilla TS/JS + CSS; served by homecore-server at /homecore.",
|
"description": "HOMECORE-UI — operational dashboard for the two-tier Cognitum stack (ADR-131). Zero-dependency vanilla TS/JS + CSS; served by homecore-server at /homecore.",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"check": "node tests/verify-imports.mjs",
|
"check": "node tests/verify-imports.mjs",
|
||||||
"test": "node tests/verify-imports.mjs && node tests/boot.mjs && node tests/render-smoke.mjs && node tests/interaction.mjs && node tests/prod-errors.mjs",
|
"test": "node tests/verify-imports.mjs && node tests/boot.mjs && node tests/render-smoke.mjs && node tests/interaction.mjs && node tests/prod-errors.mjs && node tests/unit-fixes.mjs",
|
||||||
"bench": "node tests/benchmark.mjs"
|
"bench": "node tests/benchmark.mjs"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,101 @@
|
|||||||
|
// Regression tests pinning the ADR-131 PR-1082 review fixes:
|
||||||
|
// * dashboard renders a not-available state ('—') for null appliance
|
||||||
|
// metrics — never "null%"/"null°C" (§6 honesty / fabricated-data fix).
|
||||||
|
// * cogs panel does NOT throw when the gateway forwards a `hef` that is a
|
||||||
|
// string (or other non-array) instead of an array (crash/robustness fix).
|
||||||
|
// * cogs Hailo worker pill reflects the real probe, not a hardcoded
|
||||||
|
// "connected" (§6 honesty fix).
|
||||||
|
// Run: node tests/unit-fixes.mjs
|
||||||
|
import { install } from './dom-shim.mjs';
|
||||||
|
install();
|
||||||
|
globalThis.HOMECORE_UI_DEMO = false; // production path — no fixtures
|
||||||
|
|
||||||
|
const fails = [], passes = [];
|
||||||
|
async function t(name, fn) {
|
||||||
|
try { await fn(); passes.push(name); }
|
||||||
|
catch (e) { fails.push(`${name}: ${e && e.stack ? e.stack.split('\n').slice(0, 3).join(' | ') : e}`); }
|
||||||
|
}
|
||||||
|
const assert = (c, m) => { if (!c) throw new Error(m || 'assertion failed'); };
|
||||||
|
|
||||||
|
const { api } = await import('../js/api.js');
|
||||||
|
|
||||||
|
// Shared ctx; per-test we override the api accessors we need.
|
||||||
|
function ctxWith(overrides) {
|
||||||
|
return {
|
||||||
|
api: Object.assign(Object.create(api), overrides),
|
||||||
|
navigate() {},
|
||||||
|
params: {},
|
||||||
|
onEvent() { return () => {}; },
|
||||||
|
onWs(fn) { fn({ state: 'closed', lagged: false }); return () => {}; },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── dashboard: null metrics → '—', never "null%"/"null°C" ─────────────
|
||||||
|
await t('dashboard renders not-available for null hailo metrics (no "null%")', async () => {
|
||||||
|
const mod = await import('../js/panels/dashboard.js');
|
||||||
|
const root = document.createElement('div');
|
||||||
|
const ctx = ctxWith({
|
||||||
|
appliance: async () => ({
|
||||||
|
cpu_pct: 12.5, ram_pct: 40.1,
|
||||||
|
hailo_load_pct: null, hailo_temp_c: null, // the fabricated-data trap
|
||||||
|
uptime_s: null,
|
||||||
|
services: [{ name: 'ruview-mcp-brain', port: 9876, status: 'unreachable' }],
|
||||||
|
event_rate: [], channel_capacity: 4096, channel_lag: 0,
|
||||||
|
}),
|
||||||
|
seeds: async () => [],
|
||||||
|
esp32Warnings: async () => [],
|
||||||
|
cogs: async () => [],
|
||||||
|
anyDemo: () => false,
|
||||||
|
});
|
||||||
|
const cleanup = await mod.default.render(root, ctx);
|
||||||
|
const text = root.textContent;
|
||||||
|
assert(!/null\s*%/.test(text), `dashboard showed "null%": ${text.slice(0, 200)}`);
|
||||||
|
assert(!/null\s*°C/.test(text), `dashboard showed "null°C": ${text.slice(0, 200)}`);
|
||||||
|
assert(text.includes('—'), 'dashboard should render the "—" not-available marker for null metrics');
|
||||||
|
// real values must still concatenate their unit
|
||||||
|
assert(text.includes('12.5%'), 'real CPU value must still render with its unit');
|
||||||
|
if (typeof cleanup === 'function') cleanup();
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── cogs: string `hef` must not throw ─────────────────────────────────
|
||||||
|
await t('cogs does not throw when hef is a string (non-array)', async () => {
|
||||||
|
const mod = await import('../js/panels/cogs.js');
|
||||||
|
const root = document.createElement('div');
|
||||||
|
const ctx = ctxWith({
|
||||||
|
cogs: async () => [
|
||||||
|
{ id: 'cog-pose', version: '1.0', arch: 'hailo10', status: 'running', pid: 42,
|
||||||
|
sha256_verified: true, signature_verified: true, throughput_fps: 30,
|
||||||
|
hef: 'pose_estimation.hef' }, // STRING, not array — the crash trap
|
||||||
|
],
|
||||||
|
cogUpdates: async () => [],
|
||||||
|
appliance: async () => ({ services: [{ name: 'ruvector-hailo-worker', port: 50051, status: 'running' }] }),
|
||||||
|
isDemo: () => false,
|
||||||
|
});
|
||||||
|
// If asArray() weren't applied, .forEach/.join/.length on a string would throw.
|
||||||
|
const cleanup = await mod.default.render(root, ctx);
|
||||||
|
assert(root.children.length > 0, 'cogs rendered nothing');
|
||||||
|
// The string hef should surface as a single loaded HEF row.
|
||||||
|
assert(root.textContent.includes('pose_estimation.hef'), 'string hef should render as one HEF entry');
|
||||||
|
if (typeof cleanup === 'function') cleanup();
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── cogs: Hailo worker pill reflects the real probe, not hardcoded ────
|
||||||
|
await t('cogs Hailo worker pill is unknown when appliance probe is unavailable', async () => {
|
||||||
|
const mod = await import('../js/panels/cogs.js');
|
||||||
|
const root = document.createElement('div');
|
||||||
|
const ctx = ctxWith({
|
||||||
|
cogs: async () => [],
|
||||||
|
cogUpdates: async () => [],
|
||||||
|
appliance: async () => { throw new Error('appliance upstream down'); }, // probe fails
|
||||||
|
isDemo: () => false,
|
||||||
|
});
|
||||||
|
const cleanup = await mod.default.render(root, ctx);
|
||||||
|
// statusPill('unknown') → grey pill containing the literal label "unknown".
|
||||||
|
assert(root.textContent.includes('unknown'), 'worker status should be honestly "unknown" when probe fails');
|
||||||
|
assert(!/connected/.test(root.textContent), 'worker pill must not fabricate "connected"');
|
||||||
|
if (typeof cleanup === 'function') cleanup();
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`\n${passes.length} passed, ${fails.length} failed`);
|
||||||
|
if (fails.length) { console.error('\nFAILURES:'); fails.forEach((f) => console.error(' ✗ ' + f)); process.exit(1); }
|
||||||
|
console.log('OK — dashboard not-available, cogs string-hef + honest worker pill pinned');
|
||||||
Reference in New Issue
Block a user