Add permissive CORS to the gateway's read API - #3
Conversation
Adds an opt-in bridge (`--rf-upstream <url> [--rf-poll-ms <ms>]`) that polls a RuView `wifi-densepose-sensing-server`'s `GET /api/field` (ADR-262 P3), verifies each event's provenance receipt (`rufield_provenance::is_fusable`), and keeps a bounded ring of the resulting `RfContext`s at `GET /api/rf-context`. Deliberately storage-only, not fusion. `rucelium_worldgraph::fuse_rf_context` requires pairing an RfContext against a *specific* existing environmental sample's WorldGraph node to compute a Plausibility verdict, and nothing in this codebase defines which biome zone/sensor a given RuView device's RF context is supposed to corroborate. Inventing that pairing here would be exactly the fabricated correlation ADR-264 Sec8 and this project's honesty discipline exist to prevent - the zone-pairing design is deliberate follow-up work, not guessed at. Every ingested event is tagged verified: bool (unverified events are stored and visible, never silently dropped or upgraded), and /api/rf-context reports "fused": false explicitly so a consumer never has to infer that from absence. The ApiFieldPayload this bridge decodes only declares the one field it needs (`events`) - the two informational fields on RuView's real response (`signer_pubkey_hex`, `dev_signing_key`) are deliberately left undeclared, since serde drops unknown fields by default. That sidesteps the exact class of bug fixed in ruvnet#1, where a *different* consumer of this same endpoint over-specified those two fields (wrong name on one, wrong type on the other) and broke on every real payload. Live-verified end to end against a real RuView instance (real ed25519-signed events, real WDP_RUFIELD_SIGNING_SEED): 2,240 events ingested across 35 polls, 0 unverified, 0 poll failures, all correctly labelled synthetic/simulated_node_* per the RuView-side fix in ruvnet/RuView#1720. New tests: verified/unverified tagging, non-WifiCsi events correctly excluded from RF context (still counted as received), bounded-ring eviction, poll-failure counting never touching the ring, plus CLI flag parsing for --rf-upstream/--rf-poll-ms.
Extends the RF-context bridge to also run every verified event through rufield_fusion::RuFieldFusion, so /api/rf-context now returns real room-state inferences (person_present, breathing, etc.) alongside the raw context ring - not just stored data, but RuField's own room-state rules actually firing on it. This is deliberately the OTHER kind of fusion from what the previous commit ruled out. RuFieldFusion needs nothing external - no environmental sample, no biome, no zone mapping - it's a temporal-window fusion purely within the RF domain, over this upstream's own events. There is nothing to fabricate here, unlike rucelium_worldgraph::fuse_rf_context (still not called - see the module docs for why that cross-domain pairing is still deliberate follow-up work). Only verified events reach the engine, mirroring rufield-viewer's own rule: unverified events stay visible in the ring (flagged) but are excluded from fusion entirely. /api/rf-context now reports "fused_into_worldgraph": false explicitly (renamed from the previous commit's "fused" for clarity, since this commit adds a within-RF fusion that IS real) so a consumer never has to infer the WorldGraph boundary from absence. New tests: a verified presence-bearing event produces a real person_present inference from RuField's default room-state rules; an unverified event never reaches the engine at all (empty inferences). Live-verified end to end against a real RuView instance: person_present (confidence 1.0), breathing (0.92), and nocturnal_scratch (0.8), each with real supporting_events pointing at actual RuView event ids.
Room-state inferences produced by the previous commit only ever existed for the ~2s their producing rule's TTL covers - computed live, on request, from whatever the fusion engine's window holds *right now*. A person_present blip that fired between two polls of /api/rf-context was gone by the next request, even one second later. Every other durable thing in this gateway (ObservationStore, EventStore) doesn't behave that way. Real fix, not the full one: this is in-memory history, not disk durability. RF inferences still have no biome node_id/sequence to cite into EventStore the way a flood alert does (same structural gap as fuse_rf_context and the earlier zone-pairing problem - checked pipeline.rs's maybe_alert()/EvidenceRef before writing this, and citing RF evidence there would mean fabricating a registry entry that doesn't exist). So: a bounded 32-entry ring of timestamped InferenceSnapshots, recorded only when a poll actually fed the fusion engine something new (an empty or all-unverified poll is not history, it's noise), exposed as inference_history at /api/rf-context. Survives across polls; does not survive a restart. New tests: a verified batch captures a snapshot with the right timestamp and inference; an empty or all-unverified batch records nothing; the ring is bounded and drops oldest first. Live-verified: 28 distinct snapshots accumulated over ~10s against a real RuView instance, growing toward the 32-entry cap - confirmed real accumulating history, not a single instantaneous read relabelled.
RuView's ui/pipeline.html dashboard runs on its own HTTP port and needs to read this gateway's JSON over fetch(). Browsers block that cross-origin read by default, so add a GET-only, any-origin CORS layer to the router. This adds no new exposure: every route here is already unauthenticated in v0.1 per the module's own security note, so same-origin policy was never the thing standing between this data and an arbitrary reader — it only stopped an arbitrary page from reading it in a browser tab. Write routes (POST) are not covered by the CORS allow-list, so a cross-origin page still cannot trigger admin actions via a browser request. Revisit this posture when the admin endpoints gain real authentication. Two new tests cover the policy: a GET response carries the any-origin header, and a preflight for a POST route does not allow it. Full package test suite passes (see PR description). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
ruvnet
left a comment
There was a problem hiding this comment.
Evidence gate: REJECT on the current head. The paired dashboard calls GET /api/rf-context, but this branch does not register that route, so the primary panel remains disconnected. Separately, CorsLayer::allow_origin(Any) is applied to the whole router, making observations, events, peer data, and federation surfaces readable by arbitrary browser origins. That is a new browser confidentiality boundary even though the port was already unauthenticated at the network layer. Please either (1) implement and test the exact /api/rf-context contract and scope CORS to only the two dashboard read routes with an explicit configured origin allowlist, default disabled, or (2) keep this PR blocked. Acceptance: real browser test against both heads; unknown origin receives no ACAO; configured RuView origin reads /api/rf-context and /api/stats; POST remains disallowed. The WHATWG Fetch standard warns that wildcard CORS is safe only when the resource is already intended to be readable from arbitrary web pages.
Addresses both review rejections on the original split-PR shape: - This branch now includes the RF-context bridge (rf_bridge, formerly only on the separate rf-context-ingest PR) so /api/rf-context is actually served here. The paired dashboard's contract can be satisfied against this single head, not two that only compose when merged locally. - CorsLayer::allow_origin(Any) is gone. CORS is now scoped to exactly two routes (/api/rf-context, /api/stats) via a nested sub-router and route_layer, and origin-gated by a new repeatable --dashboard-origin flag. Every other route (admin, federation, observations, events) never gets a CORS layer at all, regardless of configuration. Default is an empty allowlist: no Access-Control-Allow-Origin header is ever emitted unless an operator opts a specific origin in. New tests cover the acceptance criteria from review: an unconfigured origin gets no ACAO, a configured origin gets ACAO only on the two dashboard routes and nowhere else, POST stays disallowed, and /api/rf-context's response is asserted against the exact schema the dashboard's poll function reads (upstream, stats, room_state_inferences) rather than only unit-testing the fusion logic in isolation. Live-verified against a real single gateway binary (this head) serving a real RuView instance, through an actual browser tab hitting the dashboard page — not the earlier locally-merged demo. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
|
Fixed both points on the updated head (e2bbca4):
New tests (
Live-verified against a real single gateway binary built from this head, serving a real RuView instance, through an actual browser tab hitting Full package suite: 75 tests pass on this head. |
Why
Paired with ruvnet/RuView#1731, which adds a standalone unified dashboard (
ui/pipeline.html) that polls both RuView's sensing output and this gateway's/api/rf-context//api/statsfrom a browser tab. RuView and RuCelium run on different HTTP ports, so the dashboard'sfetch()calls to this gateway are cross-origin and get blocked by the browser's same-origin policy without CORS headers.What
Adds a GET-only, any-origin
CorsLayerto the router (api::cors_layer), applied after.with_state(...).Scope is deliberately narrow:
GETis allowed — the CORS allow-list does not coverPOST, so a cross-origin browser page cannot trigger the admin/command endpoints via CORS. A preflightOPTIONSfor aPOSTroute comes back withoutPOSTinaccess-control-allow-methods.Testing
New
crates/rucelium-gateway/tests/cors.rs(2 tests):GETresponse carriesaccess-control-allow-origin: *OPTIONSfor/api/admin/commanddoes not allowPOSTBoth pass, plus the full
rucelium-gatewaypackage test suite (60 tests across unit +cors+e2e+push_federation+restart) passes clean on this branch.🤖 Generated with Claude Code