Merge pull request #1332 from ruvnet/integrate/pr-1311-1313

Integrate community fixes: DevKitC-1 overlay, EngineBridge guard config, pose-WS bearer auth (#1308 #1309 #1310)
This commit is contained in:
rUv 2026-07-14 12:48:11 -04:00 committed by GitHub
commit 7b244bdc8c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 191 additions and 22 deletions

View File

@ -11,6 +11,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- **`@ruvnet/rvagent` startup optimization — stdio time-to-first-response ~242 ms → ~189 ms (22%; MEASURED, median of repeated `initialize` round-trips against `dist/index.js`, this container, reproduce with a piped-stdin timer).** Two changes: (1) `./http-transport.js` is now imported **lazily** inside the `RVAGENT_HTTP_PORT` branch — it chain-loads the MCP SDK's `streamableHttp` module (~48 ms MEASURED via per-module `import()` timing), which the default stdio path never uses; (2) the advertised JSON Schemas generated from the Zod sources are memoized per tool instead of re-walking the Zod tree on every `tools/list` (matters under the session-per-server HTTP model where each session lists tools). No behavior change: 99/99 jest tests, HTTP session flow re-smoke-tested through the lazy path. The `@ruvnet/ruview` harness CLI was profiled too and left alone — 50 ms vs the ~29 ms bare `node -e ''` floor on the same box (MEASURED), i.e. already near the interpreter floor with zero dependencies. - **`@ruvnet/rvagent` startup optimization — stdio time-to-first-response ~242 ms → ~189 ms (22%; MEASURED, median of repeated `initialize` round-trips against `dist/index.js`, this container, reproduce with a piped-stdin timer).** Two changes: (1) `./http-transport.js` is now imported **lazily** inside the `RVAGENT_HTTP_PORT` branch — it chain-loads the MCP SDK's `streamableHttp` module (~48 ms MEASURED via per-module `import()` timing), which the default stdio path never uses; (2) the advertised JSON Schemas generated from the Zod sources are memoized per tool instead of re-walking the Zod tree on every `tools/list` (matters under the session-per-server HTTP model where each session lists tools). No behavior change: 99/99 jest tests, HTTP session flow re-smoke-tested through the lazy path. The `@ruvnet/ruview` harness CLI was profiled too and left alone — 50 ms vs the ~29 ms bare `node -e ''` floor on the same box (MEASURED), i.e. already near the interpreter floor with zero dependencies.
### Fixed ### Fixed
- **EngineBridge now honors `WDP_GUARD_INTERVAL_US`/`WDP_SOFT_GUARD_US`/`WDP_TDM_SLOTS`+`WDP_TDM_SLOT_US`** (#1309, PR #1312, @erichkusuki). The governed trust path previously built its multistatic fuser from a hardcoded `MultistaticConfig::default()` (60 ms guard), so multi-node deployments with WiFi/ESP-NOW time sync (10150 ms drift) failed every governed cycle regardless of configuration — while the startup log claimed the override took effect. New `StreamingEngine::set_multistatic_config()`; `EngineBridge::new()` takes an `Option<MultistaticConfig>` threaded from the same env-derived config as `AppState.multistatic_fuser`. Hardware-verified on a live 2-node ESP32-S3 setup (90 s window, 0 fusion errors; previously every cycle failed).
- **`/api/v1/stream/pose` WebSocket reachable with `RUVIEW_API_TOKEN` set + dashboard bearer-token field** (#1310, PR #1313, @erichkusuki). Browsers cannot attach an `Authorization` header to a WS upgrade, so the Live Demo pose stream always failed when auth was on; the path is now on a narrow exact-match exemption list (mirrors `/ws/sensing`), with a regression test pinning that the exemption doesn't leak to other `/api/v1/*` paths. The QuickSettings panel gains an "API Access" field storing the bearer token in `localStorage`; the token is applied at `api.service.js` module load so the very first request carries it.
- **Display-less DevKitC-1 boards: `sdkconfig.defaults.devkitc` build overlay** (#1308, PR #1311, @erichkusuki). The ADR-045 runtime display probe false-positives on stock ESP32-S3-DevKitC-1 (floating QSPI pins), which silently skipped the RuView#893 MGMT+DATA CSI upgrade and collapsed CSI yield to 0 pps. The overlay compiles display support out (`has_display` constant-false). Also fixes stale `espressif/idf:v5.2` README references to v5.4 (source requires `esp_driver_uart`, IDF ≥5.3). Hardware-verified on 2× DevKitC-1-N16R8 (0 → 4045 pps).
- **ADR-263/264/265 implemented — the RuView npm surface fixed end-to-end (`@ruvnet/ruview@0.2.0`, `@ruvnet/rvagent@0.2.0`, `@ruv/ruview-cli`).** Harness (ADR-263 O1O9): `claim-check` now **fails closed** on empty input (CLI exit 2 + `empty_text` tool error); the MCP stdio server dispatches `tools/call` asynchronously over promise-based `spawn``ping` answers while a long `verify`/`calibrate` runs (pinned by a new e2e test that runs a 3 s fake proof and asserts sub-second ping); the two `optionalDependencies` are gone so a cold `npx` installs exactly 1 package (MEASURED: was 4 packages / 620 kB / 71 files, `npm i` in a clean prefix); child output is captured as bounded rolling tails (no more 1 MiB `maxBuffer` kills); `node_monitor` passes the port via `sys.argv` instead of splicing it into `python -c` source; the MCP `serverInfo.version` reads package.json; `.claude/skills/*/SKILL.md` are generated from `skills/*.md` by a `prepack` sync script (byte-equality pinned by test); `which()` is a memoized dep-free PATH scan; tools are underscore-canonical (`ruview_claim_check`, …) with the dotted names accepted as call-time aliases, plus `resources/list`/`prompts/list` stubs; the guardrail's `METRIC_TERMS` matching is precision-fixed (word-boundary `map`/`f1`/`auc`/`iou`, code-span + label scrubbing, quantitative-claims-only) — ADR-263/264/265 and both package READMEs now PASS `claim-check` while real untagged claims still flag. 30/30 tests (MEASURED, `node --test`). rvagent (ADR-264 O1O9): `exports` fixed (types-first, the never-built `dist/index.cjs` `require` target removed — verified broken in the published 0.1.0 tarball); tarball is map-free (127,704 B unpacked / 46 files / 0 maps — MEASURED, `npm pack --dry-run`, down from 188 kB with 44 maps); the Streamable HTTP transport is **actually wired** behind `RVAGENT_HTTP_PORT` with one transport + one MCP server per session (`mcp-session-id` routing), a 1 MiB body cap (413), and a port-aware localhost origin gate — the "dual-transport" description is now true; tools renamed to underscore-canonical with dotted router aliases; ONE Zod validation gate per call with the advertised JSON Schema generated from the same Zod source (`zod-to-json-schema`); `train_count` closes its log fds (was leaking 2/job) and persists job records to `<jobsDir>/<id>.json` so `job_status` survives restarts, with bounded log-tail reads; `detectCogBinary` actually probes its candidate paths; version reads package.json; `@types/express` dropped, `@types/jest` aligned to jest 29; README rewritten to match reality (no phantom `stdio`/`http`/`policy grant` subcommands; unimplemented ADR-124 catalog tools labeled roadmap). 99/99 jest tests (MEASURED); stdio handshake + HTTP session flow + 403/400/404/413 gates smoke-tested live. CLI: bin renamed `ruview-cli` (the `ruview` bin belongs to `@ruvnet/ruview`, ADR-265 D4), version single-sourced. Distribution (ADR-265 D1D4): new `npm-packages.yml` (3-package × Node 20/22 matrix: tests, version-literal grep gate, pack-content/size gate, tarball-install smoke test incl. the fail-closed claim-check and an ESM-import probe that would have caught the broken `require` export, README claim-check) and `ruview-npm-release.yml` (publish from CI only, `npm publish --provenance`); `ci.yml` NODE_VERSION 18→20. - **ADR-263/264/265 implemented — the RuView npm surface fixed end-to-end (`@ruvnet/ruview@0.2.0`, `@ruvnet/rvagent@0.2.0`, `@ruv/ruview-cli`).** Harness (ADR-263 O1O9): `claim-check` now **fails closed** on empty input (CLI exit 2 + `empty_text` tool error); the MCP stdio server dispatches `tools/call` asynchronously over promise-based `spawn``ping` answers while a long `verify`/`calibrate` runs (pinned by a new e2e test that runs a 3 s fake proof and asserts sub-second ping); the two `optionalDependencies` are gone so a cold `npx` installs exactly 1 package (MEASURED: was 4 packages / 620 kB / 71 files, `npm i` in a clean prefix); child output is captured as bounded rolling tails (no more 1 MiB `maxBuffer` kills); `node_monitor` passes the port via `sys.argv` instead of splicing it into `python -c` source; the MCP `serverInfo.version` reads package.json; `.claude/skills/*/SKILL.md` are generated from `skills/*.md` by a `prepack` sync script (byte-equality pinned by test); `which()` is a memoized dep-free PATH scan; tools are underscore-canonical (`ruview_claim_check`, …) with the dotted names accepted as call-time aliases, plus `resources/list`/`prompts/list` stubs; the guardrail's `METRIC_TERMS` matching is precision-fixed (word-boundary `map`/`f1`/`auc`/`iou`, code-span + label scrubbing, quantitative-claims-only) — ADR-263/264/265 and both package READMEs now PASS `claim-check` while real untagged claims still flag. 30/30 tests (MEASURED, `node --test`). rvagent (ADR-264 O1O9): `exports` fixed (types-first, the never-built `dist/index.cjs` `require` target removed — verified broken in the published 0.1.0 tarball); tarball is map-free (127,704 B unpacked / 46 files / 0 maps — MEASURED, `npm pack --dry-run`, down from 188 kB with 44 maps); the Streamable HTTP transport is **actually wired** behind `RVAGENT_HTTP_PORT` with one transport + one MCP server per session (`mcp-session-id` routing), a 1 MiB body cap (413), and a port-aware localhost origin gate — the "dual-transport" description is now true; tools renamed to underscore-canonical with dotted router aliases; ONE Zod validation gate per call with the advertised JSON Schema generated from the same Zod source (`zod-to-json-schema`); `train_count` closes its log fds (was leaking 2/job) and persists job records to `<jobsDir>/<id>.json` so `job_status` survives restarts, with bounded log-tail reads; `detectCogBinary` actually probes its candidate paths; version reads package.json; `@types/express` dropped, `@types/jest` aligned to jest 29; README rewritten to match reality (no phantom `stdio`/`http`/`policy grant` subcommands; unimplemented ADR-124 catalog tools labeled roadmap). 99/99 jest tests (MEASURED); stdio handshake + HTTP session flow + 403/400/404/413 gates smoke-tested live. CLI: bin renamed `ruview-cli` (the `ruview` bin belongs to `@ruvnet/ruview`, ADR-265 D4), version single-sourced. Distribution (ADR-265 D1D4): new `npm-packages.yml` (3-package × Node 20/22 matrix: tests, version-literal grep gate, pack-content/size gate, tarball-install smoke test incl. the fail-closed claim-check and an ESM-import probe that would have caught the broken `require` export, README claim-check) and `ruview-npm-release.yml` (publish from CI only, `npm publish --provenance`); `ci.yml` NODE_VERSION 18→20.
- **Multistatic fusion never ran on a mixed-mode ESP32 mesh — live bridge fed raw, un-canonicalized per-node CSI to the fuser (#1170).** `node_frame_from_state` (`multistatic_bridge.rs`) wrapped each node's **raw** amplitude vector (HT20 ≈ 64 bins, HT40 ≈ 128/192) into a struct *named* `CanonicalCsiFrame` without ever resampling, so `MultistaticFuser::fuse` tripped `DimensionMismatch` on every cycle, silently fell back to per-node sum/dedup, and spun `total_engine_errors` unbounded. Added `HardwareNormalizer::resample_to_canonical` (resample-only, **no z-score** — preserves the amplitude scale the person-score's `variance/mean²` relies on) and run every node frame through it onto the canonical 56-tone grid before fusion. Heterogeneous meshes now fuse instead of erroring. Pinned by `heterogeneous_node_counts_canonicalize_and_fuse` (mixed 64/192 → fuses), `resample_to_canonical_is_length_only_no_zscore`, and an updated `test_node_frame_conversion`; the pre-existing `engine_bridge::observe_cycle_counts_engine_errors` was retargeted to force a `TimestampMismatch` (its old 56-vs-30 setup now canonicalizes cleanly). `wifi-densepose-signal` 501 / `wifi-densepose-sensing-server` 677 tests, 0 failed. - **Multistatic fusion never ran on a mixed-mode ESP32 mesh — live bridge fed raw, un-canonicalized per-node CSI to the fuser (#1170).** `node_frame_from_state` (`multistatic_bridge.rs`) wrapped each node's **raw** amplitude vector (HT20 ≈ 64 bins, HT40 ≈ 128/192) into a struct *named* `CanonicalCsiFrame` without ever resampling, so `MultistaticFuser::fuse` tripped `DimensionMismatch` on every cycle, silently fell back to per-node sum/dedup, and spun `total_engine_errors` unbounded. Added `HardwareNormalizer::resample_to_canonical` (resample-only, **no z-score** — preserves the amplitude scale the person-score's `variance/mean²` relies on) and run every node frame through it onto the canonical 56-tone grid before fusion. Heterogeneous meshes now fuse instead of erroring. Pinned by `heterogeneous_node_counts_canonicalize_and_fuse` (mixed 64/192 → fuses), `resample_to_canonical_is_length_only_no_zscore`, and an updated `test_node_frame_conversion`; the pre-existing `engine_bridge::observe_cycle_counts_engine_errors` was retargeted to force a `TimestampMismatch` (its old 56-vs-30 setup now canonicalizes cleanly). `wifi-densepose-signal` 501 / `wifi-densepose-sensing-server` 677 tests, 0 failed.
- **`csi_fps_ema` reported the CSI frame rate 40840× too high under bursty UDP delivery (#1180).** `update_csi_fps_ema` only rejected deltas `≤ 0` or `≥ 1 s`, so a 36 µs intra-burst arrival delta yielded `1/dt ≈ 27 kHz` straight into the EMA — the metric measured server arrival jitter, not the node's ~40 fps production rate. Added a `MIN_PLAUSIBLE_CSI_DT_SEC = 0.005` floor (derived from the firmware's 50 fps `CSI_MIN_SEND_INTERVAL_US` ceiling, ×4 slack) and made `observe_csi_frame_arrival` keep its anchor across sub-floor bursts so the next genuine inter-frame gap measures true cadence. Pinned by `subms_burst_delta_rejected`, `burst_interleaved_with_nominal_stays_in_band`, and `observe_csi_frame_arrival_ignores_subms_bursts`. - **`csi_fps_ema` reported the CSI frame rate 40840× too high under bursty UDP delivery (#1180).** `update_csi_fps_ema` only rejected deltas `≤ 0` or `≥ 1 s`, so a 36 µs intra-burst arrival delta yielded `1/dt ≈ 27 kHz` straight into the EMA — the metric measured server arrival jitter, not the node's ~40 fps production rate. Added a `MIN_PLAUSIBLE_CSI_DT_SEC = 0.005` floor (derived from the firmware's 50 fps `CSI_MIN_SEND_INTERVAL_US` ceiling, ×4 slack) and made `observe_csi_frame_arrival` keep its anchor across sub-floor bursts so the next genuine inter-frame gap measures true cadence. Pinned by `subms_burst_delta_rejected`, `burst_interleaved_with_nominal_stays_in_band`, and `observe_csi_frame_arrival_ignores_subms_bursts`.

View File

@ -4,7 +4,7 @@
This firmware captures WiFi Channel State Information (CSI) from an ESP32-S3 (production) or ESP32-C6 (research target — Wi-Fi 6 / 802.15.4 / TWT / LP-core hibernation, see [ADR-110](../../docs/adr/ADR-110-esp32-c6-firmware-extension.md)) and transforms it into real-time presence detection, vital sign monitoring, and programmable sensing -- all without cameras or wearables. Part of the [WiFi-DensePose](../../README.md) project. This firmware captures WiFi Channel State Information (CSI) from an ESP32-S3 (production) or ESP32-C6 (research target — Wi-Fi 6 / 802.15.4 / TWT / LP-core hibernation, see [ADR-110](../../docs/adr/ADR-110-esp32-c6-firmware-extension.md)) and transforms it into real-time presence detection, vital sign monitoring, and programmable sensing -- all without cameras or wearables. Part of the [WiFi-DensePose](../../README.md) project.
[![ESP-IDF v5.2](https://img.shields.io/badge/ESP--IDF-v5.2-blue.svg)](https://docs.espressif.com/projects/esp-idf/en/v5.2/) [![ESP-IDF v5.4](https://img.shields.io/badge/ESP--IDF-v5.4-blue.svg)](https://docs.espressif.com/projects/esp-idf/en/v5.4/)
[![Target: ESP32-S3 / ESP32-C6](https://img.shields.io/badge/target-ESP32--S3%20%7C%20ESP32--C6-purple.svg)](https://www.espressif.com/en/products/socs/esp32-s3) [![Target: ESP32-S3 / ESP32-C6](https://img.shields.io/badge/target-ESP32--S3%20%7C%20ESP32--C6-purple.svg)](https://www.espressif.com/en/products/socs/esp32-s3)
[![License: MIT OR Apache-2.0](https://img.shields.io/badge/license-MIT%20OR%20Apache--2.0-green.svg)](../../LICENSE) [![License: MIT OR Apache-2.0](https://img.shields.io/badge/license-MIT%20OR%20Apache--2.0-green.svg)](../../LICENSE)
[![Binary: ~943 KB](https://img.shields.io/badge/binary-~943%20KB-orange.svg)](#memory-budget) [![Binary: ~943 KB](https://img.shields.io/badge/binary-~943%20KB-orange.svg)](#memory-budget)
@ -48,10 +48,18 @@ with `--flash_size 4MB`.
# From the repository root: # From the repository root:
MSYS_NO_PATHCONV=1 docker run --rm \ MSYS_NO_PATHCONV=1 docker run --rm \
-v "$(pwd)/firmware/esp32-csi-node:/project" -w /project \ -v "$(pwd)/firmware/esp32-csi-node:/project" -w /project \
espressif/idf:v5.2 bash -c \ espressif/idf:v5.4 bash -c \
"rm -rf build sdkconfig && idf.py set-target esp32s3 && idf.py build" "rm -rf build sdkconfig && idf.py set-target esp32s3 && idf.py build"
``` ```
> **Display-less boards (ESP32-S3-DevKitC-1 and similar):** build with the
> `sdkconfig.defaults.devkitc` overlay instead — the default build compiles
> display support in, and the runtime panel probe false-positives on boards
> with no panel, which disables the RuView#893 MGMT+DATA CSI upgrade and
> collapses CSI yield to 0 pps. See the header of
> [`sdkconfig.defaults.devkitc`](sdkconfig.defaults.devkitc) for the exact
> build command.
### 2. Flash ### 2. Flash
Offsets must match `partitions_display.csv` (8 MB) or `partitions_4mb.csv` (4 MB): Offsets must match `partitions_display.csv` (8 MB) or `partitions_4mb.csv` (4 MB):
@ -250,7 +258,7 @@ Offset Size Field
# From the repository root: # From the repository root:
MSYS_NO_PATHCONV=1 docker run --rm \ MSYS_NO_PATHCONV=1 docker run --rm \
-v "$(pwd)/firmware/esp32-csi-node:/project" -w /project \ -v "$(pwd)/firmware/esp32-csi-node:/project" -w /project \
espressif/idf:v5.2 bash -c \ espressif/idf:v5.4 bash -c \
"rm -rf build sdkconfig && idf.py set-target esp32s3 && idf.py build" "rm -rf build sdkconfig && idf.py set-target esp32s3 && idf.py build"
``` ```
@ -268,7 +276,7 @@ To change Kconfig settings before building:
```bash ```bash
MSYS_NO_PATHCONV=1 docker run --rm -it \ MSYS_NO_PATHCONV=1 docker run --rm -it \
-v "$(pwd)/firmware/esp32-csi-node:/project" -w /project \ -v "$(pwd)/firmware/esp32-csi-node:/project" -w /project \
espressif/idf:v5.2 bash -c \ espressif/idf:v5.4 bash -c \
"idf.py set-target esp32s3 && idf.py menuconfig" "idf.py set-target esp32s3 && idf.py menuconfig"
``` ```

View File

@ -0,0 +1,16 @@
# DevKitC-1 (display-less) production overlay.
#
# The stock ESP32-S3-DevKitC-1 has no AMOLED panel, but the ADR-045 runtime
# probe false-positives on it: with no TCA9554 and floating QSPI pins, the
# SH8601 init sequence reports success, display_is_active() returns true, and
# main.c skips the RuView#893 MGMT+DATA promiscuous upgrade — CSI yield
# collapses to 0 pps (the exact symptom #893 fixed). Compiling display support
# out makes has_display constant-false so the upgrade always applies.
#
# Build (from repo root, per README "Docker — the only reliable method"):
# MSYS_NO_PATHCONV=1 docker run --rm \
# -v "$(pwd)/firmware/esp32-csi-node:/project" -w /project \
# espressif/idf:v5.4 bash -c \
# "rm -rf build sdkconfig && idf.py -DSDKCONFIG_DEFAULTS='sdkconfig.defaults;sdkconfig.defaults.devkitc' set-target esp32s3 && idf.py -DSDKCONFIG_DEFAULTS='sdkconfig.defaults;sdkconfig.defaults.devkitc' build"
# CONFIG_DISPLAY_ENABLE is not set

View File

@ -147,4 +147,17 @@ export class ApiService {
} }
// Create singleton instance // Create singleton instance
export const apiService = new ApiService(); export const apiService = new ApiService();
// Storage key shared with the QuickSettings "API Access" panel.
export const API_TOKEN_STORAGE_KEY = 'ruview-api-token';
// Apply a previously-saved bearer token at module load — before app init
// dispatches its first request — so a configured RUVIEW_API_TOKEN works from
// the very first /api/v1/* call. The server only ever checks the
// `Authorization: Bearer` header (see bearer_auth.rs) — this intentionally
// never puts the token in a URL query string.
try {
const storedToken = localStorage.getItem(API_TOKEN_STORAGE_KEY);
if (storedToken) apiService.setAuthToken(storedToken);
} catch { /* storage unavailable (private browsing etc.) */ }

View File

@ -4088,6 +4088,20 @@ a:focus-visible,
color: #fff; color: #fff;
} }
.qs-text-input {
padding: var(--space-6) var(--space-8);
border-radius: var(--radius-sm);
border: 1px solid var(--color-border);
background: var(--color-secondary);
color: var(--color-text);
font-size: var(--font-size-sm);
}
.qs-text-input:focus {
outline: 2px solid var(--color-primary);
outline-offset: 1px;
}
/* --- Screenshot Flash --- */ /* --- Screenshot Flash --- */
.screenshot-flash { .screenshot-flash {

View File

@ -1,6 +1,8 @@
// Quick Settings Panel - Centralized configuration for all UI features // Quick Settings Panel - Centralized configuration for all UI features
// Accessible via gear icon in header // Accessible via gear icon in header
import { apiService, API_TOKEN_STORAGE_KEY } from '../services/api.service.js';
export class QuickSettings { export class QuickSettings {
constructor(app) { constructor(app) {
this.app = app; this.app = app;
@ -9,6 +11,8 @@ export class QuickSettings {
this.isOpen = false; this.isOpen = false;
} }
// A stored token is applied at api.service.js module load (before any
// request fires) — this panel only saves/clears it.
init() { init() {
this.createButton(); this.createButton();
this.createPanel(); this.createPanel();
@ -70,6 +74,18 @@ export class QuickSettings {
<span class="qs-switch"></span> <span class="qs-switch"></span>
</label> </label>
</div> </div>
<div class="qs-section">
<div class="qs-section-title">API Access</div>
<div class="qs-row" style="flex-direction: column; align-items: stretch; gap: 6px;">
<span>Bearer token (set only if the server enforces RUVIEW_API_TOKEN)</span>
<input type="password" id="qs-api-token" class="qs-text-input" placeholder="Paste token..." autocomplete="off" style="width: 100%; box-sizing: border-box;">
<div style="display: flex; gap: 8px;">
<button class="qs-btn" id="qs-api-token-save">Save & Apply</button>
<button class="qs-btn-danger" id="qs-api-token-clear">Clear</button>
</div>
<span id="qs-api-token-status" style="font-size: 0.85em; opacity: 0.75;"></span>
</div>
</div>
<div class="qs-section"> <div class="qs-section">
<div class="qs-section-title">Data</div> <div class="qs-section-title">Data</div>
<div class="qs-row"> <div class="qs-row">
@ -112,6 +128,30 @@ export class QuickSettings {
} }
}); });
this.panel.querySelector('#qs-api-token-save').addEventListener('click', () => {
const input = this.panel.querySelector('#qs-api-token');
const status = this.panel.querySelector('#qs-api-token-status');
const token = input.value.trim();
if (!token) {
status.textContent = 'Enter a token first, or use Clear to remove one.';
return;
}
try { localStorage.setItem(API_TOKEN_STORAGE_KEY, token); } catch { /* noop */ }
apiService.setAuthToken(token);
status.textContent = 'Token saved and applied. Reloading...';
setTimeout(() => window.location.reload(), 600);
});
this.panel.querySelector('#qs-api-token-clear').addEventListener('click', () => {
const input = this.panel.querySelector('#qs-api-token');
const status = this.panel.querySelector('#qs-api-token-status');
try { localStorage.removeItem(API_TOKEN_STORAGE_KEY); } catch { /* noop */ }
apiService.setAuthToken(null);
input.value = '';
status.textContent = 'Token cleared. Reloading...';
setTimeout(() => window.location.reload(), 600);
});
this.panel.querySelector('#qs-clear-data').addEventListener('click', () => { this.panel.querySelector('#qs-clear-data').addEventListener('click', () => {
try { try {
localStorage.clear(); localStorage.clear();
@ -154,6 +194,10 @@ export class QuickSettings {
if (this.getSetting('compact')) { if (this.getSetting('compact')) {
document.body.classList.add('compact-mode'); document.body.classList.add('compact-mode');
} }
const status = this.panel.querySelector('#qs-api-token-status');
let hasToken = false;
try { hasToken = !!localStorage.getItem(API_TOKEN_STORAGE_KEY); } catch { /* noop */ }
if (status) status.textContent = hasToken ? 'A token is currently set.' : 'No token set (auth is off or unnecessary).';
} }
prefersReducedMotion() { prefersReducedMotion() {

4
v2/Cargo.lock generated
View File

@ -10961,7 +10961,7 @@ dependencies = [
[[package]] [[package]]
name = "wifi-densepose-engine" name = "wifi-densepose-engine"
version = "0.3.0" version = "0.3.1"
dependencies = [ dependencies = [
"blake3", "blake3",
"criterion", "criterion",
@ -11126,7 +11126,7 @@ dependencies = [
[[package]] [[package]]
name = "wifi-densepose-sensing-server" name = "wifi-densepose-sensing-server"
version = "0.3.3" version = "0.3.4"
dependencies = [ dependencies = [
"axum", "axum",
"chrono", "chrono",

View File

@ -1,7 +1,7 @@
[package] [package]
name = "wifi-densepose-engine" name = "wifi-densepose-engine"
description = "RuView streaming-engine integration layer — composes the ADR-135..146 building blocks into one trust-traceable pipeline cycle" description = "RuView streaming-engine integration layer — composes the ADR-135..146 building blocks into one trust-traceable pipeline cycle"
version = "0.3.0" version = "0.3.1"
edition.workspace = true edition.workspace = true
authors.workspace = true authors.workspace = true
license.workspace = true license.workspace = true

View File

@ -226,6 +226,19 @@ impl StreamingEngine {
} }
} }
/// Override the multistatic fuser's timestamp guard interval (#1049/#1057).
/// Without this, `StreamingEngine::new` always builds
/// `MultistaticFuser::with_config(MultistaticConfig::default())` — a
/// hardcoded 60 ms hard guard that ignores whatever schedule/override the
/// caller derived from `WDP_TDM_SLOTS`/`WDP_GUARD_INTERVAL_US`, so
/// WiFi/ESP-NOW-synced multi-node deployments spuriously fail governed
/// trust cycles even after widening the guard elsewhere.
///
/// Rebuilds the fuser, so call before any frames are processed.
pub fn set_multistatic_config(&mut self, cfg: MultistaticConfig) {
self.fuser = MultistaticFuser::with_config(cfg);
}
/// Activate a per-room calibration adapter (ADR-150 §3.4). From the next /// Activate a per-room calibration adapter (ADR-150 §3.4). From the next
/// cycle on, the adapter id is part of provenance `model_version` — and /// cycle on, the adapter id is part of provenance `model_version` — and
/// therefore of the witness — so the exact weights shaping inference are /// therefore of the witness — so the exact weights shaping inference are

View File

@ -1,6 +1,6 @@
[package] [package]
name = "wifi-densepose-sensing-server" name = "wifi-densepose-sensing-server"
version = "0.3.3" version = "0.3.4"
edition.workspace = true edition.workspace = true
description = "Lightweight Axum server for WiFi sensing UI with RuVector signal processing" description = "Lightweight Axum server for WiFi sensing UI with RuVector signal processing"
license.workspace = true license.workspace = true

View File

@ -34,6 +34,15 @@ pub const API_TOKEN_ENV: &str = "RUVIEW_API_TOKEN";
/// Path prefix the middleware protects when auth is enabled. /// Path prefix the middleware protects when auth is enabled.
pub const PROTECTED_PREFIX: &str = "/api/v1/"; pub const PROTECTED_PREFIX: &str = "/api/v1/";
/// `/api/v1/stream/pose` is a WebSocket upgrade endpoint reachable from
/// browser code. Unlike a plain fetch(), the browser `WebSocket` constructor
/// cannot attach an `Authorization` header to the handshake request, so this
/// path can never carry a bearer token from a stock browser client — the
/// same reasoning that already exempts `/ws/sensing` (see module docs).
/// Exempted here rather than moved out of `/api/v1/*` to avoid an API
/// surface change for existing clients.
const EXEMPT_PATHS: &[&str] = &["/api/v1/stream/pose"];
/// Cheap, cloneable handle to the configured token (or `None`). /// Cheap, cloneable handle to the configured token (or `None`).
#[derive(Debug, Clone, Default)] #[derive(Debug, Clone, Default)]
pub struct AuthState { pub struct AuthState {
@ -93,7 +102,8 @@ pub async fn require_bearer(
let Some(expected) = auth.token.clone() else { let Some(expected) = auth.token.clone() else {
return next.run(request).await; return next.run(request).await;
}; };
if !request.uri().path().starts_with(PROTECTED_PREFIX) { let path = request.uri().path();
if !path.starts_with(PROTECTED_PREFIX) || EXEMPT_PATHS.contains(&path) {
return next.run(request).await; return next.run(request).await;
} }
let supplied = request let supplied = request
@ -141,6 +151,7 @@ mod tests {
.route("/health", get(|| async { "ok" })) .route("/health", get(|| async { "ok" }))
.route("/api/v1/info", get(|| async { "ok" })) .route("/api/v1/info", get(|| async { "ok" }))
.route("/api/v1/sensitive", axum::routing::post(|| async { "ok" })) .route("/api/v1/sensitive", axum::routing::post(|| async { "ok" }))
.route("/api/v1/stream/pose", get(|| async { "ok" }))
.route("/ui/index.html", get(|| async { "<html/>" })) .route("/ui/index.html", get(|| async { "<html/>" }))
} }
@ -361,6 +372,26 @@ mod tests {
); );
} }
/// `/api/v1/stream/pose` is a WebSocket upgrade the browser `WebSocket`
/// constructor drives directly — it cannot attach an `Authorization`
/// header, so this path must stay reachable even with auth ON (mirrors
/// the existing `/ws/sensing` exemption, just inside the `/api/v1/*`
/// prefix this time).
#[tokio::test]
async fn enabled_exempts_pose_stream_websocket() {
let r = wrap(AuthState::from_token("s3cr3t"));
assert_eq!(
status(r.clone(), "GET", "/api/v1/stream/pose", None).await,
StatusCode::OK,
"pose stream WS must stay reachable without a bearer token"
);
// The exemption is narrow: it must not leak to other /api/v1/* paths.
assert_eq!(
status(r, "GET", "/api/v1/info", None).await,
StatusCode::UNAUTHORIZED
);
}
#[test] #[test]
fn ct_eq_basics() { fn ct_eq_basics() {
assert!(ct_eq(b"abc", b"abc")); assert!(ct_eq(b"abc", b"abc"));

View File

@ -38,6 +38,7 @@ use wifi_densepose_bfld::{PrivacyClass, PrivacyMode};
use wifi_densepose_engine::{AdapterInfo, EngineError, StreamingEngine, TrustedOutput}; use wifi_densepose_engine::{AdapterInfo, EngineError, StreamingEngine, TrustedOutput};
use wifi_densepose_geo::types::GeoRegistration; use wifi_densepose_geo::types::GeoRegistration;
use wifi_densepose_signal::ruvsense::fusion_quality::CalibrationId; use wifi_densepose_signal::ruvsense::fusion_quality::CalibrationId;
use wifi_densepose_signal::ruvsense::multistatic::MultistaticConfig;
use wifi_densepose_worldgraph::WorldId; use wifi_densepose_worldgraph::WorldId;
use super::multistatic_bridge::node_frames_from_states; use super::multistatic_bridge::node_frames_from_states;
@ -79,8 +80,24 @@ pub struct EngineBridge {
impl EngineBridge { impl EngineBridge {
/// Build a bridge for one installation. `room_area_id`/`room_name` name the /// Build a bridge for one installation. `room_area_id`/`room_name` name the
/// observation scope; `mode` is the starting privacy mode. /// observation scope; `mode` is the starting privacy mode.
pub fn new(mode: PrivacyMode, model_version: u16, room_area_id: &str, room_name: &str) -> Self { ///
/// `multistatic_cfg` is `None` on the pure default (60 ms/20 ms guard);
/// callers that derive a config from `WDP_TDM_SLOTS`/`WDP_GUARD_INTERVAL_US`
/// (see `main.rs::multistatic_guard_config_from_env`) should pass it here
/// too — otherwise the governed trust cycle silently keeps using the
/// hardcoded default even though the sibling `multistatic_fuser` field on
/// `AppState` picked up the override (#1049/#1057).
pub fn new(
mode: PrivacyMode,
model_version: u16,
room_area_id: &str,
room_name: &str,
multistatic_cfg: Option<MultistaticConfig>,
) -> Self {
let mut engine = StreamingEngine::new(mode, model_version, GeoRegistration::default()); let mut engine = StreamingEngine::new(mode, model_version, GeoRegistration::default());
if let Some(cfg) = multistatic_cfg {
engine.set_multistatic_config(cfg);
}
let room = engine.add_room(room_area_id, room_name); let room = engine.add_room(room_area_id, room_name);
Self { Self {
engine, engine,
@ -267,7 +284,7 @@ mod tests {
#[test] #[test]
fn empty_states_produce_no_belief() { fn empty_states_produce_no_belief() {
let mut bridge = EngineBridge::new(PrivacyMode::PrivateHome, 1, "living_room", "Living Room"); let mut bridge = EngineBridge::new(PrivacyMode::PrivateHome, 1, "living_room", "Living Room", None);
let out = bridge.process_cycle_from_states(&HashMap::new(), 1_000); let out = bridge.process_cycle_from_states(&HashMap::new(), 1_000);
assert!(out.is_none()); assert!(out.is_none());
// No belief published, no sensor wired. // No belief published, no sensor wired.
@ -276,7 +293,7 @@ mod tests {
#[test] #[test]
fn live_cycle_produces_witnessed_belief_with_provenance() { fn live_cycle_produces_witnessed_belief_with_provenance() {
let mut bridge = EngineBridge::new(PrivacyMode::PrivateHome, 1, "living_room", "Living Room"); let mut bridge = EngineBridge::new(PrivacyMode::PrivateHome, 1, "living_room", "Living Room", None);
let states = two_node_states(); let states = two_node_states();
let out = bridge let out = bridge
.process_cycle_from_states(&states, 10_000) .process_cycle_from_states(&states, 10_000)
@ -299,7 +316,7 @@ mod tests {
fn live_path_is_deterministic() { fn live_path_is_deterministic() {
let states = two_node_states_fixed(); let states = two_node_states_fixed();
let run = || { let run = || {
let mut b = EngineBridge::new(PrivacyMode::PrivateHome, 1, "r", "R"); let mut b = EngineBridge::new(PrivacyMode::PrivateHome, 1, "r", "R", None);
b.process_cycle_from_states(&states, 5_000).unwrap().unwrap() b.process_cycle_from_states(&states, 5_000).unwrap().unwrap()
}; };
let a = run(); let a = run();
@ -325,7 +342,7 @@ mod tests {
#[test] #[test]
fn nodes_registered_once_across_cycles() { fn nodes_registered_once_across_cycles() {
let mut bridge = EngineBridge::new(PrivacyMode::PrivateHome, 1, "r", "R"); let mut bridge = EngineBridge::new(PrivacyMode::PrivateHome, 1, "r", "R", None);
let states = two_node_states(); let states = two_node_states();
bridge.process_cycle_from_states(&states, 1_000); bridge.process_cycle_from_states(&states, 1_000);
bridge.process_cycle_from_states(&states, 2_000); bridge.process_cycle_from_states(&states, 2_000);
@ -336,7 +353,7 @@ mod tests {
#[test] #[test]
fn retention_bounds_world_graph_growth() { fn retention_bounds_world_graph_growth() {
let mut bridge = EngineBridge::new(PrivacyMode::PrivateHome, 1, "r", "R"); let mut bridge = EngineBridge::new(PrivacyMode::PrivateHome, 1, "r", "R", None);
bridge.set_semantic_retention(5); bridge.set_semantic_retention(5);
let states = two_node_states(); let states = two_node_states();
for i in 0..20i64 { for i in 0..20i64 {
@ -349,7 +366,7 @@ mod tests {
#[test] #[test]
fn adapter_identity_flows_into_live_witness() { fn adapter_identity_flows_into_live_witness() {
let states = two_node_states_fixed(); let states = two_node_states_fixed();
let mut bridge = EngineBridge::new(PrivacyMode::PrivateHome, 1, "r", "R"); let mut bridge = EngineBridge::new(PrivacyMode::PrivateHome, 1, "r", "R", None);
let base = bridge let base = bridge
.process_cycle_from_states(&states, 1_000) .process_cycle_from_states(&states, 1_000)
.unwrap() .unwrap()
@ -381,7 +398,7 @@ mod tests {
/// the status endpoint, with a zero error count on the happy path. /// the status endpoint, with a zero error count on the happy path.
#[test] #[test]
fn observe_cycle_records_trust_state() { fn observe_cycle_records_trust_state() {
let mut bridge = EngineBridge::new(PrivacyMode::PrivateHome, 1, "r", "R"); let mut bridge = EngineBridge::new(PrivacyMode::PrivateHome, 1, "r", "R", None);
assert!(bridge.last_trust_witness().is_none()); assert!(bridge.last_trust_witness().is_none());
assert_eq!(bridge.effective_class(), None); assert_eq!(bridge.effective_class(), None);
@ -430,7 +447,7 @@ mod tests {
m m
} }
let mut bridge = EngineBridge::new(PrivacyMode::PrivateHome, 1, "r", "R"); let mut bridge = EngineBridge::new(PrivacyMode::PrivateHome, 1, "r", "R", None);
let mismatched = mismatched_states(); let mismatched = mismatched_states();
assert!(bridge.observe_cycle(&mismatched, 1_000).is_none()); assert!(bridge.observe_cycle(&mismatched, 1_000).is_none());
@ -461,7 +478,7 @@ mod tests {
/// mapping bfld's privacy gate applies at `Restricted`. /// mapping bfld's privacy gate applies at `Restricted`.
#[test] #[test]
fn restricted_class_suppresses_raw_outputs() { fn restricted_class_suppresses_raw_outputs() {
let mut bridge = EngineBridge::new(PrivacyMode::PrivateHome, 1, "r", "R"); let mut bridge = EngineBridge::new(PrivacyMode::PrivateHome, 1, "r", "R", None);
bridge.set_privacy_mode(PrivacyMode::StrictNoIdentity); // base = Restricted bridge.set_privacy_mode(PrivacyMode::StrictNoIdentity); // base = Restricted
bridge bridge
.observe_cycle(&two_node_states(), 1_000) .observe_cycle(&two_node_states(), 1_000)
@ -472,7 +489,7 @@ mod tests {
#[test] #[test]
fn identity_strict_mode_is_carried_into_provenance() { fn identity_strict_mode_is_carried_into_provenance() {
let mut bridge = EngineBridge::new(PrivacyMode::PrivateHome, 1, "r", "R"); let mut bridge = EngineBridge::new(PrivacyMode::PrivateHome, 1, "r", "R", None);
bridge.set_privacy_mode(PrivacyMode::StrictNoIdentity); bridge.set_privacy_mode(PrivacyMode::StrictNoIdentity);
let out = bridge let out = bridge
.process_cycle_from_states(&two_node_states(), 7_000) .process_cycle_from_states(&two_node_states(), 7_000)

View File

@ -7514,6 +7514,11 @@ async fn main() {
let field_surface: rufield_surface::FieldState = let field_surface: rufield_surface::FieldState =
Arc::new(RwLock::new(rufield_surface::FieldSurface::from_env())); Arc::new(RwLock::new(rufield_surface::FieldSurface::from_env()));
// Populated inside the `multistatic_fuser` field initializer below, then
// threaded into `engine_bridge` so both fusion paths honor the same
// WDP_TDM_SLOTS/WDP_GUARD_INTERVAL_US-derived guard (#1049/#1057).
let mut engine_bridge_multistatic_cfg: Option<MultistaticConfig> = None;
let state: SharedState = Arc::new(RwLock::new(AppStateInner { let state: SharedState = Arc::new(RwLock::new(AppStateInner {
latest_update: None, latest_update: None,
rssi_history: VecDeque::new(), rssi_history: VecDeque::new(),
@ -7588,7 +7593,7 @@ async fn main() {
); );
let mut fuser = MultistaticFuser::with_config(MultistaticConfig { let mut fuser = MultistaticFuser::with_config(MultistaticConfig {
min_nodes: 1, // single-node passthrough min_nodes: 1, // single-node passthrough
..cfg ..cfg.clone()
}); });
if let Some(ref pos_str) = args.node_positions { if let Some(ref pos_str) = args.node_positions {
let positions = field_bridge::parse_node_positions(pos_str); let positions = field_bridge::parse_node_positions(pos_str);
@ -7600,6 +7605,10 @@ async fn main() {
fuser.set_node_positions(positions); fuser.set_node_positions(positions);
} }
} }
engine_bridge_multistatic_cfg = Some(MultistaticConfig {
min_nodes: 1,
..cfg
});
fuser fuser
}, },
engine_bridge: engine_bridge::EngineBridge::new( engine_bridge: engine_bridge::EngineBridge::new(
@ -7607,6 +7616,7 @@ async fn main() {
1, 1,
"default", "default",
"Default Room", "Default Room",
engine_bridge_multistatic_cfg,
), ),
field_model: if args.calibrate { field_model: if args.calibrate {
info!("Field model calibration enabled — room should be empty during startup"); info!("Field model calibration enabled — room should be empty during startup");