mirror of
https://github.com/ruvnet/RuView.git
synced 2026-08-26 02:04:55 +00:00
feat: add governed Cognitum Spaces activation (#1631)
Add explicit spaces:read consent, a bounded read client and CLI surface, and ADR-325's privacy, memory, and action-governance contract. Closes #1630.
This commit is contained in:
@@ -0,0 +1,422 @@
|
||||
# ADR-325: Cognitum Spaces activation and governed spatial exchange
|
||||
|
||||
- **Status**: Accepted — read path implemented; write path remains policy-gated
|
||||
- **Date**: 2026-08-17
|
||||
- **Deciders**: ruv
|
||||
- **Tags**: cognitum-spaces, oauth, spatial-state, privacy, ruvector, policy, autogenous
|
||||
- **Relates to**: ADR-271, ADR-277, ADR-304, ADR-306, ADR-312, ADR-318, ADR-319, ADR-321; Cognitum API ADR-094; Autogenous ADR-402
|
||||
|
||||
## Context
|
||||
|
||||
RuView produces camera-free RF perception locally. Cognitum Spaces provides a
|
||||
tenant-scoped cloud projection of physical places. Autogenous ADR-402 proposes
|
||||
using that projection as a spatial-intelligence input for agent coordination.
|
||||
The useful product is not another sensor dashboard: it is a governed chain from
|
||||
local perception to spatial state, persistent memory, explanation, and action.
|
||||
|
||||
Four product pillars define the requested integration:
|
||||
|
||||
1. **Spatial state** — sites, buildings, floors, rooms/spaces, zones, entities,
|
||||
semantic events, and alerts.
|
||||
2. **RuView perception** — camera-free sensing is normalized locally before any
|
||||
permitted P2/P3 semantic event synchronizes.
|
||||
3. **Persistent memory** — RuVector grounds anomaly explanations in
|
||||
tenant-scoped spatial history.
|
||||
4. **Governed action** — agents observe or recommend by default; consequential
|
||||
execution requires explicit policy authorization.
|
||||
|
||||
The live API audit on 2026-08-17 established the current production boundary:
|
||||
|
||||
- `GET https://api.cognitum.one/v1/spaces` exists and returns a bounded list;
|
||||
- an unauthenticated request is rejected;
|
||||
- the current account has no paired sites, so the authenticated result is an
|
||||
empty list rather than fabricated sample state;
|
||||
- the projection declares HomeCore Edge authoritative and excludes raw CSI,
|
||||
CIR, RF tensors, recordings, pose frames, vital waveforms, and identity
|
||||
observations;
|
||||
- the first deployed Function revision accepted only legacy `cog_` API keys;
|
||||
- the gateway was configured to authenticate private Function hops, but the
|
||||
direct Function endpoint was still publicly invokable; that bypass has now
|
||||
been closed and the exact gateway runtime service account is the only
|
||||
invoker;
|
||||
- OAuth protected-resource metadata and a RuView-scoped OAuth accept path were
|
||||
absent.
|
||||
|
||||
The Autogenous review at commit
|
||||
`f7fa308b261bac89a8909edae8a3fdbbfb8ce66c` found additional integration risks:
|
||||
|
||||
- its Spaces client only listed spaces; no governed ingest contract existed;
|
||||
- it trusted a loose TypeScript cast, with no response-size, timeout, redirect,
|
||||
or strict semantic-boundary validation;
|
||||
- its observation conversion dropped tenant/message/sequence identity;
|
||||
- missing confidence became zero but could still enter fusion;
|
||||
- provenance could be substituted for calibration identity;
|
||||
- a Spaces-derived belief could be converted back into an observation and
|
||||
counted as independent corroboration, laundering one source into two;
|
||||
- its API-key exchange returns a `cognitum-cli` OAuth token, but the live Spaces
|
||||
endpoint accepted only a `cog_` key. Calling this “OAuth Spaces access” was a
|
||||
contract mismatch.
|
||||
|
||||
## Decision
|
||||
|
||||
Adopt a one-way-by-default, typed spatial exchange with separate activation,
|
||||
data, memory, and action authorities.
|
||||
|
||||
```text
|
||||
RuView RF capture (P0/P1, local)
|
||||
-> calibrated/OOD-gated semantic observation
|
||||
-> ontology + evidence + witness envelope (P2/P3)
|
||||
-> HomeCore authoritative edge state
|
||||
-> Cognitum Spaces tenant/workspace projection
|
||||
-> RuView bounded read client / Autogenous spatial context
|
||||
-> RuVector tenant-scoped memory and explanation
|
||||
-> recommendation
|
||||
-> ruvview-policy authorization + approval + receipt
|
||||
-> optional consequential action
|
||||
```
|
||||
|
||||
Cloud state is a projection of edge state, not a second sensor and not an
|
||||
independent corroborating modality.
|
||||
|
||||
### 1. Activation and data-plane credentials are distinct
|
||||
|
||||
RuView uses Cognitum's existing Authorization Code + PKCE flow with the public
|
||||
`ruview` client. A user explicitly requests `spaces:read` with
|
||||
`wifi-densepose login --spaces`. The authorization-server registration is a
|
||||
ceiling; ordinary sensing login does not silently gain cloud access.
|
||||
|
||||
The Spaces resource server accepts either:
|
||||
|
||||
- a legacy API key carrying `spaces:read` (or the migration-compatible
|
||||
predecessor `devices:manage`); or
|
||||
- a Cognitum OAuth access token that passes every condition below.
|
||||
|
||||
OAuth acceptance is conjunctive:
|
||||
|
||||
| Check | Required value |
|
||||
|---|---|
|
||||
| Signature | ES256 against `https://auth.cognitum.one/.well-known/jwks.json` |
|
||||
| Issuer | exact `https://auth.cognitum.one` |
|
||||
| Audience | exact `ruview` |
|
||||
| Client claim | exact `ruview` |
|
||||
| Token type | ordinary `access`; setup/workload tokens denied |
|
||||
| Lifetime | current `exp`/`nbf`, five-second clock tolerance only |
|
||||
| Scope | exact token `spaces:read` member |
|
||||
| Tenant binding | valid non-empty UUID `org_id` and `workspace_id` |
|
||||
|
||||
An API key is not called OAuth. An OAuth token is not stored in
|
||||
`COGNITUM_SPACES_API`. The compatibility environment variable contains an API
|
||||
key only and is never printed, logged, or committed.
|
||||
|
||||
OAuth consent grants identity-bound read access. It does **not** grant device
|
||||
pairing, data publication, deployment, billing, spending, leases, learning
|
||||
promotion, automation installation, commands, or actuator authority.
|
||||
|
||||
### 2. The gateway owns the private credential relay
|
||||
|
||||
The public gateway strips inbound `X-Cognitum-User-Authorization` and
|
||||
`X-Serverless-Authorization`. For a locked Function upstream it then:
|
||||
|
||||
1. retains a legacy `cog_` credential in `X-API-Key`, or, for the exact Spaces
|
||||
route only, retains a non-key bearer in a gateway-owned internal header;
|
||||
2. replaces `Authorization` with the gateway's Google invoker ID token;
|
||||
3. fails closed with `503` if it cannot mint that hop identity;
|
||||
4. forwards only to the configured Function origin.
|
||||
|
||||
The Function's Cloud Run invoker check is enabled. `allUsers` has no invoker
|
||||
binding; only the exact `apigateway-sa` service account may invoke it. This is
|
||||
required because otherwise a caller could bypass Cloud Armor and spoof an
|
||||
internal relay header.
|
||||
|
||||
The API publishes RFC 9728 protected-resource metadata naming the authorization
|
||||
server and `spaces:read` scope. Discovery describes capability; it does not
|
||||
grant it.
|
||||
|
||||
### 3. Tenant isolation is part of authentication
|
||||
|
||||
Legacy API-key documents are queried by their existing owner-bound `tenantId`.
|
||||
OAuth requests are conjunctively queried by both signed `org_id` and
|
||||
`workspace_id` using stored `tenantId` and `workspaceId` fields. The public
|
||||
tenant identifier is projected from signed `org_id`. A request cannot supply
|
||||
either selector in a query string.
|
||||
|
||||
No cross-tenant aggregation exists on this path. Pagination, search, memory,
|
||||
and event endpoints added later must carry the same authoritative principal;
|
||||
client-provided tenant filters may only narrow within it, never replace it.
|
||||
|
||||
### 4. Spatial model and ownership
|
||||
|
||||
The canonical RuView vocabulary remains ADR-306:
|
||||
|
||||
```text
|
||||
Site -> Building -> Floor -> Space -> Zone
|
||||
-> Sensor / Person / Object / Track
|
||||
-> Observation -> Event -> Alert
|
||||
```
|
||||
|
||||
Cognitum may call a bounded room a “space”; RuView does not create a second
|
||||
room type. Stable external IDs are namespaced and validated before entering the
|
||||
ontology. HomeCore remains authoritative for local registry state and local
|
||||
automation. Cognitum owns tenant/workspace projection and activation. RuVector
|
||||
owns indexed spatial history, not tenancy or authorization.
|
||||
|
||||
The current live endpoint exposes the first `Space` slice only. Sites, floors,
|
||||
zones, entities, events, and alerts are contract milestones, not inferred from
|
||||
missing fields. A client must represent absence as unknown/unavailable and must
|
||||
not fabricate parents, coordinates, people, alerts, or provenance.
|
||||
|
||||
### 5. Privacy boundary and synchronization eligibility
|
||||
|
||||
Only allow-listed P2/P3 semantic projections may cross the cloud boundary.
|
||||
|
||||
| Class | Examples | Cloud default |
|
||||
|---|---|---|
|
||||
| P0 | raw CSI, CIR, RF tensors, packet captures | prohibited |
|
||||
| P1 | pose frames, vital waveforms, identity observations, recordings | prohibited |
|
||||
| P2 | occupancy count, bounded activity/fall possibility, anomaly score | permitted when policy allows |
|
||||
| P3 | versions, connection health, signed capability metadata | permitted |
|
||||
|
||||
The client independently rejects forbidden raw-field names anywhere in the
|
||||
response. This is defense in depth, not a substitute for server-side
|
||||
projection. It also enforces HTTPS except for loopback tests, refuses redirects,
|
||||
uses bounded connect/total timeouts, caps responses at 1 MiB, caps the list at
|
||||
100 spaces, bounds nesting/arrays/strings, validates confidence, and rejects
|
||||
non-P2/P3 space records.
|
||||
|
||||
Cloud-bound envelopes must preserve, when available:
|
||||
|
||||
- tenant/workspace/site/space/device identity;
|
||||
- `messageId` and monotonic `eventSequence`;
|
||||
- `observedAt`, `expiresAt`, freshness, and connection state;
|
||||
- privacy class and semantic schema version;
|
||||
- calibrated confidence and explicit uncertainty/abstention;
|
||||
- model, HomeCore, hardware-manifest, calibration, evidence, and witness
|
||||
provenance.
|
||||
|
||||
Provenance is never used as a calibration identifier. Missing confidence,
|
||||
calibration, timestamp, or tenant identity stays missing and cannot satisfy an
|
||||
admission rule.
|
||||
|
||||
### 6. No feedback laundering or false corroboration
|
||||
|
||||
A Spaces record derived from RuView evidence carries derivation lineage. If it
|
||||
returns to RuView or Autogenous, it is a **projection/recollection** of that
|
||||
lineage, not a new observation. It cannot:
|
||||
|
||||
- increment corroborating-sensor count;
|
||||
- raise evidence level;
|
||||
- be fused as an independent modality;
|
||||
- reset freshness to retrieval time;
|
||||
- erase abstention, contradiction, or uncertainty;
|
||||
- generate a second belief that cites the first as support.
|
||||
|
||||
Deduplication keys include tenant, source/witness identity, message ID, and
|
||||
sequence. Cycles are detected and rejected. Independent corroboration requires
|
||||
a distinct authenticated source and evidence chain.
|
||||
|
||||
### 7. Persistent memory is tenant-scoped and explanation-oriented
|
||||
|
||||
RuVector indexes accepted semantic state under at least:
|
||||
|
||||
```text
|
||||
(tenant_id, workspace_id, site_id, space_id, schema_version, time_bucket)
|
||||
```
|
||||
|
||||
It stores bounded semantic features, uncertainty, evidence references, and
|
||||
witness digests. It does not store OAuth/API credentials or prohibited raw
|
||||
payloads. Retrieval always applies the authenticated tenant/workspace filter
|
||||
before similarity ranking.
|
||||
|
||||
An anomaly explanation names:
|
||||
|
||||
- the current semantic state and its uncertainty;
|
||||
- the relevant learned baseline/window from ADR-312;
|
||||
- comparable tenant-local history;
|
||||
- the measured deviation and contradictory evidence;
|
||||
- the provenance/witness chain;
|
||||
- the evidence label (`MEASURED`, `SYNTHETIC`, or `CLAIMED`).
|
||||
|
||||
Memory supplies context, not permission. A historically common action is not
|
||||
automatically authorized.
|
||||
|
||||
### 8. Agents observe and recommend; policy authorizes action
|
||||
|
||||
Autogenous and other agents receive read-only spatial context by default. Their
|
||||
normal outputs are observations, explanations, proposals, and recommendations.
|
||||
|
||||
Any consequential action must cross the ADR-321 `ruview-policy` gate with:
|
||||
|
||||
- an exact action class and target;
|
||||
- a fresh capability certificate;
|
||||
- KNOWN/DEGRADED/UNKNOWN domain state;
|
||||
- bounded uncertainty and sufficient evidence;
|
||||
- tenant/workspace authorization;
|
||||
- expiry, nonce, idempotency key, and replay protection;
|
||||
- required human/policy approval;
|
||||
- a terminal witness receipt for allow or deny.
|
||||
|
||||
Missing policy, unknown action class, stale state, incomplete provenance, or an
|
||||
unavailable approval service denies. OAuth `spaces:read` can never authorize an
|
||||
action. This ADR adds no actuator method to the Spaces client.
|
||||
|
||||
## Implementation
|
||||
|
||||
### RuView
|
||||
|
||||
- `ruview-cognitum-spaces` is a reusable, read-only client with typed/redacted
|
||||
credentials and a bounded response decoder.
|
||||
- `wifi-densepose login --spaces` explicitly requests `spaces:read` through the
|
||||
existing PKCE flow and credential store.
|
||||
- `wifi-densepose spaces` refreshes OAuth through the existing single-flight,
|
||||
persist-before-return mechanism, verifies that the stored grant contains
|
||||
`spaces:read`, and lists validated state. `COGNITUM_SPACES_API` remains an
|
||||
explicit compatibility path.
|
||||
|
||||
### Cognitum Identity
|
||||
|
||||
- the `ruview` public client allow-list includes `spaces:read`;
|
||||
- RFC 8414 metadata advertises it;
|
||||
- refresh preserves the originally granted scope;
|
||||
- no new client secret or password grant is introduced.
|
||||
|
||||
### Cognitum API
|
||||
|
||||
- the gateway preserves caller OAuth through an internal, spoof-resistant
|
||||
relay while authenticating the private Function hop;
|
||||
- Spaces verifies the signed OAuth principal and queries by tenant + workspace;
|
||||
- legacy API-key behavior remains available;
|
||||
- OpenAPI documents both alternatives and RFC 9728 metadata supports discovery;
|
||||
- the Function remains gateway-only at Cloud Run IAM.
|
||||
|
||||
### Autogenous
|
||||
|
||||
Autogenous must consume an explicitly typed credential. It must not imply that
|
||||
`/v1/cli/session/exchange` produces a RuView-audience token: that exchange
|
||||
currently produces `client_id=cognitum-cli` and cannot pass the Spaces policy.
|
||||
An external RuView PKCE token may be supplied after activation, or a scoped API
|
||||
key may be used as the compatibility path. Response validation and lineage
|
||||
rules in this ADR apply before agent belief formation.
|
||||
|
||||
## Threat model
|
||||
|
||||
| Threat | Required control |
|
||||
|---|---|
|
||||
| Direct Function bypass | invoker IAM check; gateway SA only; no `allUsers` |
|
||||
| Forged internal OAuth header | strip inbound relay headers; gateway writes after route classification |
|
||||
| Token substitution | ES256/JWKS plus exact issuer, audience, client, type, scope, and tenant claims |
|
||||
| Cross-tenant enumeration | principal-derived Firestore selector; bounded non-enumerating errors |
|
||||
| Redirect/token exfiltration | redirects disabled; HTTPS required; fixed path |
|
||||
| Oversized/malformed response | byte/depth/count/string bounds before use |
|
||||
| Raw-data regression | server allow-list plus client forbidden-field rejection |
|
||||
| Secret disclosure | redacting types; no token logs/URLs; `.env` untracked |
|
||||
| Feedback amplification | lineage preservation, dedupe, cycle rejection, no independent corroboration |
|
||||
| Memory leakage | tenant filter before vector search; no global nearest-neighbor pass |
|
||||
| Agent overreach | observe/recommend default; ADR-321 fail-closed action gate |
|
||||
| Stale/replayed state | expiry, sequence, message ID, freshness, witness receipt |
|
||||
| JWKS outage/rotation | bounded cache; fail closed; refresh after unknown `kid`; no algorithm fallback |
|
||||
|
||||
## Deployment and rollback
|
||||
|
||||
Rollout order is dependency-safe:
|
||||
|
||||
1. merge and deploy Identity scope/metadata;
|
||||
2. deploy the Spaces Function with OAuth verification while API-key behavior
|
||||
remains unchanged;
|
||||
3. deploy the gateway relay and protected-resource metadata;
|
||||
4. verify gateway API-key access, OAuth denial matrices, direct-URL `403`, and
|
||||
tenant isolation;
|
||||
5. merge/release the RuView client and CLI activation;
|
||||
6. enable Autogenous consumption only after its strict validation/lineage gates
|
||||
pass.
|
||||
|
||||
Rollback disables OAuth advertisement/relay and returns clients to scoped API
|
||||
keys. It must not restore public Function invocation. Revoking an OAuth session
|
||||
or API key must not alter paired-site state.
|
||||
|
||||
## Validation and acceptance
|
||||
|
||||
Required automated gates:
|
||||
|
||||
- Identity: metadata test, migration application, PKCE authorize/token/refresh
|
||||
scope preservation, cross-client scope denial;
|
||||
- API Function: valid claim matrix and rejection for wrong issuer/audience/
|
||||
client/type/scope/tenant, API-key regression, tenant query assertion, bounded
|
||||
projection tests, build and dependency audit;
|
||||
- gateway: spoofed relay stripped, caller OAuth preserved, Google hop identity
|
||||
substituted, OpenAPI security alternatives, RFC 9728 metadata, build and
|
||||
dependency audit;
|
||||
- RuView: semantic decoder bounds/privacy tests, redaction tests, login scope
|
||||
tests, CLI compile, and live empty/non-empty response tests without fixtures
|
||||
masquerading as production;
|
||||
- policy: no Spaces read can invoke an actuator; denial receipts are witnessed.
|
||||
|
||||
Production readback must prove:
|
||||
|
||||
- unauthenticated gateway request returns `401`;
|
||||
- legacy scoped API key returns the authenticated tenant list;
|
||||
- valid RuView OAuth returns only its workspace;
|
||||
- wrong client, missing `spaces:read`, setup/workload token, and second-tenant
|
||||
token are denied;
|
||||
- direct Function URL returns `403` even with a valid application credential;
|
||||
- response remains `no-store` and excludes P0/P1;
|
||||
- no secret appears in logs, diffs, artifacts, or issue/PR text.
|
||||
|
||||
Performance, detection quality, and action-safety numbers are not claimed by
|
||||
this decision. Any such number requires a named reproducer and the repository's
|
||||
evidence labels. An empty production tenant is a successful isolation/read-path
|
||||
test, not sensing-quality evidence.
|
||||
|
||||
## Consequences
|
||||
|
||||
### Positive
|
||||
|
||||
- One Cognitum identity can explicitly activate RuView's cloud spatial read
|
||||
capability without sharing a long-lived static bearer.
|
||||
- Tenant and workspace become cryptographically bound inputs to the data query.
|
||||
- RuView and Autogenous gain useful spatial context without importing raw RF or
|
||||
inventing independent evidence.
|
||||
- RuVector can ground explanations in local/tenant history while action remains
|
||||
separately governed.
|
||||
- The direct-origin bypass is closed permanently, independent of OAuth rollout.
|
||||
|
||||
### Costs and limitations
|
||||
|
||||
- Two credential types coexist during migration and must stay visibly distinct.
|
||||
- OAuth depends on Identity JWKS availability and correct key rotation.
|
||||
- The current API exposes spaces only; the full hierarchy/events/alerts model
|
||||
remains staged work.
|
||||
- OAuth workspace IDs will return only documents populated with `workspaceId`;
|
||||
legacy owner-only documents require an explicit migration, never a broad query.
|
||||
- No write, command, or agent execution surface is implemented by this ADR.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Keep API keys only.** Rejected as the target: keys are useful for service
|
||||
compatibility but do not provide user activation, consent, short lifetime, or
|
||||
refresh/revocation semantics.
|
||||
|
||||
**Treat the CLI API-key exchange token as a Spaces OAuth token.** Rejected: it
|
||||
is minted for `cognitum-cli`, not `ruview`, and accepting it would remove the
|
||||
audience/client boundary.
|
||||
|
||||
**Trust the gateway without verifying OAuth in Spaces.** Rejected: hop identity
|
||||
and user authorization are distinct, and authorization must remain valid if the
|
||||
route topology changes.
|
||||
|
||||
**Make Spaces state independent corroboration.** Rejected: it is derived from
|
||||
the same RuView/HomeCore lineage and would double-count evidence.
|
||||
|
||||
**Allow agents to execute from `spaces:read`.** Rejected: read consent is not
|
||||
action authority, and perception confidence alone cannot authorize consequence.
|
||||
|
||||
**Synchronize raw RF for better cloud models.** Rejected by default: it violates
|
||||
the edge privacy boundary and is unnecessary for the semantic product.
|
||||
|
||||
## References
|
||||
|
||||
- Autogenous ADR-402, `docs/adr/ADR-402-ruview-cognitum-spaces-spatial-intelligence.md`
|
||||
- Cognitum API ADR-094, `docs/adr/ADR-094-cognitum-spaces-homecore-edge-boundary.md`
|
||||
- RFC 7636, Proof Key for Code Exchange
|
||||
- RFC 8414, OAuth 2.0 Authorization Server Metadata
|
||||
- RFC 9700, OAuth 2.0 Security Best Current Practice
|
||||
- RFC 9728, OAuth 2.0 Protected Resource Metadata
|
||||
14
v2/Cargo.lock
generated
14
v2/Cargo.lock
generated
@@ -9694,6 +9694,18 @@ dependencies = [
|
||||
"wifi-densepose-calibration",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ruview-cognitum-spaces"
|
||||
version = "0.3.1"
|
||||
dependencies = [
|
||||
"reqwest 0.12.28",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"thiserror 2.0.18",
|
||||
"tokio",
|
||||
"url",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ruview-counterfactual"
|
||||
version = "0.3.1"
|
||||
@@ -13734,6 +13746,7 @@ dependencies = [
|
||||
"predicates",
|
||||
"reqwest 0.12.28",
|
||||
"ruview-auth",
|
||||
"ruview-cognitum-spaces",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tabled",
|
||||
@@ -13744,6 +13757,7 @@ dependencies = [
|
||||
"tower-http",
|
||||
"tracing",
|
||||
"tracing-subscriber",
|
||||
"url",
|
||||
"uuid",
|
||||
"wifi-densepose-calibration",
|
||||
"wifi-densepose-core",
|
||||
|
||||
@@ -37,6 +37,7 @@ members = [
|
||||
# their Cognitum identity instead of a shared static bearer. No login flow
|
||||
# and no outbound Cognitum API calls live here — verification only.
|
||||
"crates/ruview-auth",
|
||||
"crates/ruview-cognitum-spaces", # ADR-325 Cognitum Spaces read client
|
||||
"crates/nvsim",
|
||||
"crates/nvsim-server",
|
||||
"crates/homecore", # ADR-127 — HOMECORE state machine
|
||||
|
||||
@@ -19,6 +19,10 @@ pub mod scope {
|
||||
/// Irreversible: a deleted model or labelled capture may represent days of
|
||||
/// collection, and a training run burns hours of CPU on a Pi.
|
||||
pub const SENSING_ADMIN: &str = "sensing:admin";
|
||||
|
||||
/// Read tenant-scoped P2/P3 semantic state from Cognitum Spaces.
|
||||
/// This grants no raw sensing access and no action authority.
|
||||
pub const SPACES_READ: &str = "spaces:read";
|
||||
}
|
||||
|
||||
/// A verified caller. Constructed only by
|
||||
|
||||
19
v2/crates/ruview-cognitum-spaces/Cargo.toml
Normal file
19
v2/crates/ruview-cognitum-spaces/Cargo.toml
Normal file
@@ -0,0 +1,19 @@
|
||||
[package]
|
||||
name = "ruview-cognitum-spaces"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
authors.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
description = "Bounded, privacy-preserving Cognitum Spaces client for RuView"
|
||||
publish = false
|
||||
|
||||
[dependencies]
|
||||
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
thiserror.workspace = true
|
||||
url = "2"
|
||||
|
||||
[dev-dependencies]
|
||||
tokio.workspace = true
|
||||
387
v2/crates/ruview-cognitum-spaces/src/lib.rs
Normal file
387
v2/crates/ruview-cognitum-spaces/src/lib.rs
Normal file
@@ -0,0 +1,387 @@
|
||||
//! Cognitum Spaces read client (ADR-325).
|
||||
//!
|
||||
//! This crate consumes tenant-scoped semantic P2/P3 state only. It never
|
||||
//! uploads raw CSI/CIR, RF tensors, pose frames, vital waveforms, recordings,
|
||||
//! or identity observations, and it exposes no action method.
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
use reqwest::redirect::Policy;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
use url::Url;
|
||||
|
||||
const MAX_RESPONSE_BYTES: usize = 1024 * 1024;
|
||||
const MAX_SPACES: usize = 100;
|
||||
const MAX_JSON_DEPTH: usize = 16;
|
||||
const MAX_STRING_BYTES: usize = 4096;
|
||||
const REQUIRED_EXCLUSIONS: [&str; 7] = [
|
||||
"raw_csi",
|
||||
"cir",
|
||||
"rf_tensors",
|
||||
"recordings",
|
||||
"pose_frames",
|
||||
"vital_waveforms",
|
||||
"identity_observations",
|
||||
];
|
||||
|
||||
#[derive(Clone)]
|
||||
pub enum Credential {
|
||||
OAuth(String),
|
||||
ApiKey(String),
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for Credential {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Self::OAuth(_) => f.write_str("OAuth(<redacted>)"),
|
||||
Self::ApiKey(_) => f.write_str("ApiKey(<redacted>)"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Credential {
|
||||
pub fn oauth(token: impl Into<String>) -> Result<Self, Error> {
|
||||
secret(Self::OAuth, token.into())
|
||||
}
|
||||
|
||||
pub fn api_key(key: impl Into<String>) -> Result<Self, Error> {
|
||||
let key = key.into();
|
||||
if !key.starts_with("cog_") || key.len() == 4 {
|
||||
return Err(Error::InvalidCredential);
|
||||
}
|
||||
secret(Self::ApiKey, key)
|
||||
}
|
||||
}
|
||||
|
||||
fn secret(make: impl FnOnce(String) -> Credential, value: String) -> Result<Credential, Error> {
|
||||
if value.is_empty()
|
||||
|| value.len() > 16_384
|
||||
|| value.chars().any(char::is_whitespace)
|
||||
|| value.chars().any(char::is_control)
|
||||
{
|
||||
return Err(Error::InvalidCredential);
|
||||
}
|
||||
Ok(make(value))
|
||||
}
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum Error {
|
||||
#[error("Spaces base URL must be HTTPS (HTTP is allowed only on loopback)")]
|
||||
InsecureUrl,
|
||||
#[error("invalid Spaces base URL")]
|
||||
InvalidUrl,
|
||||
#[error("invalid or empty credential")]
|
||||
InvalidCredential,
|
||||
#[error("Spaces request failed: {0}")]
|
||||
Transport(#[from] reqwest::Error),
|
||||
#[error("Spaces rejected the credential ({0})")]
|
||||
Authentication(u16),
|
||||
#[error("Spaces returned HTTP {0}")]
|
||||
Http(u16),
|
||||
#[error("Spaces response is too large")]
|
||||
ResponseTooLarge,
|
||||
#[error("Spaces response is not JSON")]
|
||||
ContentType,
|
||||
#[error("Spaces response violates the semantic boundary: {0}")]
|
||||
InvalidResponse(String),
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Client {
|
||||
endpoint: Url,
|
||||
credential: Credential,
|
||||
http: reqwest::Client,
|
||||
}
|
||||
|
||||
impl Client {
|
||||
pub fn new(base_url: &str, credential: Credential) -> Result<Self, Error> {
|
||||
let base = Url::parse(base_url).map_err(|_| Error::InvalidUrl)?;
|
||||
let loopback = base
|
||||
.host_str()
|
||||
.is_some_and(|host| host == "localhost" || host == "127.0.0.1" || host == "::1");
|
||||
if base.scheme() != "https" && !(base.scheme() == "http" && loopback) {
|
||||
return Err(Error::InsecureUrl);
|
||||
}
|
||||
if !base.username().is_empty()
|
||||
|| base.password().is_some()
|
||||
|| base.query().is_some()
|
||||
|| base.fragment().is_some()
|
||||
{
|
||||
return Err(Error::InvalidUrl);
|
||||
}
|
||||
let endpoint = base.join("/v1/spaces").map_err(|_| Error::InvalidUrl)?;
|
||||
let http = reqwest::Client::builder()
|
||||
.redirect(Policy::none())
|
||||
.connect_timeout(Duration::from_secs(5))
|
||||
.timeout(Duration::from_secs(10))
|
||||
.user_agent(concat!(
|
||||
"ruview-cognitum-spaces/",
|
||||
env!("CARGO_PKG_VERSION")
|
||||
))
|
||||
.build()?;
|
||||
Ok(Self {
|
||||
endpoint,
|
||||
credential,
|
||||
http,
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn list(&self) -> Result<SpacesResponse, Error> {
|
||||
let mut request = self
|
||||
.http
|
||||
.get(self.endpoint.clone())
|
||||
.header("Accept", "application/json");
|
||||
request = match &self.credential {
|
||||
Credential::OAuth(token) => request.bearer_auth(token),
|
||||
Credential::ApiKey(key) => request.header("X-API-Key", key),
|
||||
};
|
||||
let mut response = request.send().await?;
|
||||
let status = response.status();
|
||||
if status.as_u16() == 401 || status.as_u16() == 403 {
|
||||
return Err(Error::Authentication(status.as_u16()));
|
||||
}
|
||||
if !status.is_success() {
|
||||
return Err(Error::Http(status.as_u16()));
|
||||
}
|
||||
let is_json = response
|
||||
.headers()
|
||||
.get(reqwest::header::CONTENT_TYPE)
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.is_some_and(|v| {
|
||||
v.split(';')
|
||||
.next()
|
||||
.is_some_and(|m| m.trim().eq_ignore_ascii_case("application/json"))
|
||||
});
|
||||
if !is_json {
|
||||
return Err(Error::ContentType);
|
||||
}
|
||||
if response
|
||||
.content_length()
|
||||
.is_some_and(|n| n > MAX_RESPONSE_BYTES as u64)
|
||||
{
|
||||
return Err(Error::ResponseTooLarge);
|
||||
}
|
||||
let mut body = Vec::new();
|
||||
while let Some(chunk) = response.chunk().await? {
|
||||
if body.len().saturating_add(chunk.len()) > MAX_RESPONSE_BYTES {
|
||||
return Err(Error::ResponseTooLarge);
|
||||
}
|
||||
body.extend_from_slice(&chunk);
|
||||
}
|
||||
decode(&body)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SpacesResponse {
|
||||
pub object: String,
|
||||
pub data: Vec<Space>,
|
||||
pub boundary: DataBoundary,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Space {
|
||||
pub id: String,
|
||||
pub tenant_id: String,
|
||||
pub workspace_id: Option<String>,
|
||||
pub site_id: String,
|
||||
pub name: String,
|
||||
pub version: u64,
|
||||
pub privacy: String,
|
||||
pub status: String,
|
||||
pub connection: String,
|
||||
pub state: SemanticState,
|
||||
pub provenance: Value,
|
||||
pub hardware: Value,
|
||||
pub data_boundary: Value,
|
||||
#[serde(default)]
|
||||
pub observed_at: Option<String>,
|
||||
#[serde(default)]
|
||||
pub expires_at: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SemanticState {
|
||||
pub occupancy: Option<u64>,
|
||||
pub confidence: Option<f64>,
|
||||
pub observed_at: Option<String>,
|
||||
pub freshness_ms: Option<u64>,
|
||||
pub classification: String,
|
||||
pub uncertainty: Value,
|
||||
pub evidence: Vec<Value>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct DataBoundary {
|
||||
pub authoritative_state: String,
|
||||
pub cloud_role: String,
|
||||
pub excluded: Vec<String>,
|
||||
}
|
||||
|
||||
pub fn decode(bytes: &[u8]) -> Result<SpacesResponse, Error> {
|
||||
if bytes.len() > MAX_RESPONSE_BYTES {
|
||||
return Err(Error::ResponseTooLarge);
|
||||
}
|
||||
let value: Value = serde_json::from_slice(bytes)
|
||||
.map_err(|_| Error::InvalidResponse("malformed JSON".into()))?;
|
||||
validate_value(&value, 0)?;
|
||||
let response: SpacesResponse = serde_json::from_value(value)
|
||||
.map_err(|e| Error::InvalidResponse(format!("schema mismatch: {e}")))?;
|
||||
if response.object != "list" || response.data.len() > MAX_SPACES {
|
||||
return Err(Error::InvalidResponse("invalid list envelope".into()));
|
||||
}
|
||||
if response.boundary.authoritative_state != "HomeCore Edge"
|
||||
|| REQUIRED_EXCLUSIONS.iter().any(|required| {
|
||||
!response
|
||||
.boundary
|
||||
.excluded
|
||||
.iter()
|
||||
.any(|excluded| excluded == required)
|
||||
})
|
||||
{
|
||||
return Err(Error::InvalidResponse(
|
||||
"incomplete edge privacy boundary".into(),
|
||||
));
|
||||
}
|
||||
for space in &response.data {
|
||||
if space.id.is_empty()
|
||||
|| space.tenant_id.is_empty()
|
||||
|| space.site_id.is_empty()
|
||||
|| space.name.is_empty()
|
||||
{
|
||||
return Err(Error::InvalidResponse(
|
||||
"space identity is incomplete".into(),
|
||||
));
|
||||
}
|
||||
if !matches!(space.privacy.as_str(), "P2" | "P3") || space.state.classification != "P2" {
|
||||
return Err(Error::InvalidResponse("non-semantic privacy class".into()));
|
||||
}
|
||||
if space
|
||||
.state
|
||||
.confidence
|
||||
.is_some_and(|v| !v.is_finite() || !(0.0..=1.0).contains(&v))
|
||||
{
|
||||
return Err(Error::InvalidResponse("invalid confidence".into()));
|
||||
}
|
||||
}
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
fn validate_value(value: &Value, depth: usize) -> Result<(), Error> {
|
||||
if depth > MAX_JSON_DEPTH {
|
||||
return Err(Error::InvalidResponse("JSON nesting is too deep".into()));
|
||||
}
|
||||
match value {
|
||||
Value::String(s) if s.len() > MAX_STRING_BYTES => {
|
||||
return Err(Error::InvalidResponse("string exceeds bound".into()));
|
||||
}
|
||||
Value::Array(items) if items.len() > 1000 => {
|
||||
return Err(Error::InvalidResponse("array exceeds bound".into()));
|
||||
}
|
||||
Value::Array(items) => {
|
||||
for item in items {
|
||||
validate_value(item, depth + 1)?;
|
||||
}
|
||||
}
|
||||
Value::Object(map) => {
|
||||
if map.len() > 128 {
|
||||
return Err(Error::InvalidResponse("object exceeds bound".into()));
|
||||
}
|
||||
for (key, item) in map {
|
||||
if key.len() > MAX_STRING_BYTES {
|
||||
return Err(Error::InvalidResponse("object key exceeds bound".into()));
|
||||
}
|
||||
let normalized: String = key
|
||||
.chars()
|
||||
.filter(|c| c.is_ascii_alphanumeric())
|
||||
.flat_map(char::to_lowercase)
|
||||
.collect();
|
||||
if matches!(
|
||||
normalized.as_str(),
|
||||
"rawcsi"
|
||||
| "cir"
|
||||
| "rftensors"
|
||||
| "recordings"
|
||||
| "poseframes"
|
||||
| "vitalwaveforms"
|
||||
| "identityobservations"
|
||||
) {
|
||||
return Err(Error::InvalidResponse(format!(
|
||||
"forbidden raw field: {key}"
|
||||
)));
|
||||
}
|
||||
validate_value(item, depth + 1)?;
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn valid() -> Vec<u8> {
|
||||
br#"{"object":"list","data":[{"id":"room-1","tenantId":"tenant-1","workspaceId":"workspace-1","siteId":"site-1","name":"Room","version":1,"privacy":"P2","status":"live","connection":"connected","state":{"occupancy":1,"confidence":0.9,"observedAt":"2026-08-17T00:00:00Z","freshnessMs":5,"classification":"P2","uncertainty":null,"evidence":[]},"provenance":{},"hardware":{},"dataBoundary":{},"observedAt":"2026-08-17T00:00:00Z","expiresAt":null}],"boundary":{"authoritativeState":"HomeCore Edge","cloudRole":"tenant-scoped semantic synchronization","excluded":["raw_csi","cir","rf_tensors","recordings","pose_frames","vital_waveforms","identity_observations"]}}"#.to_vec()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accepts_bounded_semantic_state() {
|
||||
assert_eq!(decode(&valid()).unwrap().data.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_raw_fields_anywhere() {
|
||||
let mut value: Value = serde_json::from_slice(&valid()).unwrap();
|
||||
value["data"][0]["state"]["raw_csi"] = Value::String("secret".into());
|
||||
assert!(matches!(
|
||||
decode(&serde_json::to_vec(&value).unwrap()),
|
||||
Err(Error::InvalidResponse(_))
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_missing_confidence_as_a_number_outside_bounds() {
|
||||
let mut value: Value = serde_json::from_slice(&valid()).unwrap();
|
||||
value["data"][0]["state"]["confidence"] = Value::from(2.0);
|
||||
assert!(matches!(
|
||||
decode(&serde_json::to_vec(&value).unwrap()),
|
||||
Err(Error::InvalidResponse(_))
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_incomplete_privacy_boundary() {
|
||||
let mut value: Value = serde_json::from_slice(&valid()).unwrap();
|
||||
value["boundary"]["excluded"] = serde_json::json!(["raw_csi"]);
|
||||
assert!(matches!(
|
||||
decode(&serde_json::to_vec(&value).unwrap()),
|
||||
Err(Error::InvalidResponse(_))
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_credential_bearing_urls_and_whitespace_secrets() {
|
||||
let credential = Credential::oauth("token").unwrap();
|
||||
assert!(matches!(
|
||||
Client::new("https://user:pass@api.cognitum.one", credential),
|
||||
Err(Error::InvalidUrl)
|
||||
));
|
||||
assert!(matches!(
|
||||
Credential::oauth("token with spaces"),
|
||||
Err(Error::InvalidCredential)
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn credentials_are_redacted() {
|
||||
let c = Credential::oauth("secret-token").unwrap();
|
||||
assert!(!format!("{c:?}").contains("secret-token"));
|
||||
}
|
||||
}
|
||||
@@ -62,9 +62,11 @@ anyhow = "1.0"
|
||||
# the sensing server depends on this same crate with default features and
|
||||
# gets only the verifier.
|
||||
ruview-auth = { path = "../ruview-auth", features = ["login"] }
|
||||
ruview-cognitum-spaces = { path = "../ruview-cognitum-spaces" }
|
||||
# Only for constructing the HTTP client hands to Session.
|
||||
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
|
||||
thiserror = "2.0"
|
||||
url = "2"
|
||||
|
||||
# Time
|
||||
chrono = { version = "0.4", features = ["serde"] }
|
||||
|
||||
@@ -24,6 +24,10 @@ pub struct LoginArgs {
|
||||
#[arg(long)]
|
||||
pub admin: bool,
|
||||
|
||||
/// Also activate read-only Cognitum Spaces access (`spaces:read`).
|
||||
#[arg(long)]
|
||||
pub spaces: bool,
|
||||
|
||||
/// Skip the browser and use the paste-a-code flow.
|
||||
///
|
||||
/// Detected automatically over SSH and inside containers; this forces it.
|
||||
@@ -66,18 +70,21 @@ fn path_or_default(p: Option<PathBuf>) -> PathBuf {
|
||||
/// least-privilege test in the library, but this command does NOT go through
|
||||
/// that default — it builds the scope string itself, so the library test says
|
||||
/// nothing about what the CLI actually requests.
|
||||
fn requested_scope(admin: bool) -> String {
|
||||
fn requested_scope(admin: bool, spaces: bool) -> String {
|
||||
let mut scopes = vec![scope::SENSING_READ];
|
||||
if admin {
|
||||
// Admin implies read: there is no scope hierarchy server-side, so a
|
||||
// session that needs both must consent to both explicitly.
|
||||
format!("{} {}", scope::SENSING_READ, scope::SENSING_ADMIN)
|
||||
} else {
|
||||
scope::SENSING_READ.to_string()
|
||||
scopes.push(scope::SENSING_ADMIN);
|
||||
}
|
||||
if spaces {
|
||||
scopes.push(scope::SPACES_READ);
|
||||
}
|
||||
scopes.join(" ")
|
||||
}
|
||||
|
||||
pub async fn login_cmd(args: LoginArgs) -> anyhow::Result<()> {
|
||||
let scope = requested_scope(args.admin);
|
||||
let scope = requested_scope(args.admin, args.spaces);
|
||||
|
||||
let opts = LoginOptions {
|
||||
credentials_path: path_or_default(args.credentials_path),
|
||||
@@ -102,7 +109,9 @@ pub async fn logout_cmd(args: LogoutArgs) -> anyhow::Result<()> {
|
||||
}
|
||||
// Deliberately local-only. This makes the machine unable to act as you;
|
||||
// revoking the session for every device is an account-level action.
|
||||
println!("Note: this forgets the local credential only. It does not revoke the session server-side.");
|
||||
println!(
|
||||
"Note: this forgets the local credential only. It does not revoke the session server-side."
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -157,9 +166,12 @@ mod tests {
|
||||
// poses must not carry the capability to delete recordings. If this
|
||||
// ever returns admin by default, every session silently becomes
|
||||
// destructive-capable and nothing else in the suite would notice.
|
||||
let s = requested_scope(false);
|
||||
let s = requested_scope(false, false);
|
||||
assert_eq!(s, scope::SENSING_READ);
|
||||
assert!(!s.contains(scope::SENSING_ADMIN), "read-only login leaked admin: {s}");
|
||||
assert!(
|
||||
!s.contains(scope::SENSING_ADMIN),
|
||||
"read-only login leaked admin: {s}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -167,9 +179,22 @@ mod tests {
|
||||
// The authorization server grants exactly what is requested; admin does
|
||||
// not imply read. Asking for admin alone would produce a session that
|
||||
// cannot stream.
|
||||
let s = requested_scope(true);
|
||||
assert!(s.split_whitespace().any(|x| x == scope::SENSING_READ), "{s}");
|
||||
assert!(s.split_whitespace().any(|x| x == scope::SENSING_ADMIN), "{s}");
|
||||
let s = requested_scope(true, false);
|
||||
assert!(
|
||||
s.split_whitespace().any(|x| x == scope::SENSING_READ),
|
||||
"{s}"
|
||||
);
|
||||
assert!(
|
||||
s.split_whitespace().any(|x| x == scope::SENSING_ADMIN),
|
||||
"{s}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spaces_activation_is_explicit_and_read_only() {
|
||||
let s = requested_scope(false, true);
|
||||
assert!(s.split_whitespace().any(|x| x == scope::SPACES_READ));
|
||||
assert!(!s.split_whitespace().any(|x| x == scope::SENSING_ADMIN));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -29,9 +29,10 @@ use clap::{Parser, Subcommand};
|
||||
pub mod auth;
|
||||
pub mod calibrate;
|
||||
pub mod calibrate_api;
|
||||
pub mod room;
|
||||
#[cfg(feature = "mat")]
|
||||
pub mod mat;
|
||||
pub mod room;
|
||||
pub mod spaces;
|
||||
|
||||
/// WiFi-DensePose Command Line Interface
|
||||
#[derive(Parser, Debug)]
|
||||
@@ -61,6 +62,9 @@ pub enum Commands {
|
||||
/// Show the stored Cognitum session: account, scope, and whether it is live.
|
||||
Whoami(auth::WhoamiArgs),
|
||||
|
||||
/// Read tenant-scoped semantic state from Cognitum Spaces (ADR-325).
|
||||
Spaces(spaces::SpacesArgs),
|
||||
|
||||
/// Empty-room baseline calibration (ADR-135).
|
||||
/// Captures CSI frames via UDP and saves a per-subcarrier statistical
|
||||
/// baseline used for real-time motion z-scoring and CIR reference.
|
||||
|
||||
@@ -27,6 +27,9 @@ async fn main() -> anyhow::Result<()> {
|
||||
Commands::Whoami(args) => {
|
||||
wifi_densepose_cli::auth::whoami_cmd(args).await?;
|
||||
}
|
||||
Commands::Spaces(args) => {
|
||||
wifi_densepose_cli::spaces::spaces_cmd(args).await?;
|
||||
}
|
||||
Commands::Calibrate(args) => {
|
||||
wifi_densepose_cli::calibrate::execute(args).await?;
|
||||
}
|
||||
|
||||
74
v2/crates/wifi-densepose-cli/src/spaces.rs
Normal file
74
v2/crates/wifi-densepose-cli/src/spaces.rs
Normal file
@@ -0,0 +1,74 @@
|
||||
//! `wifi-densepose spaces` — Cognitum Spaces activation and read access.
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use clap::Args;
|
||||
use ruview_auth::{login, scope};
|
||||
use ruview_cognitum_spaces::{Client, Credential};
|
||||
|
||||
#[derive(Debug, Args)]
|
||||
pub struct SpacesArgs {
|
||||
/// Cognitum Spaces API origin.
|
||||
#[arg(long, default_value = "https://api.cognitum.one")]
|
||||
pub base_url: String,
|
||||
|
||||
/// Compatibility API key. If omitted, use the stored OAuth session.
|
||||
#[arg(long, env = "COGNITUM_SPACES_API", hide_env_values = true)]
|
||||
pub api_key: Option<String>,
|
||||
|
||||
/// OAuth credential file used when no API key is supplied.
|
||||
#[arg(long, env = ruview_auth::login::CREDENTIALS_PATH_ENV)]
|
||||
pub credentials_path: Option<PathBuf>,
|
||||
|
||||
/// Emit the validated response as JSON.
|
||||
#[arg(long)]
|
||||
pub json: bool,
|
||||
}
|
||||
|
||||
pub async fn spaces_cmd(args: SpacesArgs) -> anyhow::Result<()> {
|
||||
let credential = match args.api_key {
|
||||
Some(key) => Credential::api_key(key)?,
|
||||
None => {
|
||||
let path = args
|
||||
.credentials_path
|
||||
.unwrap_or_else(login::default_credentials_path);
|
||||
let session = login::Session::load_from(path, reqwest::Client::new())?;
|
||||
let snapshot = session.snapshot().await;
|
||||
let granted = snapshot.effective_scope().unwrap_or_default();
|
||||
if !granted
|
||||
.split_whitespace()
|
||||
.any(|item| item == scope::SPACES_READ)
|
||||
{
|
||||
anyhow::bail!(
|
||||
"stored OAuth session lacks spaces:read; run `wifi-densepose login --spaces`"
|
||||
);
|
||||
}
|
||||
Credential::oauth(session.ensure_fresh().await?)?
|
||||
}
|
||||
};
|
||||
let response = Client::new(&args.base_url, credential)?.list().await?;
|
||||
if args.json {
|
||||
println!("{}", serde_json::to_string_pretty(&response)?);
|
||||
return Ok(());
|
||||
}
|
||||
println!("Cognitum Spaces: {}", response.data.len());
|
||||
println!(
|
||||
"Boundary: {} / {}",
|
||||
response.boundary.authoritative_state, response.boundary.cloud_role
|
||||
);
|
||||
for space in response.data {
|
||||
let occupancy = space
|
||||
.state
|
||||
.occupancy
|
||||
.map_or_else(|| "unknown".into(), |v| v.to_string());
|
||||
let confidence = space
|
||||
.state
|
||||
.confidence
|
||||
.map_or_else(|| "unknown".into(), |v| format!("{v:.3}"));
|
||||
println!(
|
||||
"{}\t{}\toccupancy={}\tconfidence={}\t{}",
|
||||
space.id, space.name, occupancy, confidence, space.status
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
Reference in New Issue
Block a user