Skip to content

Add permissive CORS to the gateway's read API - #3

Open
fallen-pc wants to merge 6 commits into
ruvnet:mainfrom
fallen-pc:feat/dashboard-cors
Open

Add permissive CORS to the gateway's read API#3
fallen-pc wants to merge 6 commits into
ruvnet:mainfrom
fallen-pc:feat/dashboard-cors

Conversation

@fallen-pc

Copy link
Copy Markdown

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/stats from a browser tab. RuView and RuCelium run on different HTTP ports, so the dashboard's fetch() 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 CorsLayer to the router (api::cors_layer), applied after .with_state(...).

Scope is deliberately narrow:

  • Only GET is allowed — the CORS allow-list does not cover POST, so a cross-origin browser page cannot trigger the admin/command endpoints via CORS. A preflight OPTIONS for a POST route comes back without POST in access-control-allow-methods.
  • No new exposure over the current v0.1 posture: every route in this gateway is already unauthenticated (see the module's own security doc comment) — same-origin policy was never the thing standing between this JSON and an arbitrary reader, it only ever stopped an arbitrary page from reading it inside a browser tab.
  • Revisit when the admin endpoints gain real authentication (tracked as v0.1 follow-up work already noted in the module docs).

Testing

New crates/rucelium-gateway/tests/cors.rs (2 tests):

  • a GET response carries access-control-allow-origin: *
  • a preflight OPTIONS for /api/admin/command does not allow POST

Both pass, plus the full rucelium-gateway package test suite (60 tests across unit + cors + e2e + push_federation + restart) passes clean on this branch.

🤖 Generated with Claude Code

fallen-pc and others added 4 commits August 27, 2026 14:02
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 ruvnet left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

fallen-pc and others added 2 commits August 28, 2026 20:56
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>
@fallen-pc

Copy link
Copy Markdown
Author

Fixed both points on the updated head (e2bbca4):

  1. /api/rf-context now actually exists on this branch. Folded in the RF-context bridge (previously only on the separate rf-context-ingest PR) so this head is self-sufficient — the dashboard's contract can be satisfied against this PR alone, not two that only compose when merged locally.

  2. CORS is scoped and origin-gated, not wildcard-on-everything. CorsLayer::allow_origin(Any) is gone. CORS now applies to exactly /api/rf-context and /api/stats via a nested sub-router + route_layer, gated by a new repeatable --dashboard-origin <origin> flag. Default is an empty allowlist — no Access-Control-Allow-Origin header is emitted at all unless an operator opts a specific origin in. Every other route (admin, federation, observations, events) never gets a CORS layer, regardless of configuration.

New tests (tests/cors.rs, 6 cases) cover exactly the stated acceptance criteria:

  • unknown origin → no ACAO
  • configured origin → ACAO on the two dashboard routes only, nowhere else
  • no origin configured → no ACAO at all (default posture unchanged from pre-dashboard)
  • POST stays disallowed regardless of origin
  • /api/rf-context's response is asserted against the literal schema pollRuCelium() reads (upstream, stats, room_state_inferences), not just unit-tested in isolation

Live-verified against a real single gateway binary built from this head, serving a real RuView instance, through an actual browser tab hitting ui/pipeline.html — not a locally-merged demo.

Full package suite: 75 tests pass on this head.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants