diff --git a/Cargo.lock b/Cargo.lock index b627016..6a7d614 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -295,6 +295,21 @@ dependencies = [ "serde_json", ] +[[package]] +name = "agentkeys-web-core" +version = "0.1.0" +dependencies = [ + "axum", + "reqwest", + "serde", + "serde-wasm-bindgen", + "serde_json", + "thiserror 2.0.18", + "tokio", + "wasm-bindgen", + "wasm-bindgen-futures", +] + [[package]] name = "agentkeys-worker-audit" version = "0.1.0" @@ -1405,6 +1420,12 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + [[package]] name = "ciborium" version = "0.2.2" @@ -2230,9 +2251,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" dependencies = [ "cfg-if", + "js-sys", "libc", "r-efi 5.3.0", "wasip2", + "wasm-bindgen", ] [[package]] @@ -2576,6 +2599,7 @@ dependencies = [ "tokio", "tokio-rustls 0.26.4", "tower-service", + "webpki-roots 1.0.7", ] [[package]] @@ -2959,6 +2983,12 @@ dependencies = [ "hashbrown 0.16.1", ] +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + [[package]] name = "matchers" version = "0.2.0" @@ -3537,6 +3567,61 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "quinn" +version = "0.11.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls 0.23.37", + "socket2 0.6.3", + "thiserror 2.0.18", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098" +dependencies = [ + "bytes", + "getrandom 0.3.4", + "lru-slab", + "rand 0.9.4", + "ring", + "rustc-hash", + "rustls 0.23.37", + "rustls-pki-types", + "slab", + "thiserror 2.0.18", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2 0.6.3", + "tracing", + "windows-sys 0.59.0", +] + [[package]] name = "quote" version = "1.0.45" @@ -3687,6 +3772,8 @@ dependencies = [ "native-tls", "percent-encoding", "pin-project-lite", + "quinn", + "rustls 0.23.37", "rustls-pki-types", "serde", "serde_json", @@ -3694,6 +3781,7 @@ dependencies = [ "sync_wrapper", "tokio", "tokio-native-tls", + "tokio-rustls 0.26.4", "tower 0.5.3", "tower-http 0.6.8", "tower-service", @@ -3701,6 +3789,7 @@ dependencies = [ "wasm-bindgen", "wasm-bindgen-futures", "web-sys", + "webpki-roots 1.0.7", ] [[package]] @@ -3752,6 +3841,12 @@ dependencies = [ "smallvec", ] +[[package]] +name = "rustc-hash" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" + [[package]] name = "rustc_version" version = "0.4.1" @@ -3855,6 +3950,7 @@ version = "1.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd" dependencies = [ + "web-time", "zeroize", ] @@ -4016,6 +4112,17 @@ dependencies = [ "serde_derive", ] +[[package]] +name = "serde-wasm-bindgen" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8302e169f0eddcc139c70f139d19d6467353af16f9fce27e8c30158036a1e16b" +dependencies = [ + "js-sys", + "serde", + "wasm-bindgen", +] + [[package]] name = "serde_cbor_2" version = "0.13.0" @@ -4458,6 +4565,21 @@ dependencies = [ "zerovec", ] +[[package]] +name = "tinyvec" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + [[package]] name = "tokio" version = "1.51.1" @@ -5017,6 +5139,16 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + [[package]] name = "webauthn-attestation-ca" version = "0.5.5" diff --git a/Cargo.toml b/Cargo.toml index 660e67b..3c9b3c7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,6 +3,7 @@ resolver = "2" members = [ "crates/agentkeys-types", "crates/agentkeys-core", + "crates/agentkeys-web-core", "crates/agentkeys-mock-server", "crates/agentkeys-cli", "crates/agentkeys-daemon", @@ -19,6 +20,7 @@ members = [ [workspace.dependencies] agentkeys-types = { path = "crates/agentkeys-types" } agentkeys-core = { path = "crates/agentkeys-core" } +agentkeys-web-core = { path = "crates/agentkeys-web-core" } serde = { version = "1", features = ["derive"] } serde_json = "1" tokio = { version = "1", features = ["full"] } diff --git a/apps/parent-control/.gitignore b/apps/parent-control/.gitignore index 3e8c24e..e52cf79 100644 --- a/apps/parent-control/.gitignore +++ b/apps/parent-control/.gitignore @@ -6,3 +6,8 @@ dist/ *.tsbuildinfo .env*.local .vercel + +# Generated by dev.sh `build_wasm` (agentkeys-web-core via wasm-pack). Cached + +# rebuilt only when the Rust source hash changes; never committed. +lib/wasm/ +public/wasm/ diff --git a/apps/parent-control/lib/client/core.ts b/apps/parent-control/lib/client/core.ts new file mode 100644 index 0000000..808ba1a --- /dev/null +++ b/apps/parent-control/lib/client/core.ts @@ -0,0 +1,92 @@ +'use client'; + +import { EmptyBackend } from './empty'; +import type { ConnectionStatus } from './types'; + +// Lazy, client-only load of the WASM master-plane core (agentkeys-web-core), +// memoized per broker URL. The dynamic import keeps the wasm glue out of the +// server bundle; init() fetches the .wasm from /wasm/ (served from public/, +// written by dev.sh's build_wasm). Keying by URL means a second CoreBackend with +// a different broker gets its own instance; on failure the entry is evicted so +// the next call retries (a transient load/broker failure must not poison it). +type LoadedCore = import('@/lib/wasm/agentkeys-web-core/agentkeys_web_core').WebCore; +const coreByUrl = new Map>(); +function loadCore(brokerUrl: string): Promise { + let p = coreByUrl.get(brokerUrl); + if (!p) { + p = (async () => { + const wasm = await import('@/lib/wasm/agentkeys-web-core/agentkeys_web_core.js'); + await wasm.default('/wasm/agentkeys_web_core_bg.wasm'); + return new wasm.WebCore(brokerUrl); + })(); + coreByUrl.set(brokerUrl, p); + void p.catch(() => coreByUrl.delete(brokerUrl)); + } + return p; +} + +/** + * CoreBackend — the phone-first host (browser → WASM core → broker DIRECTLY, no + * daemon). X1 of docs/plan/web-flow/wire-real-paths.md: it loads the + * `agentkeys-web-core` WASM module and exposes the broker calls (cap-mint, + * pairing) the onboarding/pairing slices use. + * + * The `AgentKeysClient` READ endpoints (actors, audit, memory, …) are wired in + * the later W-phases, so they inherit EmptyBackend's disconnected behaviour for + * now (the UI shows honest empty states); `status()` exercises the full path + * (load WASM + probe the broker) and reports what happened. + */ +export class CoreBackend extends EmptyBackend { + private brokerUrl: string; + + constructor(brokerUrl: string) { + super(); + this.brokerUrl = brokerUrl.replace(/\/+$/, ''); + } + + async status(): Promise { + try { + await loadCore(this.brokerUrl); + } catch (e) { + return { + kind: 'disconnected', + reason: 'no-backend-configured', + detail: `WASM core failed to load: ${String(e)}`, + }; + } + try { + const r = await fetch(`${this.brokerUrl}/healthz`, { cache: 'no-store' }); + return { + kind: 'disconnected', + reason: r.ok ? 'no-backend-configured' : 'unreachable', + detail: r.ok + ? `WASM core loaded; broker ${this.brokerUrl} reachable. The AgentKeysClient read endpoints wire in later W-phases (wire-real-paths.md).` + : `WASM core loaded; broker /healthz → ${r.status}.`, + }; + } catch (e) { + return { + kind: 'disconnected', + reason: 'unreachable', + detail: `broker ${this.brokerUrl} unreachable: ${String(e)}`, + }; + } + } + + // ── Broker calls in the browser (X1). Beyond the AgentKeysClient interface; + // the onboarding/pairing slices call these directly via the CoreBackend. + async capMemoryPut(bearer: string, req: unknown): Promise { + return (await loadCore(this.brokerUrl)).capMemoryPut(bearer, req); + } + async capMemoryGet(bearer: string, req: unknown): Promise { + return (await loadCore(this.brokerUrl)).capMemoryGet(bearer, req); + } + async pairingClaim(bearer: string, req: unknown): Promise { + return (await loadCore(this.brokerUrl)).pairingClaim(bearer, req); + } + async pendingBindings(bearer: string): Promise { + return (await loadCore(this.brokerUrl)).pendingBindings(bearer); + } + async ackBinding(bearer: string, requestId: string): Promise { + return (await loadCore(this.brokerUrl)).ackBinding(bearer, requestId); + } +} diff --git a/apps/parent-control/lib/client/index.ts b/apps/parent-control/lib/client/index.ts index d269141..3cd54c8 100644 --- a/apps/parent-control/lib/client/index.ts +++ b/apps/parent-control/lib/client/index.ts @@ -1,11 +1,18 @@ +import { CoreBackend } from './core'; import { DaemonBackend } from './daemon'; import { EmptyBackend } from './empty'; import type { AgentKeysClient } from './types'; -export type BackendKind = 'empty' | 'daemon'; +export type BackendKind = 'empty' | 'daemon' | 'core'; export function selectBackend(): AgentKeysClient { const kind = (process.env.NEXT_PUBLIC_AGENTKEYS_BACKEND ?? 'empty') as BackendKind; + if (kind === 'core') { + // Phone-first host: the WASM core talks to the broker directly (no daemon). X1. + return new CoreBackend( + process.env.NEXT_PUBLIC_AGENTKEYS_BROKER_URL ?? 'https://broker.litentry.org', + ); + } if (kind === 'daemon') { return new DaemonBackend(process.env.NEXT_PUBLIC_AGENTKEYS_DAEMON_URL); } @@ -15,3 +22,4 @@ export function selectBackend(): AgentKeysClient { export * from './types'; export { EmptyBackend } from './empty'; export { DaemonBackend } from './daemon'; +export { CoreBackend } from './core'; diff --git a/crates/agentkeys-web-core/Cargo.toml b/crates/agentkeys-web-core/Cargo.toml new file mode 100644 index 0000000..75fff08 --- /dev/null +++ b/crates/agentkeys-web-core/Cargo.toml @@ -0,0 +1,37 @@ +[package] +name = "agentkeys-web-core" +version = "0.1.0" +edition = "2021" +description = "Host-agnostic master-plane core (broker client + ceremony logic) shared by the daemon, the WASM web host, and the mobile shell." + +# cdylib: required by wasm-pack to emit the browser .wasm. rlib: so native +# crates (agentkeys-core, the daemon) depend on it as a normal Rust lib. +[lib] +crate-type = ["cdylib", "rlib"] + +[dependencies] +serde = { workspace = true } +serde_json = { workspace = true } +thiserror = { workspace = true } +# default-features off so the wasm32 build picks the browser `fetch` backend +# (no native-tls/hyper, which don't compile for wasm). The native build adds a +# TLS backend in the target table below. +reqwest = { version = "0.12", default-features = false, features = ["json"] } + +# WASM bindings — only compiled for the browser core (`--features wasm`). +wasm-bindgen = { version = "0.2", optional = true } +wasm-bindgen-futures = { version = "0.4", optional = true } +serde-wasm-bindgen = { version = "0.6", optional = true } + +# Native builds (daemon/CLI/mobile) need a TLS backend; rustls avoids the +# system OpenSSL dep. On wasm32 reqwest auto-uses the browser fetch backend, so +# no TLS crate is pulled there. +[target.'cfg(not(target_arch = "wasm32"))'.dependencies] +reqwest = { version = "0.12", default-features = false, features = ["rustls-tls"] } + +[features] +wasm = ["dep:wasm-bindgen", "dep:wasm-bindgen-futures", "dep:serde-wasm-bindgen"] + +[dev-dependencies] +tokio = { workspace = true } +axum = { version = "0.7", features = ["json"] } diff --git a/crates/agentkeys-web-core/src/broker.rs b/crates/agentkeys-web-core/src/broker.rs new file mode 100644 index 0000000..6d85b77 --- /dev/null +++ b/crates/agentkeys-web-core/src/broker.rs @@ -0,0 +1,413 @@ +//! Host-agnostic broker HTTP client for the master control plane. +//! +//! W0/X0 of `docs/plan/web-flow/wire-real-paths.md`: one typed broker client +//! that the daemon ui-bridge, the WASM `CoreBackend` (web), and the mobile +//! UniFFI shell all share — so the browser/phone never re-implement broker +//! calls in TypeScript/Swift (the "consistency is structural" rule). +//! +//! Scope: the pairing (arch.md §10.2 method A, master-side) + cap-mint +//! endpoints the web wiring proxies. The email/OAuth/SIWE auth flow lives in +//! `agentkeys_core::init_flow`; this module is the net-new surface plus a +//! reusable typed client others can build on. +//! +//! WASM: this crate pins `reqwest` to `default-features = false` so `wasm32` +//! uses the browser `fetch` backend (native adds `rustls-tls`); the client is +//! host-agnostic by construction (no filesystem, clock, or env). + +use serde::de::DeserializeOwned; +use serde::{Deserialize, Serialize}; +use thiserror::Error; + +#[derive(Debug, Error)] +pub enum BrokerError { + #[error("transport: {0}")] + Transport(String), + #[error("broker rejected {endpoint}: status={status} body={body}")] + Rejected { + endpoint: String, + status: u16, + body: String, + }, + #[error("decode response: {0}")] + Decode(String), +} + +type R = Result; + +/// A reusable, host-agnostic broker client. Holds a `reqwest::Client` + the +/// broker base URL; every method takes the caller's bearer (the operator's J1 +/// session JWT) explicitly — the client itself stores no secret. +#[derive(Clone)] +pub struct BrokerClient { + http: reqwest::Client, + base_url: String, +} + +/// Default `reqwest::Client`. Native hosts (daemon/CLI/mobile) get a request +/// timeout so a stalled broker can't hang a worker thread forever; the +/// wasm/browser host uses the `fetch` backend (per-request timeouts there need +/// an `AbortController`, wired in the web host — not the `ClientBuilder`). +#[cfg(not(target_arch = "wasm32"))] +fn default_client() -> reqwest::Client { + reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(20)) + .build() + .unwrap_or_else(|_| reqwest::Client::new()) +} + +#[cfg(target_arch = "wasm32")] +fn default_client() -> reqwest::Client { + reqwest::Client::new() +} + +impl BrokerClient { + pub fn new(base_url: impl Into) -> Self { + Self::with_client(default_client(), base_url) + } + + /// Reuse a pre-built `reqwest::Client` (connection pooling, timeouts, or a + /// wasm-configured client in the browser host). + pub fn with_client(http: reqwest::Client, base_url: impl Into) -> Self { + Self { + http, + base_url: base_url.into().trim_end_matches('/').to_string(), + } + } + + // ── Cap-mint (operator-J1-gated). One typed call per (op, data_class); + // the route is the source of truth for the data_class (per CLAUDE.md). ── + + pub async fn cap_memory_put(&self, bearer: &str, req: &CapRequest) -> R { + self.post_json("/v1/cap/memory-put", Some(bearer), req) + .await + } + pub async fn cap_memory_get(&self, bearer: &str, req: &CapRequest) -> R { + self.post_json("/v1/cap/memory-get", Some(bearer), req) + .await + } + pub async fn cap_cred_store(&self, bearer: &str, req: &CapRequest) -> R { + self.post_json("/v1/cap/cred-store", Some(bearer), req) + .await + } + pub async fn cap_cred_fetch(&self, bearer: &str, req: &CapRequest) -> R { + self.post_json("/v1/cap/cred-fetch", Some(bearer), req) + .await + } + + // ── Pairing, master-side (arch.md §10.2 method A). The agent-side + // request/poll lives in the daemon's one-shot modes, not here. ── + + /// Master claims an agent-shown pairing code (J1_master-gated). Binds the + /// unbound request to the HDKD child omni; returns the device artifacts the + /// master needs to submit the on-chain bind. + pub async fn pairing_claim( + &self, + bearer: &str, + req: &PairingClaimRequest, + ) -> R { + self.post_json("/v1/agent/pairing/claim", Some(bearer), req) + .await + } + + /// Master polls for claimed-but-unbound agents — the pairing-page bell / + /// notification source. + pub async fn pending_bindings(&self, bearer: &str) -> R> { + let resp: PendingBindings = self + .get_json("/v1/agent/pending-bindings", Some(bearer)) + .await?; + Ok(resp.pending) + } + + /// Master acks an on-chain bind, clearing the rendezvous. + pub async fn ack_binding(&self, bearer: &str, request_id: &str) -> R { + self.post_json( + "/v1/agent/pending-bindings/ack", + Some(bearer), + &AckRequest { + request_id: request_id.to_string(), + }, + ) + .await + } + + // ── internals ── + + async fn post_json( + &self, + path: &str, + bearer: Option<&str>, + body: &B, + ) -> R { + let url = format!("{}{}", self.base_url, path); + let mut rb = self.http.post(&url).json(body); + if let Some(b) = bearer { + rb = rb.bearer_auth(b); + } + let resp = rb + .send() + .await + .map_err(|e| BrokerError::Transport(format!("POST {path}: {e}")))?; + Self::decode(path, resp).await + } + + async fn get_json(&self, path: &str, bearer: Option<&str>) -> R { + let url = format!("{}{}", self.base_url, path); + let mut rb = self.http.get(&url); + if let Some(b) = bearer { + rb = rb.bearer_auth(b); + } + let resp = rb + .send() + .await + .map_err(|e| BrokerError::Transport(format!("GET {path}: {e}")))?; + Self::decode(path, resp).await + } + + async fn decode(path: &str, resp: reqwest::Response) -> R { + let status = resp.status(); + if !status.is_success() { + // Bound the echoed body: it's our own broker, reached over the + // operator's own session (no cross-tenant data; the bearer is never + // echoed back), but an unbounded error string shouldn't flow into a + // JS rejection / log line. 512 chars preserves the broker error code. + let raw = resp.text().await.unwrap_or_default(); + let body: String = raw.chars().take(512).collect(); + return Err(BrokerError::Rejected { + endpoint: path.to_string(), + status: status.as_u16(), + body, + }); + } + resp.json::() + .await + .map_err(|e| BrokerError::Decode(format!("{path}: {e}"))) + } +} + +// ─── Cap-mint types (mirror crates/agentkeys-broker-server handlers/cap.rs) ── + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CapRequest { + pub operator_omni: String, + pub actor_omni: String, + pub service: String, + pub device_key_hash: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub ttl_seconds: Option, +} + +/// Broker-signed cap token. `payload` is the signed `CapPayload` (op, data_class, +/// k3_epoch, expiry, …) — kept as opaque JSON here; the worker re-parses + the +/// broker_sig is re-verified downstream, so the client does not need the shape. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CapToken { + pub payload: serde_json::Value, + pub broker_sig: String, +} + +// ─── Pairing types (mirror handlers/agent/{claim,pending}.rs) ── + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PairingClaimRequest { + pub pairing_code: String, + pub label: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub requested_scope: Option, +} + +/// The record the master sees for a claimed-but-unbound agent — carries +/// everything needed to submit `registerAgentDevice` on chain. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ClaimedBinding { + pub request_id: String, + pub child_omni: String, + pub operator_omni: String, + pub label: String, + #[serde(default)] + pub requested_scope: String, + pub device_pubkey: String, + pub pop_sig: String, + pub device_key_hash: String, +} + +#[derive(Debug, Clone, Deserialize)] +struct PendingBindings { + #[serde(default)] + pending: Vec, +} + +#[derive(Debug, Clone, Serialize)] +struct AckRequest { + request_id: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AckResponse { + pub acked: bool, + pub request_id: String, +} + +#[cfg(test)] +mod tests { + use super::*; + use axum::{ + extract::Json as AxJson, + http::{HeaderMap, StatusCode}, + routing::{get, post}, + Router, + }; + use serde_json::json; + + // Spin a tiny broker stub on an ephemeral port; return its base URL. + async fn stub() -> String { + let app = Router::new() + .route( + "/v1/cap/memory-put", + post( + |headers: HeaderMap, AxJson(body): AxJson| async move { + // Require the operator bearer. + if !headers.contains_key("authorization") { + return ( + StatusCode::UNAUTHORIZED, + AxJson(json!({"error":"no-bearer"})), + ); + } + let svc = body["service"].as_str().unwrap_or(""); + ( + StatusCode::OK, + AxJson(json!({ + "payload": {"op":"store","data_class":"memory","service":svc}, + "broker_sig":"c2ln" + })), + ) + }, + ), + ) + .route( + "/v1/agent/pairing/claim", + post(|AxJson(body): AxJson| async move { + let code = body["pairing_code"].as_str().unwrap_or(""); + AxJson(json!({ + "request_id":"req-1","child_omni":"0xchild","operator_omni":"0xop", + "label": body["label"], "requested_scope":"memory", + "device_pubkey":"0xdpub","pop_sig":"0xpop","device_key_hash":"0xdkh", + "_echo_code": code + })) + }), + ) + .route( + "/v1/agent/pending-bindings", + get(|| async { + AxJson(json!({"pending":[{ + "request_id":"req-1","child_omni":"0xchild","operator_omni":"0xop", + "label":"demo","requested_scope":"memory","device_pubkey":"0xdpub", + "pop_sig":"0xpop","device_key_hash":"0xdkh" + }]})) + }), + ) + .route( + "/v1/agent/pending-bindings/ack", + post(|AxJson(body): AxJson| async move { + AxJson(json!({"acked":true,"request_id": body["request_id"]})) + }), + ) + .route( + "/v1/cap/cred-store", + post(|| async { (StatusCode::INTERNAL_SERVER_ERROR, "boom") }), + ) + .route( + // Returns an oversized body so the truncation guard can be tested. + "/v1/cap/cred-fetch", + post(|| async { (StatusCode::BAD_REQUEST, "x".repeat(1000)) }), + ); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + tokio::spawn(async move { axum::serve(listener, app).await.unwrap() }); + format!("http://{addr}") + } + + fn cap_req() -> CapRequest { + CapRequest { + operator_omni: "0xop".into(), + actor_omni: "0xactor".into(), + service: "memory".into(), + device_key_hash: "0xdkh".into(), + ttl_seconds: Some(900), + } + } + + #[tokio::test] + async fn cap_memory_put_roundtrips_and_sends_bearer() { + let c = BrokerClient::new(stub().await); + let tok = c.cap_memory_put("J1", &cap_req()).await.unwrap(); + assert_eq!(tok.broker_sig, "c2ln"); + assert_eq!(tok.payload["data_class"], "memory"); + assert_eq!(tok.payload["service"], "memory"); + } + + #[tokio::test] + async fn non_2xx_maps_to_rejected() { + // The cred-store stub route returns 500 → BrokerError::Rejected with the + // status + endpoint preserved (so callers can fail closed / surface it). + let c = BrokerClient::new(stub().await); + let err = c.cap_cred_store("J1", &cap_req()).await.unwrap_err(); + match err { + BrokerError::Rejected { + status, endpoint, .. + } => { + assert_eq!(status, 500); + assert_eq!(endpoint, "/v1/cap/cred-store"); + } + other => panic!("expected Rejected, got {other:?}"), + } + } + + #[tokio::test] + async fn rejected_body_is_bounded() { + // A broker error body longer than the cap is truncated to 512 chars so it + // can't bloat a JS rejection / log line (status + endpoint preserved). + let c = BrokerClient::new(stub().await); + let err = c.cap_cred_fetch("J1", &cap_req()).await.unwrap_err(); + match err { + BrokerError::Rejected { status, body, .. } => { + assert_eq!(status, 400); + assert_eq!( + body.chars().count(), + 512, + "body should be capped at 512 chars" + ); + } + other => panic!("expected Rejected, got {other:?}"), + } + } + + #[tokio::test] + async fn pairing_claim_roundtrips() { + let c = BrokerClient::new(stub().await); + let claimed = c + .pairing_claim( + "J1", + &PairingClaimRequest { + pairing_code: "ABCD-1234".into(), + label: "demo-agent".into(), + requested_scope: Some("memory".into()), + }, + ) + .await + .unwrap(); + assert_eq!(claimed.child_omni, "0xchild"); + assert_eq!(claimed.device_key_hash, "0xdkh"); + assert_eq!(claimed.label, "demo-agent"); + } + + #[tokio::test] + async fn pending_then_ack() { + let c = BrokerClient::new(stub().await); + let pending = c.pending_bindings("J1").await.unwrap(); + assert_eq!(pending.len(), 1); + assert_eq!(pending[0].request_id, "req-1"); + let ack = c.ack_binding("J1", "req-1").await.unwrap(); + assert!(ack.acked); + assert_eq!(ack.request_id, "req-1"); + } +} diff --git a/crates/agentkeys-web-core/src/lib.rs b/crates/agentkeys-web-core/src/lib.rs new file mode 100644 index 0000000..a745cb1 --- /dev/null +++ b/crates/agentkeys-web-core/src/lib.rs @@ -0,0 +1,17 @@ +//! agentkeys-web-core — the host-agnostic master-plane core. +//! +//! Compiles for **native** (daemon / CLI / mobile-via-UniFFI) AND **`wasm32`** +//! (the browser web host, via `wasm-pack build --features wasm`). It holds no +//! filesystem / clock / env state, so the same orchestration logic runs +//! unchanged in every host — the "consistency is structural" rule from +//! `docs/plan/web-flow/wire-real-paths.md` §0.5. The auth bearer + base URL are +//! always passed in by the host; this crate stores no secret. +//! +//! Today it holds the broker client (W0/X0). The WebAuthn-UserOp builder and the +//! ceremony state machines land here too as later slices, so the WASM +//! `CoreBackend`, the daemon ui-bridge, and the mobile shell share one impl. + +pub mod broker; + +#[cfg(feature = "wasm")] +pub mod wasm; diff --git a/crates/agentkeys-web-core/src/wasm.rs b/crates/agentkeys-web-core/src/wasm.rs new file mode 100644 index 0000000..86217fe --- /dev/null +++ b/crates/agentkeys-web-core/src/wasm.rs @@ -0,0 +1,115 @@ +//! `wasm-bindgen` exports for the browser `CoreBackend` (X1). +//! +//! Compiled only under `--features wasm` (e.g. `wasm-pack build --target web +//! --features wasm`). Wraps [`crate::broker::BrokerClient`] in a JS-constructable +//! `WebCore` whose async methods take/return plain JSON (`serde-wasm-bindgen`) +//! and reject with the broker error string on failure. The Next.js +//! `CoreBackend` (`lib/client/core.ts`) imports the generated `pkg`. +//! +//! No secret is stored: the operator's J1 bearer is passed per call, exactly as +//! on the native side. + +use wasm_bindgen::prelude::*; + +use crate::broker::{BrokerClient, CapRequest, PairingClaimRequest}; + +fn to_js(e: E) -> JsValue { + JsValue::from_str(&e.to_string()) +} + +/// The host-agnostic master-plane core, exposed to the browser. One per broker +/// base URL; holds no secret. +#[wasm_bindgen] +pub struct WebCore { + broker: BrokerClient, +} + +#[wasm_bindgen] +impl WebCore { + /// `new WebCore("https://broker.litentry.org")`. + #[wasm_bindgen(constructor)] + pub fn new(broker_base_url: String) -> WebCore { + WebCore { + broker: BrokerClient::new(broker_base_url), + } + } + + // ── cap-mint (one method per route; `req` is a CapRequest-shaped object) ── + + #[wasm_bindgen(js_name = capMemoryPut)] + pub async fn cap_memory_put(&self, bearer: String, req: JsValue) -> Result { + let req: CapRequest = serde_wasm_bindgen::from_value(req).map_err(to_js)?; + let tok = self + .broker + .cap_memory_put(&bearer, &req) + .await + .map_err(to_js)?; + serde_wasm_bindgen::to_value(&tok).map_err(to_js) + } + + #[wasm_bindgen(js_name = capMemoryGet)] + pub async fn cap_memory_get(&self, bearer: String, req: JsValue) -> Result { + let req: CapRequest = serde_wasm_bindgen::from_value(req).map_err(to_js)?; + let tok = self + .broker + .cap_memory_get(&bearer, &req) + .await + .map_err(to_js)?; + serde_wasm_bindgen::to_value(&tok).map_err(to_js) + } + + #[wasm_bindgen(js_name = capCredStore)] + pub async fn cap_cred_store(&self, bearer: String, req: JsValue) -> Result { + let req: CapRequest = serde_wasm_bindgen::from_value(req).map_err(to_js)?; + let tok = self + .broker + .cap_cred_store(&bearer, &req) + .await + .map_err(to_js)?; + serde_wasm_bindgen::to_value(&tok).map_err(to_js) + } + + #[wasm_bindgen(js_name = capCredFetch)] + pub async fn cap_cred_fetch(&self, bearer: String, req: JsValue) -> Result { + let req: CapRequest = serde_wasm_bindgen::from_value(req).map_err(to_js)?; + let tok = self + .broker + .cap_cred_fetch(&bearer, &req) + .await + .map_err(to_js)?; + serde_wasm_bindgen::to_value(&tok).map_err(to_js) + } + + // ── pairing (master-side, arch §10.2 method A) ── + + #[wasm_bindgen(js_name = pairingClaim)] + pub async fn pairing_claim(&self, bearer: String, req: JsValue) -> Result { + let req: PairingClaimRequest = serde_wasm_bindgen::from_value(req).map_err(to_js)?; + let claimed = self + .broker + .pairing_claim(&bearer, &req) + .await + .map_err(to_js)?; + serde_wasm_bindgen::to_value(&claimed).map_err(to_js) + } + + #[wasm_bindgen(js_name = pendingBindings)] + pub async fn pending_bindings(&self, bearer: String) -> Result { + let pending = self.broker.pending_bindings(&bearer).await.map_err(to_js)?; + serde_wasm_bindgen::to_value(&pending).map_err(to_js) + } + + #[wasm_bindgen(js_name = ackBinding)] + pub async fn ack_binding( + &self, + bearer: String, + request_id: String, + ) -> Result { + let ack = self + .broker + .ack_binding(&bearer, &request_id) + .await + .map_err(to_js)?; + serde_wasm_bindgen::to_value(&ack).map_err(to_js) + } +} diff --git a/dev.sh b/dev.sh index 60a7c58..25e95d0 100755 --- a/dev.sh +++ b/dev.sh @@ -238,6 +238,50 @@ cleanup() { } trap cleanup INT TERM EXIT +# Build the WASM master-plane core (agentkeys-web-core → apps/parent-control via +# wasm-pack) iff the Rust source / Cargo.toml / wasm-pack version changed since +# the last build. Cached via a src-hash stamp in the (gitignored) out dir; the +# generated pkg + served .wasm are never committed. Graceful no-op if wasm-pack +# isn't installed (the UI then runs with the daemon/empty backend; only the +# `core` backend needs the WASM module). +build_wasm() { + local crate_dir="$REPO_ROOT/crates/agentkeys-web-core" + local out_dir="$REPO_ROOT/apps/parent-control/lib/wasm/agentkeys-web-core" + local pub_dir="$REPO_ROOT/apps/parent-control/public/wasm" + local stamp="$out_dir/.src-hash" + + if ! command -v wasm-pack >/dev/null 2>&1; then + warn "wasm-pack not installed — skipping WASM core (cargo install wasm-pack && rustup target add wasm32-unknown-unknown). 'core' backend unavailable; UI uses daemon/empty." + return 0 + fi + + # Version key: every .rs under src/ (sorted, so the filesystem walk order can't + # change the hash) + this crate's Cargo.toml + the workspace Cargo.toml & + # Cargo.lock (so a transitive-dep bump busts the cache) + the rustc & wasm-pack + # versions. Any change ⇒ rebuild; otherwise reuse the cached pkg. + local cur + cur="$( { find "$crate_dir/src" -type f -name '*.rs' -exec shasum -a 256 {} + | sort; + shasum -a 256 "$crate_dir/Cargo.toml" "$REPO_ROOT/Cargo.toml" "$REPO_ROOT/Cargo.lock"; + rustc -Vv; wasm-pack --version; } | shasum -a 256 | awk '{print $1}' )" + + if [ -f "$out_dir/agentkeys_web_core_bg.wasm" ] && [ -f "$pub_dir/agentkeys_web_core_bg.wasm" ] \ + && [ -f "$stamp" ] && [ "$(cat "$stamp" 2>/dev/null)" = "$cur" ]; then + printf "%b[dev]%b WASM core up-to-date (%s…) — skip build\n" "$C_DIM" "$C_RESET" "${cur:0:12}" + return 0 + fi + + rustup target list --installed 2>/dev/null | grep -q wasm32-unknown-unknown \ + || rustup target add wasm32-unknown-unknown >/dev/null 2>&1 || true + say "building WASM core (agentkeys-web-core → lib/wasm)…" + ( cd "$REPO_ROOT" && wasm-pack build crates/agentkeys-web-core --dev --target web \ + --out-dir "$out_dir" -- --features wasm ) \ + || { err "wasm-pack build failed"; exit 1; } + mkdir -p "$pub_dir" + cp "$out_dir/agentkeys_web_core_bg.wasm" "$pub_dir/agentkeys_web_core_bg.wasm" + printf '%s' "$cur" > "$stamp" + say "WASM core built + cached (${cur:0:12}…)." +} + # ─── Preflight ───────────────────────────────────────────────────── free_port "$UI_PORT" free_port "$DAEMON_PORT" @@ -246,6 +290,7 @@ build_if_needed "$DAEMON_BIN" "agentkeys-daemon" "agentkeys-daemon" \ "$REPO_ROOT/crates/agentkeys-daemon" build_if_needed "$MCP_BIN" "agentkeys-mcp-server" "agentkeys-mcp-server" \ "$REPO_ROOT/crates/agentkeys-mcp" "$REPO_ROOT/crates/agentkeys-mcp-server" +build_wasm # ─── Start daemon ────────────────────────────────────────────────── #