diff --git a/Cargo.lock b/Cargo.lock index a57ea8e..cdf15cf 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,13 +2,113 @@ # It is not intended for manual editing. version = 3 +[[package]] +name = "async-trait" +version = "0.1.91" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae36dc4177970ef04fde5178d3e2429882def40e57a451f919c098f72baa6cec" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.0", +] + [[package]] name = "dig-node-control-interface" -version = "0.1.0" +version = "0.2.0" dependencies = [ + "async-trait", + "futures", "serde", "serde_json", - "thiserror", +] + +[[package]] +name = "futures" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a88cf1f829d945f548cf8fec32c61b1f202b6d93b45848602fc02af4b12ad218" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "262590f4fe6afeb0bc83be1daa64e52657fe185690a958af7f3ad0e92085c5ae" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2cd50c473c80f6d7c3670a752354b8e569b1a7cbfdc0419ec88e5edad85e0dc7" + +[[package]] +name = "futures-executor" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6754879cc9f2c66f88c6e5c35344bb0bdb0708b0352b1201815667c7eabc7458" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4577ecaa3c4f96589d473f679a71b596316f6641bc350038b962a5daf0085d7a" + +[[package]] +name = "futures-macro" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d6d3cde68c518367be28956066ddfef33813991b77a55005a69dae04bf3b10b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "futures-sink" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e34418ac499d6305c2fb5ad0ed2f6ac998c5f8ca209b4510f7f94242c647e307" + +[[package]] +name = "futures-task" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b231ed28831efb4a61a08580c4bc233ec56bc009f4cd8f52da2c3cb97df0c109" + +[[package]] +name = "futures-util" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a77a90a256fce34da66415271e30f94ee91c57b04b8a2c042d9cf3220179deaa" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", ] [[package]] @@ -23,6 +123,12 @@ version = "2.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + [[package]] name = "proc-macro2" version = "1.0.107" @@ -84,6 +190,12 @@ dependencies = [ "zmij", ] +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + [[package]] name = "syn" version = "2.0.119" @@ -106,26 +218,6 @@ dependencies = [ "unicode-ident", ] -[[package]] -name = "thiserror" -version = "1.0.69" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" -dependencies = [ - "thiserror-impl", -] - -[[package]] -name = "thiserror-impl" -version = "1.0.69" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", -] - [[package]] name = "unicode-ident" version = "1.0.24" diff --git a/Cargo.toml b/Cargo.toml index b93a103..1e61e86 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -13,7 +13,7 @@ # is designed, matching the sibling dig--protocol crates' bootstrap order. [package] name = "dig-node-control-interface" -version = "0.1.0" +version = "0.2.0" edition = "2021" rust-version = "1.75.0" license = "Apache-2.0 OR MIT" @@ -27,7 +27,10 @@ categories = ["network-programming", "authentication"] # JSON-RPC 2.0 envelope + wire types (the real catalog lands in T2). serde = { version = "1", features = ["derive"] } serde_json = "1" -# The stable control-error taxonomy. -thiserror = "1" +# `async fn` in the node-facing `ControlHandler` trait (matches the sibling contract crates). +async-trait = "0.1" [dev-dependencies] +# Drive the async `ControlHandler` KATs to completion without pulling a full runtime dependency +# into the library itself. +futures = "0.3" diff --git a/README.md b/README.md index 87cf789..91f9313 100644 --- a/README.md +++ b/README.md @@ -2,24 +2,139 @@ The canonical **client ⇄ dig-node CONTROL interface** contract. One ecosystem definition of the management/query surface a client (the CLI `dign`, the browser extension, dig-app, hub) uses to -control and query a running dig-node — config, cache config, peer status, subscriptions, peer -connect/disconnect, and other node-control operations — so the client side and the node side can -never silently drift. Transport-agnostic: rides the `dig-ipc-protocol` local session, or -loopback-mTLS + a signed control token over HTTP/WebSocket for clients that aren't on the local pipe. +control and query a running dig-node — status, config, cache, hosted/pinned stores, §21 sync, the peer +network, subscriptions, the auto-update beacon, live log level, and control-token pairing — so the +client side and the node side can never silently drift. Transport-agnostic: rides the +`dig-ipc-protocol` local session, or loopback-mTLS + a signed control token over HTTP/WebSocket for +clients that aren't on the local pipe. - **License:** Apache-2.0 OR MIT - **Spec:** [`SPEC.md`](./SPEC.md) (normative) — this README is the at-a-glance interface reference. ```toml [dependencies] -dig-node-control-interface = "0.1" +dig-node-control-interface = "0.2" ``` -> **Status: skeleton (epic #1110, T1).** This is the provisioning commit — the repository, CI/release -> plumbing, and module layout only. The full method catalog (this section is where every control -> method, its params/result types, and its error code would be listed for LLM/agent lookup, per the -> ecosystem's dig-``-protocol convention) lands in **T2** (tracked as `dig_ecosystem#1147`). Check -> back there, or re-read this README once T2 merges, for the real interface reference. +## Usage + +Client side — build a typed request, carry it over your transport, parse the typed result: + +```rust +use dig_node_control_interface::{ + params::SetCapParams, + traits::{build_request, parse_response}, + envelope::JsonRpcResponse, +}; + +let call = SetCapParams { cap_bytes: 128 * 1024 * 1024 }; +let request = build_request(1.into(), &call); // → control.cache.setCap envelope +// ... send `request` over dig-ipc / loopback-mTLS, receive `response` ... +# let response = JsonRpcResponse::success(1.into(), serde_json::json!({ "cap_bytes": 134217728 })); +let result = parse_response::(response)?; // → SetCapResult { cap_bytes } +# Ok::<(), dig_node_control_interface::ControlError>(()) +``` + +Node side — implement `ControlHandler` (one typed method per control method); the provided `dispatch` +routes a raw request to the right method and builds the response. + +## Authorization + +Every `control.*` method is token-gated: present the node's local control token as the +`X-Dig-Control-Token` header (preferred) or a `params._control_token` field. `pairing.request` / +`pairing.poll` are OPEN (a token-less client uses them to obtain a scoped token after local operator +approval). The three `control.pairing.*` admin methods require the MASTER token specifically. + +## Full interface reference + +Every control method, for LLM/agent lookup without reading code. `Auth`: **T** = requires the control +token, **M** = requires the MASTER token, **—** = open. `Route`: how the node resolves it — `own` +(service shell), `del` (engine), `open` (bootstrap). + +### Status / config / log + +| Method | Auth | Route | Params | Result | +|---|---|---|---|---| +| `control.status` | T | own | — | `{running, service, version, commit, protocol, uptime_secs, addr, upstream, cache:{cap_bytes,used_bytes,dir,shared}, hosted_store_count, cached_capsule_count, pinned_store_count, sync:{available}}` | +| `control.config.get` | T | own | — | `{addr, port, upstream, upstream_override:string\|null, cache_dir, cache_shared, config_path, sync_available}` | +| `control.config.setUpstream` | T | own | `{upstream:string}` | `{upstream, requires_restart:true}` | +| `control.log.setLevel` | T | own | `{filter:string}` (an EnvFilter directive) | `{filter}` | + +### Cache + +| Method | Auth | Route | Params | Result | +|---|---|---|---|---| +| `control.cache.get` | T | own | — | `{cap_bytes, used_bytes, dir, shared}` | +| `control.cache.setCap` | T | own | `{cap_bytes:u64}` | `{cap_bytes}` (floored at 64 MiB) | +| `control.cache.clear` | T | own | — | `{cleared:true}` | + +### Hosted / pinned stores + +| Method | Auth | Route | Params | Result | +|---|---|---|---|---| +| `control.hostedStores.list` | T | own | — | `{stores:[{store_id, pinned, capsule_count, total_bytes, capsules:[{capsule,root,size_bytes,last_used_unix_ms}]}]}` | +| `control.hostedStores.pin` | T | own | `{store:"storeId[:root]"}` | `{store_id, root:string\|null, pinned:true, fetch:{status,…}}` | +| `control.hostedStores.unpin` | T | own | `{store}` | `{store_id, unpinned:bool, evicted_capsules:u64}` | +| `control.hostedStores.status` | T | own | `{store}` | `{store_id, pinned, capsule_count, total_bytes, capsules}` | + +### §21 whole-store sync + +| Method | Auth | Route | Params | Result | +|---|---|---|---|---| +| `control.sync.status` | T | own | — | `{available, method, pinned_total, pinned_synced, whole_store_trigger_supported}` | +| `control.sync.trigger` | T | own | `{store:"storeId:root"}` | `{store_id, root, status:"synced", size_bytes, served_root}` | + +### Auto-update beacon (proxied to dig-updater) + +| Method | Auth | Route | Params | Result | +|---|---|---|---|---| +| `control.updater.status` | T | own | — | (proxied beacon status) | +| `control.updater.setChannel` | T | own | `{channel:"nightly"\|"stable"}` | (proxied) | +| `control.updater.pause` | T | own | `{until?:u64}` (unix secs; omit = indefinite) | (proxied) | +| `control.updater.resume` | T | own | — | (proxied) | +| `control.updater.checkNow` | T | own | — | (proxied) | + +### Pairing (control-token lifecycle) + +| Method | Auth | Route | Params | Result | +|---|---|---|---|---| +| `control.pairing.list` | M | own | — | (pending requests + issued tokens) | +| `control.pairing.approve` | M | own | `{pairing_id:string}` | `{approved:true, client_name, token_id}` | +| `control.pairing.revoke` | M | own | `{token_id:string}` | `{revoked:bool, token_id}` | +| `pairing.request` | — | open | `{client_name:string}` | `{pairing_id, pairing_code, expires_ms}` | +| `pairing.poll` | — | open | `{pairing_id:string}` | `{status, token?}` (token present once, after approval) | + +### Peers (delegated to the engine) + +| Method | Auth | Route | Params | Result | +|---|---|---|---|---| +| `control.peerStatus` | T | del | — | (peer-pool + relay-reservation snapshot, incl. per-peer `peers[]`) | +| `control.peers.connect` | T | del | `{peer:string}` (address or peer_id) | `{connected:true, peer_id}` | +| `control.peers.disconnect` | T | del | `{peer:string}` (peer_id) | `{disconnected:true, peer_id}` | + +### Subscriptions (delegated to the engine) + +| Method | Auth | Route | Params | Result | +|---|---|---|---|---| +| `control.subscribe` | T | del | `{store_id:string}` | `{subscribed:true, added:bool, store_id}` | +| `control.unsubscribe` | T | del | `{store_id:string}` | `{subscribed:false, removed:bool, store_id}` | +| `control.listSubscriptions` | T | del | — | `{subscriptions:[string], count}` | + +### Error codes + +Every control error is `{code:int, message:string, data:{code:SYMBOL, origin:string}}`. Branch on +`data.code` (the stable symbol), never the message. + +| Code | Symbol | Origin | Meaning | +|---|---|---|---| +| `-32700` | `PARSE_ERROR` | shell | request body was not valid JSON | +| `-32600` | `INVALID_REQUEST` | shell | not a single JSON-RPC object | +| `-32601` | `METHOD_NOT_FOUND` | boundary | control method is not resolved | +| `-32602` | `INVALID_PARAMS` | node | missing/malformed params | +| `-32000` | `DISPATCH_FAILED` | shell | the node failed to dispatch a well-formed call | +| `-32030` | `UNAUTHORIZED` | shell | `control.*` called without a valid token | +| `-32031` | `NOT_SUPPORTED` | shell | control op unsupported on this build | +| `-32032` | `CONTROL_ERROR` | shell | control op failed at runtime | ## Boundary @@ -29,10 +144,10 @@ dig-node-control-interface = "0.1" - `dig-node-control-interface` (this crate) — the control METHOD CATALOG carried inside that authenticated channel, or over loopback-mTLS for remote-transport clients. -See `SPEC.md` §1 for the full boundary statement, and the superproject `SYSTEM.md` for how this crate -fits the wider cross-repo contract map. +See `SPEC.md` for the normative contract and the superproject `SYSTEM.md` for the wider cross-repo map. -## Consumers (planned) +## Consumers -dig-node (server-side dispatch), the CLI `dign`, dig-chrome-extension, dig-app, hub — all consuming -this one control contract once T2 lands, per epic #1110. +dig-node (server-side dispatch — implements `ControlHandler`), the CLI `dign`, dig-chrome-extension, +dig-app, hub — all consuming this one control contract per epic #1110 (dig-node adoption + the +dig-rpc Control-tier removal land in later tasks of the cascade). diff --git a/SPEC.md b/SPEC.md index 6807f36..7466099 100644 --- a/SPEC.md +++ b/SPEC.md @@ -3,23 +3,18 @@ This document is the authoritative contract for the client ⇄ dig-node **control interface**: how a client (the CLI `dign`, the browser extension, dig-app, hub) controls and queries a running dig-node. An independent reimplementation of either side MUST conform to this specification byte-for-byte. Where -this document and the code disagree, the conformance KATs in `tests/` (once populated, T2) are the -tie-breaker. +this document and the code disagree, the conformance KATs in `src/kats.rs` are the tie-breaker. Layering: this file is the repo's own contract; the cross-repo interaction map is the superproject `SYSTEM.md`; the session/transport envelope this catalog rides over is `dig-ipc-protocol`'s `SPEC.md`; the node↔node peer wire is `dig-rpc-protocol`'s `SPEC.md`. All MUST agree. -> **Status: skeleton.** This is the epic #1110 T1 provisioning commit — the repository, CI, and module -> layout only. The normative method catalog below is filled in by T2 (dig_ecosystem#1147). Sections -> marked TBD are reserved headings, not yet a contract. - ## 1. Scope and boundary `dig-node-control-interface` owns the **client↔node CONTROL method catalog**: the method names, -parameter/result types, and error taxonomy for controlling and querying a running dig-node — -configuration, cache configuration, peer status, subscription lifecycle, peer connect/disconnect, and -other node-control operations. +parameter/result types, and error taxonomy for controlling and querying a running dig-node — status, +configuration, cache, hosted/pinned stores, §21 whole-store sync, the peer network, subscription +lifecycle, the auto-update beacon, live log level, and the control-token pairing handshake. It does **not** own: - the node↔node peer wire (dig-rpc-protocol's PublicRead + Peer tiers), or @@ -37,18 +32,130 @@ rides over it. - **Node (server):** the identity-agnostic engine dispatching control calls against its live state (config, cache, peer table, subscriptions). -## 3. Method catalog (TBD — T2) +### 2.1 Authorization + +- Every `control.*` method is **token-gated**: the caller MUST present the node's local control token + as the `X-Dig-Control-Token` request header (preferred) or a `params._control_token` field. The + token is a 64-hex value the node mints at first run into its machine-wide state dir with a + restrictive ACL; possession of the on-disk token is authorization. A call without a valid token MUST + be rejected with `UNAUTHORIZED` (-32030). Token comparison MUST be constant-time. +- The two **pairing-bootstrap** methods `pairing.request` / `pairing.poll` are **OPEN** (no token), so + a token-less client (e.g. an MV3 extension that cannot read a local file) can obtain a scoped token + after local operator approval. +- The three **pairing-administration** methods (`control.pairing.list` / `.approve` / `.revoke`) + require the **MASTER** control token specifically, never a scoped paired token — a paired controller + can drive ordinary mutations but MUST NOT mint more tokens or revoke itself. + +## 3. Envelope + +Requests and responses are JSON-RPC 2.0. + +- **Request:** `{"jsonrpc":"2.0","id":,"method":,"params":}`. `id` is a number, + string, or null and MUST be echoed on the response. `params` is always an object (`{}` for a + no-parameter method). +- **Success response:** `{"jsonrpc":"2.0","id":,"result":}` (no `error` key). +- **Error response:** `{"jsonrpc":"2.0","id":,"error":{"code":,"message":,"data":{"code":,"origin":}}}` + (no `result` key). A client MUST branch on `error.data.code` (the stable UPPER_SNAKE symbol), never + on the human `message`. + +## 4. Method catalog + +Method names are stable wire contract. `Auth` = requires the control token; `Master` = requires the +master token specifically; `Routing` = how the node resolves it (`owned` by the service shell, +`delegated` to the engine, `open` bootstrap). + +| Method | Auth | Routing | Params | Result | +|---|---|---|---|---| +| `control.status` | yes | owned | — | `StatusResult` | +| `control.config.get` | yes | owned | — | `ConfigResult` | +| `control.config.setUpstream` | yes | owned | `{upstream:string}` | `{upstream, requires_restart}` | +| `control.log.setLevel` | yes | owned | `{filter:string}` | `{filter}` | +| `control.cache.get` | yes | owned | — | `CacheView` | +| `control.cache.setCap` | yes | owned | `{cap_bytes:u64}` | `{cap_bytes}` (floored 64 MiB) | +| `control.cache.clear` | yes | owned | — | `{cleared:true}` | +| `control.hostedStores.list` | yes | owned | — | `{stores:[HostedStore]}` | +| `control.hostedStores.pin` | yes | owned | `{store:"storeId[:root]"}` | `{store_id, root, pinned, fetch}` | +| `control.hostedStores.unpin` | yes | owned | `{store}` | `{store_id, unpinned, evicted_capsules}` | +| `control.hostedStores.status` | yes | owned | `{store}` | `{store_id, pinned, capsule_count, total_bytes, capsules}` | +| `control.sync.status` | yes | owned | — | `{available, method, pinned_total, pinned_synced, whole_store_trigger_supported}` | +| `control.sync.trigger` | yes | owned | `{store:"storeId:root"}` | `{store_id, root, status, size_bytes, served_root}` | +| `control.updater.status` | yes | owned | — | (proxied beacon status) | +| `control.updater.setChannel` | yes | owned | `{channel:string}` | (proxied) | +| `control.updater.pause` | yes | owned | `{until?:u64}` | (proxied) | +| `control.updater.resume` | yes | owned | — | (proxied) | +| `control.updater.checkNow` | yes | owned | — | (proxied) | +| `control.pairing.list` | master | owned | — | (pending + issued tokens) | +| `control.pairing.approve` | master | owned | `{pairing_id:string}` | `{approved, client_name, token_id}` | +| `control.pairing.revoke` | master | owned | `{token_id:string}` | `{revoked, token_id}` | +| `control.peerStatus` | yes | delegated | — | (peer-pool snapshot) | +| `control.peers.connect` | yes | delegated | `{peer:string}` | `{connected, peer_id}` | +| `control.peers.disconnect` | yes | delegated | `{peer:string}` | `{disconnected, peer_id}` | +| `control.subscribe` | yes | delegated | `{store_id:string}` | `{subscribed, added, store_id}` | +| `control.unsubscribe` | yes | delegated | `{store_id:string}` | `{subscribed, removed, store_id}` | +| `control.listSubscriptions` | yes | delegated | — | `{subscriptions:[string], count}` | +| `pairing.request` | no | open | `{client_name:string}` | `{pairing_id, pairing_code, expires_ms}` | +| `pairing.poll` | no | open | `{pairing_id:string}` | `{status, token?}` | + +### 4.1 Result field definitions + +- **`StatusResult`**: `{running:bool, service:string, version:string, commit:string, protocol:string, + uptime_secs:u64, addr:string, upstream:string, cache:CacheView, hosted_store_count:u64, + cached_capsule_count:u64, pinned_store_count:u64, sync:{available:bool}}`. +- **`ConfigResult`**: `{addr:string, port:string, upstream:string, upstream_override:string|null, + cache_dir:string, cache_shared:bool, config_path:string, sync_available:bool}`. + `upstream_override` MUST be present as `null` when unset (never omitted). +- **`CacheView`**: `{cap_bytes:u64, used_bytes:u64, dir:string, shared:bool}`. +- **`HostedStore`**: `{store_id:string, pinned:bool, capsule_count:u64, total_bytes:u64, + capsules:[CapsuleEntry]}`. +- **`CapsuleEntry`**: `{capsule:"storeId:root", root:string, size_bytes:u64, last_used_unix_ms:u64}`. +- **`pairing.poll` token**: the `token` field MUST be omitted while `status` is not `approved`, and + present exactly once after approval. + +Proxied results (`control.updater.*`, `control.pairing.list`, `control.peerStatus`) carry the +underlying source's shape verbatim and are modelled as an opaque JSON value; consumers MUST NOT freeze +a struct over them. + +## 5. Error taxonomy + +The numeric codes are a published wire contract and never change once assigned. `origin` classifies +where the error was minted. + +| Code | Symbol | Origin | Meaning | +|---|---|---|---| +| `-32700` | `PARSE_ERROR` | shell | request body was not valid JSON | +| `-32600` | `INVALID_REQUEST` | shell | not a single JSON-RPC object | +| `-32601` | `METHOD_NOT_FOUND` | boundary | control method is not resolved | +| `-32602` | `INVALID_PARAMS` | node | missing/malformed params | +| `-32000` | `DISPATCH_FAILED` | shell | the node failed to dispatch a well-formed call | +| `-32030` | `UNAUTHORIZED` | shell | a `control.*` method called without a valid token | +| `-32031` | `NOT_SUPPORTED` | shell | control op unsupported on this build (e.g. §21 sync with no identity) | +| `-32032` | `CONTROL_ERROR` | shell | a control op failed at runtime | + +The `-32020..-32022` band is RESERVED for onion routing (dig-rpc-protocol); the control-plane errors +use `-32030..-32032`. + +## 6. Conformance -Reserved for the request/response method names, parameter/result types, and the associated error -taxonomy. Designed and landed by epic #1110 T2 (dig_ecosystem#1147), which also resolves whether the -existing `Control` tier in dig-rpc-protocol migrates here. +The golden known-answer tests in `src/kats.rs` are normative: golden request vectors (typed call → +exact envelope), golden response vectors (node JSON decodes into the typed result and re-encodes +byte-identically), the golden error envelope, and an end-to-end route through the node-facing +`ControlHandler` dispatcher proving every method maps to its typed handler. The node side (T7) and +every client side (T8–T10) MUST pin against these vectors. A change that alters a wire shape MUST +fail a KAT. -## 4. Error taxonomy (TBD — T2) +## 7. Stability -Reserved for the stable control-error codes clients key their UX off, analogous to dig-ipc-protocol's -`SignErrorCode`. +1. `ControlMethod` and `ControlErrorCode` are `#[non_exhaustive]`; adding a method/code is an additive + MINOR change. +2. Method wire names and error numeric values never change once assigned. +3. Result fields are additive: a new optional field is MINOR; removing/renaming/repurposing a field + is a BREAKING change. +4. The catalog MUST mirror the live dig-node surface exactly (`dig-node-service/src/control.rs` owned + methods + the `dig-node-core` delegated peer/subscription methods); a divergence is a drift bug. -## 5. Conformance +## 8. wasm / JS byte-agreement -Golden known-answer tests (KATs) proving the wire shapes are byte-exact across independent -implementations land alongside the method catalog in T2. +The catalog types are plain serde structs with no non-wasm dependencies, so a browser/extension client +(T5's `wasm-bindgen` binding) serializes them to identical JSON. The `serde_json::Value`-typed proxied +results and the `#[serde(untagged)]` `RequestId` are the only shapes needing a JS-side check; T5 adds a +Rust↔wasm/JS byte-identical KAT over the vectors in §6. diff --git a/src/envelope.rs b/src/envelope.rs new file mode 100644 index 0000000..218f9f4 --- /dev/null +++ b/src/envelope.rs @@ -0,0 +1,180 @@ +//! The minimal JSON-RPC 2.0 envelope the control catalog rides in. +//! +//! Transport-agnostic: a [`JsonRpcRequest`] carries a control method + its typed params, and a +//! [`JsonRpcResponse`] carries either a `result` or an `error` ([`ControlError`]). The catalog does +//! not own the transport (dig-ipc session, loopback-mTLS HTTP/WebSocket) — only these payload +//! shapes that travel over it. The shapes are byte-identical to what dig-node emits so both sides +//! serialize the same bytes. + +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +use crate::error::ControlError; + +/// A JSON-RPC 2.0 request id: a number, a string, or null. dig-node accepts all three and echoes +/// the id verbatim on the response. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(untagged)] +pub enum RequestId { + /// A numeric id (the common case). + Number(i64), + /// A string id. + String(String), + /// A null id. + Null, +} + +impl From for RequestId { + fn from(n: i64) -> Self { + RequestId::Number(n) + } +} + +impl From<&str> for RequestId { + fn from(s: &str) -> Self { + RequestId::String(s.to_string()) + } +} + +/// A JSON-RPC 2.0 request: `{jsonrpc:"2.0", id, method, params}`. +/// +/// `params` is a raw [`Value`] so the envelope stays method-agnostic; the typed params structs in +/// [`crate::params`] serialize into it via [`crate::traits::build_request`]. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct JsonRpcRequest { + /// Always `"2.0"`. + pub jsonrpc: String, + /// The request id, echoed on the response. + pub id: RequestId, + /// The control method's wire name (see [`crate::ControlMethod::name`]). + pub method: String, + /// The method params object. + pub params: Value, +} + +impl JsonRpcRequest { + /// Build a request for `method` with `params`, stamping `jsonrpc:"2.0"`. + pub fn new(id: RequestId, method: impl Into, params: Value) -> JsonRpcRequest { + JsonRpcRequest { + jsonrpc: "2.0".to_string(), + id, + method: method.into(), + params, + } + } +} + +/// A JSON-RPC 2.0 response: exactly one of `result` or `error` is present. +/// +/// Modelled as two `Option` fields (matching the wire) rather than an enum, because dig-node emits +/// the flat `{jsonrpc, id, result}` / `{jsonrpc, id, error}` shapes and clients read them directly. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct JsonRpcResponse { + /// Always `"2.0"`. + pub jsonrpc: String, + /// The id echoed from the request. + pub id: RequestId, + /// The success result (present iff `error` is absent). + #[serde(skip_serializing_if = "Option::is_none", default)] + pub result: Option, + /// The error (present iff `result` is absent). + #[serde(skip_serializing_if = "Option::is_none", default)] + pub error: Option, +} + +impl JsonRpcResponse { + /// Build a success response carrying `result`. + pub fn success(id: RequestId, result: Value) -> JsonRpcResponse { + JsonRpcResponse { + jsonrpc: "2.0".to_string(), + id, + result: Some(result), + error: None, + } + } + + /// Build an error response carrying `error`. + pub fn error(id: RequestId, error: ControlError) -> JsonRpcResponse { + JsonRpcResponse { + jsonrpc: "2.0".to_string(), + id, + result: None, + error: Some(error), + } + } + + /// The result value if this is a success, else the [`ControlError`]. + pub fn into_result(self) -> Result { + match (self.result, self.error) { + (Some(v), _) => Ok(v), + (None, Some(e)) => Err(e), + (None, None) => Err(ControlError::of( + crate::error::ControlErrorCode::ControlError, + "malformed JSON-RPC response: neither result nor error present", + )), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::error::ControlErrorCode; + use serde_json::json; + + #[test] + fn request_serializes_to_the_canonical_shape() { + let req = JsonRpcRequest::new(1.into(), "control.status", json!({})); + let v = serde_json::to_value(&req).unwrap(); + assert_eq!( + v, + json!({"jsonrpc":"2.0","id":1,"method":"control.status","params":{}}) + ); + } + + #[test] + fn request_id_supports_number_string_and_null() { + assert_eq!(serde_json::to_value(RequestId::from(7)).unwrap(), json!(7)); + assert_eq!( + serde_json::to_value(RequestId::from("abc")).unwrap(), + json!("abc") + ); + assert_eq!(serde_json::to_value(RequestId::Null).unwrap(), json!(null)); + assert_eq!( + serde_json::from_value::(json!("z")).unwrap(), + RequestId::String("z".into()) + ); + } + + #[test] + fn success_response_omits_the_error_field() { + let resp = JsonRpcResponse::success(1.into(), json!({"ok":true})); + let v = serde_json::to_value(&resp).unwrap(); + assert_eq!(v, json!({"jsonrpc":"2.0","id":1,"result":{"ok":true}})); + assert_eq!(resp.into_result().unwrap(), json!({"ok":true})); + } + + #[test] + fn error_response_omits_the_result_field_and_unwraps_to_err() { + let resp = JsonRpcResponse::error( + 1.into(), + ControlError::of(ControlErrorCode::Unauthorized, "no token"), + ); + let v = serde_json::to_value(&resp).unwrap(); + assert_eq!(v["error"]["data"]["code"], "UNAUTHORIZED"); + assert!(v.get("result").is_none()); + assert!(resp.into_result().is_err()); + } + + #[test] + fn a_response_with_neither_field_is_a_control_error() { + let resp = JsonRpcResponse { + jsonrpc: "2.0".into(), + id: RequestId::Number(1), + result: None, + error: None, + }; + let err = resp.into_result().unwrap_err(); + assert_eq!(err.code_enum(), Some(ControlErrorCode::ControlError)); + } +} diff --git a/src/error.rs b/src/error.rs new file mode 100644 index 0000000..df2e9ff --- /dev/null +++ b/src/error.rs @@ -0,0 +1,228 @@ +//! The canonical control-plane error taxonomy. +//! +//! One definition point for every error code a dig-node control method emits, the [`ControlError`] +//! envelope (`{code, message, data:{code, origin}}`), and the constructor both sides use so every +//! error carries a machine-branchable `data.code` (UPPER_SNAKE) and `data.origin`. +//! +//! The numeric values are the SAME published wire contract as dig-node's `meta::ErrorCode` and the +//! canonical dig-rpc `-320xx` control range — they never change once assigned. A client keys its UX +//! off `data.code` (the stable symbol), never the human `message`. +//! +//! | Code | Variant | Origin | Meaning | +//! |---|---|---|---| +//! | `-32700` | [`ParseError`](ControlErrorCode::ParseError) | shell | request body was not valid JSON | +//! | `-32600` | [`InvalidRequest`](ControlErrorCode::InvalidRequest) | shell | not a single JSON-RPC object | +//! | `-32601` | [`MethodNotFound`](ControlErrorCode::MethodNotFound) | boundary | control method is not resolved | +//! | `-32602` | [`InvalidParams`](ControlErrorCode::InvalidParams) | node | missing/malformed params | +//! | `-32000` | [`DispatchFailed`](ControlErrorCode::DispatchFailed) | shell | the node failed to dispatch | +//! | `-32030` | [`Unauthorized`](ControlErrorCode::Unauthorized) | shell | called without a valid control token | +//! | `-32031` | [`NotSupported`](ControlErrorCode::NotSupported) | shell | control op unsupported on this build | +//! | `-32032` | [`ControlError`](ControlErrorCode::ControlError) | shell | control op failed at runtime | + +use serde::{Deserialize, Serialize}; + +/// A canonical control-plane error code. +/// +/// `#[non_exhaustive]` so adding a code in a minor release is additive; downstream matches must use +/// a `_ => …` arm. +#[non_exhaustive] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum ControlErrorCode { + /// `-32700` — request body was not valid JSON. + ParseError, + /// `-32600` — the request was not a single JSON-RPC object (batches are unsupported). + InvalidRequest, + /// `-32601` — the control method is not resolved by the node. + MethodNotFound, + /// `-32602` — invalid or missing method parameters. + InvalidParams, + /// `-32000` — the node failed to dispatch a well-formed control call. + DispatchFailed, + /// `-32030` — a `control.*` method was called without a valid local control token. + Unauthorized, + /// `-32031` — the control operation is not supported on this node build (e.g. §21 sync with no + /// identity loaded, or a capability the running engine does not expose). + NotSupported, + /// `-32032` — a control operation failed at runtime (e.g. a config write or cache op errored). + ControlError, +} + +impl ControlErrorCode { + /// The numeric JSON-RPC error code. + pub const fn code(self) -> i64 { + match self { + ControlErrorCode::ParseError => -32700, + ControlErrorCode::InvalidRequest => -32600, + ControlErrorCode::MethodNotFound => -32601, + ControlErrorCode::InvalidParams => -32602, + ControlErrorCode::DispatchFailed => -32000, + ControlErrorCode::Unauthorized => -32030, + ControlErrorCode::NotSupported => -32031, + ControlErrorCode::ControlError => -32032, + } + } + + /// The stable UPPER_SNAKE symbolic name a client branches on. Never derived from the message. + pub const fn name(self) -> &'static str { + match self { + ControlErrorCode::ParseError => "PARSE_ERROR", + ControlErrorCode::InvalidRequest => "INVALID_REQUEST", + ControlErrorCode::MethodNotFound => "METHOD_NOT_FOUND", + ControlErrorCode::InvalidParams => "INVALID_PARAMS", + ControlErrorCode::DispatchFailed => "DISPATCH_FAILED", + ControlErrorCode::Unauthorized => "UNAUTHORIZED", + ControlErrorCode::NotSupported => "NOT_SUPPORTED", + ControlErrorCode::ControlError => "CONTROL_ERROR", + } + } + + /// Where the error originates, mirroring dig-node's classification. + pub const fn origin(self) -> &'static str { + match self { + ControlErrorCode::MethodNotFound => "boundary", + ControlErrorCode::InvalidParams => "node", + _ => "shell", + } + } + + /// A one-line description for the error catalogue. + pub const fn description(self) -> &'static str { + match self { + ControlErrorCode::ParseError => "Request body was not valid JSON.", + ControlErrorCode::InvalidRequest => { + "Request was not a single JSON-RPC object (batch arrays are not supported)." + } + ControlErrorCode::MethodNotFound => "The control method is not resolved by the node.", + ControlErrorCode::InvalidParams => "Invalid or missing method parameters.", + ControlErrorCode::DispatchFailed => "The node failed to dispatch the control request.", + ControlErrorCode::Unauthorized => { + "A control.* method was called without a valid local control token." + } + ControlErrorCode::NotSupported => { + "The requested control operation is not supported on this node build." + } + ControlErrorCode::ControlError => "A control operation failed at runtime.", + } + } + + /// Resolve a numeric wire code back to its variant, or `None` for an uncatalogued code. + pub fn from_code(code: i64) -> Option { + ControlErrorCode::ALL + .iter() + .copied() + .find(|c| c.code() == code) + } + + /// Every catalogued code, for the error-catalogue document + drift tests. + pub const ALL: &'static [ControlErrorCode] = &[ + ControlErrorCode::ParseError, + ControlErrorCode::InvalidRequest, + ControlErrorCode::MethodNotFound, + ControlErrorCode::InvalidParams, + ControlErrorCode::DispatchFailed, + ControlErrorCode::Unauthorized, + ControlErrorCode::NotSupported, + ControlErrorCode::ControlError, + ]; +} + +/// The machine-branchable inner data on a control error: the stable symbol + its origin. +/// +/// Serializes to `{"code": "UNAUTHORIZED", "origin": "shell"}`, matching dig-node's `error.data`. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ControlErrorData { + /// The stable UPPER_SNAKE symbol (e.g. `"UNAUTHORIZED"`) — see [`ControlErrorCode::name`]. + pub code: String, + /// Where the error originated (`shell` / `node` / `boundary`) — see [`ControlErrorCode::origin`]. + pub origin: String, +} + +/// A control-plane error, serialized as the JSON-RPC `error` object +/// (`{code, message, data:{code, origin}}`). +/// +/// [`code`](ControlError::code) is the numeric wire value; [`data`](ControlError::data) carries the +/// stable symbol + origin a client actually branches on. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ControlError { + /// The numeric JSON-RPC error code. + pub code: i64, + /// A human-readable message (never contract-stable — for display/logs only). + pub message: String, + /// The machine-branchable symbol + origin. + pub data: ControlErrorData, +} + +impl ControlError { + /// Build an error from a catalogued [`ControlErrorCode`] and a human message. + pub fn of(code: ControlErrorCode, message: impl Into) -> ControlError { + ControlError { + code: code.code(), + message: message.into(), + data: ControlErrorData { + code: code.name().to_string(), + origin: code.origin().to_string(), + }, + } + } + + /// The catalogued code, resolved from the numeric wire value (`None` if uncatalogued). + pub fn code_enum(&self) -> Option { + ControlErrorCode::from_code(self.code) + } +} + +impl std::fmt::Display for ControlError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{} ({}): {}", self.data.code, self.code, self.message) + } +} + +impl std::error::Error for ControlError {} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn codes_and_symbols_and_origins_are_stable() { + assert_eq!(ControlErrorCode::Unauthorized.code(), -32030); + assert_eq!(ControlErrorCode::NotSupported.code(), -32031); + assert_eq!(ControlErrorCode::ControlError.code(), -32032); + assert_eq!(ControlErrorCode::InvalidParams.name(), "INVALID_PARAMS"); + assert_eq!(ControlErrorCode::InvalidParams.origin(), "node"); + assert_eq!(ControlErrorCode::MethodNotFound.origin(), "boundary"); + assert_eq!(ControlErrorCode::Unauthorized.origin(), "shell"); + } + + #[test] + fn from_code_round_trips_every_code() { + for &c in ControlErrorCode::ALL { + assert_eq!(ControlErrorCode::from_code(c.code()), Some(c)); + } + assert_eq!(ControlErrorCode::from_code(0), None); + } + + #[test] + fn every_code_has_a_description() { + for &c in ControlErrorCode::ALL { + assert!(!c.description().is_empty()); + } + } + + #[test] + fn error_serializes_to_the_canonical_json_shape() { + let e = ControlError::of(ControlErrorCode::Unauthorized, "no token"); + let v = serde_json::to_value(&e).unwrap(); + assert_eq!(v["code"], -32030); + assert_eq!(v["message"], "no token"); + assert_eq!(v["data"]["code"], "UNAUTHORIZED"); + assert_eq!(v["data"]["origin"], "shell"); + assert_eq!(e.code_enum(), Some(ControlErrorCode::Unauthorized)); + } + + #[test] + fn display_is_symbol_code_and_message() { + let e = ControlError::of(ControlErrorCode::ControlError, "boom"); + assert_eq!(e.to_string(), "CONTROL_ERROR (-32032): boom"); + } +} diff --git a/src/kats.rs b/src/kats.rs new file mode 100644 index 0000000..30924d5 --- /dev/null +++ b/src/kats.rs @@ -0,0 +1,551 @@ +//! Conformance known-answer tests (KATs) — the anti-drift layer. +//! +//! These pin the exact wire shapes both sides must agree on: golden JSON-RPC request vectors for +//! every method, golden response vectors that the typed results decode-then-re-encode byte-for-byte, +//! and an end-to-end route through a mock [`ControlHandler`] proving the dispatcher maps every +//! method to its typed handler and back. Any client (extension/CLI/hub, T8–T10) and the node side +//! (T7) pin against these; a change that alters a wire shape fails here first. + +use futures::executor::block_on; +use serde_json::{json, Value}; + +use crate::envelope::{JsonRpcRequest, JsonRpcResponse, RequestId}; +use crate::error::{ControlError, ControlErrorCode}; +use crate::method::ControlMethod; +use crate::params::*; +use crate::results; +use crate::traits::{build_request, parse_response, ControlHandler}; + +const STORE: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; +const ROOT: &str = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; + +/// A golden request vector: build the typed call, assert it serializes to the exact envelope bytes. +fn assert_request(call: &C, expected: Value) { + let req = build_request(RequestId::Number(1), call); + assert_eq!( + serde_json::to_value(&req).unwrap(), + expected, + "request wire shape drifted for {}", + C::METHOD.name() + ); +} + +/// A golden response vector: the typed result decodes from `wire` and re-encodes byte-identically. +fn assert_result_round_trips(wire: Value) +where + T: serde::Serialize + serde::de::DeserializeOwned, +{ + let parsed: T = serde_json::from_value(wire.clone()).expect("golden result must decode"); + assert_eq!( + serde_json::to_value(&parsed).unwrap(), + wire, + "result wire shape is not byte-stable" + ); +} + +#[test] +fn golden_request_vectors() { + assert_request( + &StatusParams {}, + json!({"jsonrpc":"2.0","id":1,"method":"control.status","params":{}}), + ); + assert_request( + &SetUpstreamParams { + upstream: "https://rpc.dig.net".into(), + }, + json!({"jsonrpc":"2.0","id":1,"method":"control.config.setUpstream","params":{"upstream":"https://rpc.dig.net"}}), + ); + assert_request( + &SetLevelParams { + filter: "info,dig_node_core=debug".into(), + }, + json!({"jsonrpc":"2.0","id":1,"method":"control.log.setLevel","params":{"filter":"info,dig_node_core=debug"}}), + ); + assert_request( + &SetCapParams { + cap_bytes: 67108864, + }, + json!({"jsonrpc":"2.0","id":1,"method":"control.cache.setCap","params":{"cap_bytes":67108864}}), + ); + assert_request( + &PinParams { + store: format!("{STORE}:{ROOT}"), + }, + json!({"jsonrpc":"2.0","id":1,"method":"control.hostedStores.pin","params":{"store":format!("{STORE}:{ROOT}")}}), + ); + assert_request( + &SyncTriggerParams { + store: format!("{STORE}:{ROOT}"), + }, + json!({"jsonrpc":"2.0","id":1,"method":"control.sync.trigger","params":{"store":format!("{STORE}:{ROOT}")}}), + ); + assert_request( + &PauseParams { + until: Some(1_800_000_000), + }, + json!({"jsonrpc":"2.0","id":1,"method":"control.updater.pause","params":{"until":1800000000}}), + ); + assert_request( + &ApproveParams { + pairing_id: "pid-1".into(), + }, + json!({"jsonrpc":"2.0","id":1,"method":"control.pairing.approve","params":{"pairing_id":"pid-1"}}), + ); + assert_request( + &PeersConnectParams { + peer: "1.2.3.4:9257".into(), + }, + json!({"jsonrpc":"2.0","id":1,"method":"control.peers.connect","params":{"peer":"1.2.3.4:9257"}}), + ); + assert_request( + &SubscribeParams { + store_id: STORE.into(), + }, + json!({"jsonrpc":"2.0","id":1,"method":"control.subscribe","params":{"store_id":STORE}}), + ); + assert_request( + &RequestParams { + client_name: "DIG extension".into(), + }, + json!({"jsonrpc":"2.0","id":1,"method":"pairing.request","params":{"client_name":"DIG extension"}}), + ); +} + +#[test] +fn golden_response_result_vectors_are_byte_stable() { + assert_result_round_trips::(json!({ + "running": true, "service": "dig-node", "version": "0.30.0", "commit": "deadbee", + "protocol": "21", "uptime_secs": 42, "addr": "127.0.0.1:9256", + "upstream": "https://rpc.dig.net", + "cache": {"cap_bytes": 67108864, "used_bytes": 1024, "dir": "/var/cache/dig", "shared": true}, + "hosted_store_count": 3, "cached_capsule_count": 5, "pinned_store_count": 2, + "sync": {"available": true} + })); + assert_result_round_trips::(json!({ + "addr": "127.0.0.1:9256", "port": "9256", "upstream": "https://rpc.dig.net", + "upstream_override": null, "cache_dir": "/var/cache/dig", "cache_shared": true, + "config_path": "/etc/dig/config.json", "sync_available": false + })); + assert_result_round_trips::(json!({ + "cap_bytes": 67108864, "used_bytes": 0, "dir": "/c", "shared": false + })); + assert_result_round_trips::(json!({ + "stores": [{ + "store_id": STORE, "pinned": true, "capsule_count": 1, "total_bytes": 10, + "capsules": [{"capsule": format!("{STORE}:{ROOT}"), "root": ROOT, + "size_bytes": 10, "last_used_unix_ms": 1700000000000u64}] + }] + })); + assert_result_round_trips::(json!({ + "available": true, "method": "section-21-whole-store-sync", + "pinned_total": 2, "pinned_synced": 1, "whole_store_trigger_supported": false + })); + assert_result_round_trips::(json!({ + "store_id": STORE, "root": ROOT, "status": "synced", + "size_bytes": 2048, "served_root": ROOT + })); + assert_result_round_trips::(json!({ + "subscribed": true, "added": true, "store_id": STORE + })); + assert_result_round_trips::(json!({ + "subscriptions": [STORE], "count": 1 + })); + assert_result_round_trips::(json!({ + "approved": true, "client_name": "DIG extension", "token_id": "abcd1234" + })); + assert_result_round_trips::(json!({ + "status": "approved", "token": "deadbeef" + })); +} + +#[test] +fn error_envelope_golden_vector() { + let resp = JsonRpcResponse::error( + RequestId::Number(1), + ControlError::of(ControlErrorCode::Unauthorized, "control.* requires a token"), + ); + assert_eq!( + serde_json::to_value(&resp).unwrap(), + json!({ + "jsonrpc": "2.0", "id": 1, + "error": { + "code": -32030, + "message": "control.* requires a token", + "data": {"code": "UNAUTHORIZED", "origin": "shell"} + } + }) + ); +} + +/// A mock node that serves canned typed results — exercises the [`ControlHandler`] dispatcher for +/// every method group without a running node. +struct MockNode; + +#[async_trait::async_trait] +impl ControlHandler for MockNode { + async fn status(&self) -> Result { + Ok(results::StatusResult { + running: true, + service: "dig-node".into(), + version: "0.30.0".into(), + commit: "deadbee".into(), + protocol: "21".into(), + uptime_secs: 1, + addr: "127.0.0.1:9256".into(), + upstream: "https://rpc.dig.net".into(), + cache: results::CacheView { + cap_bytes: 67108864, + used_bytes: 0, + dir: "/c".into(), + shared: false, + }, + hosted_store_count: 0, + cached_capsule_count: 0, + pinned_store_count: 0, + sync: results::SyncAvailability { available: false }, + }) + } + async fn config_get(&self) -> Result { + Err(unimpl("config_get")) + } + async fn config_set_upstream( + &self, + params: SetUpstreamParams, + ) -> Result { + Ok(results::SetUpstreamResult { + upstream: params.upstream, + requires_restart: true, + }) + } + async fn log_set_level( + &self, + params: SetLevelParams, + ) -> Result { + Ok(results::SetLevelResult { + filter: params.filter, + }) + } + async fn cache_get(&self) -> Result { + Err(unimpl("cache_get")) + } + async fn cache_set_cap( + &self, + params: SetCapParams, + ) -> Result { + Ok(results::SetCapResult { + cap_bytes: params.cap_bytes.max(64 * 1024 * 1024), + }) + } + async fn cache_clear(&self) -> Result { + Ok(results::CacheClearResult { cleared: true }) + } + async fn hosted_stores_list(&self) -> Result { + Ok(results::HostedStoresListResult { stores: vec![] }) + } + async fn hosted_stores_pin( + &self, + params: PinParams, + ) -> Result { + Ok(results::PinResult { + store_id: params.store, + root: None, + pinned: true, + fetch: json!({"status": "skipped"}), + }) + } + async fn hosted_stores_unpin( + &self, + params: UnpinParams, + ) -> Result { + Ok(results::UnpinResult { + store_id: params.store, + unpinned: true, + evicted_capsules: 0, + }) + } + async fn hosted_stores_status( + &self, + params: HostedStoreStatusParams, + ) -> Result { + Ok(results::HostedStoreStatusResult { + store_id: params.store, + pinned: false, + capsule_count: 0, + total_bytes: 0, + capsules: vec![], + }) + } + async fn sync_status(&self) -> Result { + Err(unimpl("sync_status")) + } + async fn sync_trigger( + &self, + params: SyncTriggerParams, + ) -> Result { + let (store_id, root) = params.store.split_once(':').unwrap_or((¶ms.store, "")); + Ok(results::SyncTriggerResult { + store_id: store_id.into(), + root: root.into(), + status: "synced".into(), + size_bytes: 1, + served_root: root.into(), + }) + } + async fn updater_status(&self) -> Result { + Ok(json!({"channel": "stable"})) + } + async fn updater_set_channel(&self, params: SetChannelParams) -> Result { + Ok(json!({"channel": params.channel})) + } + async fn updater_pause(&self, params: PauseParams) -> Result { + Ok(json!({"paused": true, "until": params.until})) + } + async fn updater_resume(&self) -> Result { + Ok(json!({"paused": false})) + } + async fn updater_check_now(&self) -> Result { + Ok(json!({"checked": true})) + } + async fn pairing_list(&self) -> Result { + Ok(json!({"pending": [], "tokens": []})) + } + async fn pairing_approve( + &self, + params: ApproveParams, + ) -> Result { + Ok(results::PairingApproveResult { + approved: true, + client_name: params.pairing_id, + token_id: "abcd1234".into(), + }) + } + async fn pairing_revoke( + &self, + params: RevokeParams, + ) -> Result { + Ok(results::PairingRevokeResult { + revoked: true, + token_id: params.token_id, + }) + } + async fn peer_status(&self) -> Result { + Ok(json!({"running": false})) + } + async fn peers_connect( + &self, + params: PeersConnectParams, + ) -> Result { + Ok(results::PeersConnectResult { + connected: true, + peer_id: params.peer, + }) + } + async fn peers_disconnect( + &self, + params: PeersDisconnectParams, + ) -> Result { + Ok(results::PeersDisconnectResult { + disconnected: true, + peer_id: params.peer, + }) + } + async fn subscribe( + &self, + params: SubscribeParams, + ) -> Result { + Ok(results::SubscribeResult { + subscribed: true, + added: true, + store_id: params.store_id, + }) + } + async fn unsubscribe( + &self, + params: UnsubscribeParams, + ) -> Result { + Ok(results::UnsubscribeResult { + subscribed: false, + removed: true, + store_id: params.store_id, + }) + } + async fn list_subscriptions(&self) -> Result { + Ok(results::ListSubscriptionsResult { + subscriptions: vec![], + count: 0, + }) + } + async fn pairing_request( + &self, + _params: RequestParams, + ) -> Result { + Ok(results::PairingRequestResult { + pairing_id: "pid-1".into(), + pairing_code: "012345".into(), + expires_ms: 1_700_000_000_000, + }) + } + async fn pairing_poll( + &self, + _params: PollParams, + ) -> Result { + Ok(results::PairingPollResult { + status: "pending".into(), + token: None, + }) + } +} + +fn unimpl(what: &str) -> ControlError { + ControlError::of(ControlErrorCode::NotSupported, format!("{what} not mocked")) +} + +/// Route a typed call through the mock node's dispatcher and parse the typed result back. +fn round_trip(call: &C) -> Result { + let node = MockNode; + let req = build_request(RequestId::Number(1), call); + let resp = block_on(node.dispatch(req)); + parse_response::(resp) +} + +#[test] +fn dispatcher_routes_every_taking_params_method_to_its_typed_handler() { + assert!(round_trip(&StatusParams {}).unwrap().running); + assert_eq!( + round_trip(&SetCapParams { cap_bytes: 1 }) + .unwrap() + .cap_bytes, + 64 * 1024 * 1024, + "the node floors the cap" + ); + assert!(round_trip(&CacheClearParams {}).unwrap().cleared); + let pin = round_trip(&PinParams { + store: STORE.into(), + }) + .unwrap(); + assert_eq!(pin.store_id, STORE); + let sync = round_trip(&SyncTriggerParams { + store: format!("{STORE}:{ROOT}"), + }) + .unwrap(); + assert_eq!(sync.root, ROOT); + let sub = round_trip(&SubscribeParams { + store_id: STORE.into(), + }) + .unwrap(); + assert!(sub.added); + let conn = round_trip(&PeersConnectParams { peer: "p".into() }).unwrap(); + assert_eq!(conn.peer_id, "p"); + assert_eq!( + round_trip(&UpdaterStatusParams {}).unwrap(), + json!({"channel": "stable"}) + ); + assert_eq!( + round_trip(&PollParams { + pairing_id: "x".into() + }) + .unwrap() + .status, + "pending" + ); +} + +#[test] +fn default_control_client_builds_and_parses_via_the_trait() { + use crate::traits::{ControlClient, DefaultControlClient}; + let client = DefaultControlClient; + let req = client.build_request(RequestId::Number(9), &SetCapParams { cap_bytes: 5 }); + assert_eq!(req.id, RequestId::Number(9)); + assert_eq!(req.method, "control.cache.setCap"); + let resp = JsonRpcResponse::success(RequestId::Number(9), json!({"cap_bytes": 5})); + let out = client + .parse_response::(resp) + .expect("typed parse"); + assert_eq!(out.cap_bytes, 5); +} + +#[test] +fn every_method_maps_to_a_category() { + use crate::method::Category; + // Touch the category of every method so the grouping table can't silently lose an arm. + assert_eq!(ControlMethod::Status.category(), Category::Status); + assert_eq!(ControlMethod::PeerStatus.category(), Category::Peers); + for &m in ControlMethod::ALL { + let _ = m.category(); + } +} + +#[test] +fn dispatcher_rejects_an_unknown_method_with_method_not_found() { + let node = MockNode; + let req = JsonRpcRequest::new(RequestId::Number(1), "control.nope", json!({})); + let resp = block_on(node.dispatch(req)); + let err = resp.into_result().unwrap_err(); + assert_eq!(err.code_enum(), Some(ControlErrorCode::MethodNotFound)); +} + +#[test] +fn dispatcher_maps_malformed_params_to_invalid_params() { + let node = MockNode; + // `control.cache.setCap` needs a numeric `cap_bytes`; a string is malformed. + let req = JsonRpcRequest::new( + RequestId::Number(1), + ControlMethod::CacheSetCap.name(), + json!({"cap_bytes": "not-a-number"}), + ); + let resp = block_on(node.dispatch(req)); + let err = resp.into_result().unwrap_err(); + assert_eq!(err.code_enum(), Some(ControlErrorCode::InvalidParams)); +} + +#[test] +fn dispatcher_surfaces_a_handler_error_verbatim() { + let node = MockNode; + let req = build_request(RequestId::Number(1), &ConfigGetParams {}); + let resp = block_on(node.dispatch(req)); + let err = resp.into_result().unwrap_err(); + assert_eq!(err.code_enum(), Some(ControlErrorCode::NotSupported)); +} + +#[test] +fn every_catalog_method_dispatches_without_panicking() { + // The dispatcher must have an arm for EVERY catalog method — a missing arm would not compile, + // but this also proves no method routes to a MethodNotFound (i.e. the name table + the match + // agree for all 29 methods). + let node = MockNode; + for &m in ControlMethod::ALL { + let req = JsonRpcRequest::new(RequestId::Number(1), m.name(), minimal_params(m)); + let resp = block_on(node.dispatch(req)); + if let Some(err) = &resp.error { + assert_ne!( + err.code_enum(), + Some(ControlErrorCode::MethodNotFound), + "{} routed to MethodNotFound — the dispatcher is missing an arm", + m.name() + ); + } + } +} + +/// The smallest valid params object for a method, so the coverage sweep above never trips +/// `INVALID_PARAMS` for a param-taking method. +fn minimal_params(m: ControlMethod) -> Value { + match m { + ControlMethod::ConfigSetUpstream => json!({"upstream": ""}), + ControlMethod::LogSetLevel => json!({"filter": "info"}), + ControlMethod::CacheSetCap => json!({"cap_bytes": 0}), + ControlMethod::HostedStoresPin + | ControlMethod::HostedStoresUnpin + | ControlMethod::HostedStoresStatus + | ControlMethod::SyncTrigger => json!({"store": STORE}), + ControlMethod::UpdaterSetChannel => json!({"channel": "stable"}), + ControlMethod::UpdaterPause => json!({}), + ControlMethod::PairingApprove => json!({"pairing_id": "x"}), + ControlMethod::PairingRevoke => json!({"token_id": "x"}), + ControlMethod::PeersConnect | ControlMethod::PeersDisconnect => json!({"peer": "p"}), + ControlMethod::Subscribe | ControlMethod::Unsubscribe => json!({"store_id": STORE}), + ControlMethod::PairingRequest => json!({"client_name": "c"}), + ControlMethod::PairingPoll => json!({"pairing_id": "x"}), + _ => json!({}), + } +} diff --git a/src/lib.rs b/src/lib.rs index 2786288..5c73e15 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,59 +1,76 @@ //! # dig-node-control-interface — the canonical client ⇄ dig-node CONTROL interface contract //! //! This crate is the single source of truth for the management/query surface a client — the CLI -//! `dign`, the browser extension, dig-app, or hub — uses to **control and query a running dig-node**: -//! node configuration, cache configuration, peer status, subscription lifecycle, peer connect/ -//! disconnect, and other node-control operations. Both the node (the server side, dispatching these -//! calls) and every client (the callers) depend on THIS crate rather than each maintaining a -//! byte-identical copy of the method catalog, so the two can never silently drift. +//! `dign`, the browser extension, dig-app, or hub — uses to **control and query a running +//! dig-node**: node configuration, cache configuration, hosted/pinned stores, §21 whole-store sync, +//! the peer network, subscription lifecycle, the auto-update beacon, live log level, and the +//! control-token pairing handshake. Both the node (the server side, dispatching these calls) and +//! every client (the callers) depend on THIS crate rather than each maintaining a byte-identical +//! copy of the method catalog, so the two can never silently drift. //! -//! ## Boundary (the 6th directed-boundary contract crate) +//! ## The catalog +//! +//! - [`ControlMethod`] — every control method's stable wire name, auth requirement, routing, and +//! category; the enumeration a machine reads to discover the whole surface. +//! - [`params`] — a typed request-params struct per method, each bound (via [`ControlCall`]) to its +//! method and its typed result. +//! - [`results`] — the typed result payloads, field-for-field with what dig-node emits. +//! - [`error`] — the stable control-error taxonomy ([`ControlErrorCode`]) + the [`ControlError`] +//! envelope a client branches its UX off. +//! - [`envelope`] — the minimal JSON-RPC 2.0 request/response the catalog rides in. +//! - [`traits`] — the two contract traits: [`ControlClient`] (client-facing: build request / parse +//! response) and [`ControlHandler`] (node-facing: implement to serve, with a routing dispatcher). //! -//! The DIG ecosystem draws its cross-process contracts as small, single-purpose crates rather than -//! folding every wire shape into the systems that use them: +//! ## Boundary (the 6th directed-boundary contract crate) //! //! - **dig-rpc-protocol** — node ⇄ node peer wire (PublicRead + Peer tiers). -//! - **dig-ipc-protocol** — app ⇄ node local session/signing envelope (the pipe/transport + the -//! challenge/callback handshake a client authenticates over). -//! - **dig-node-control-interface (this crate)** — the CONTROL METHOD CATALOG a client sends *inside* -//! that authenticated channel (or over loopback-mTLS + a signed control token, CLAUDE.md §5.3, for -//! clients that don't ride the local IPC session): the method names, parameter/result types, and -//! error taxonomy for config/status/peers/subscriptions/cache/wallet control operations. -//! -//! This crate is deliberately **transport-agnostic** — it describes WHAT a client can ask a node to do -//! and what the node replies, not HOW the bytes travel. Consumers pick the transport (dig-ipc-protocol -//! session, loopback HTTP/WebSocket, or a future channel) and carry these types over it. -//! -//! ## Status: skeleton (epic #1110, T1 of the cascade) -//! -//! This is the PROVISIONING commit: the repository, CI/release plumbing, and module layout exist so -//! the contract can be designed and filled in without also bootstrapping infrastructure. The actual -//! method catalog — request/response types, the control-error taxonomy, and the conformance KATs — -//! lands in T2 (tracked as dig_ecosystem#1147). Until then [`control`] is an intentionally empty -//! placeholder module reserved for that catalog. +//! - **dig-ipc-protocol** — app ⇄ node local session/signing envelope (the transport a client +//! authenticates over). +//! - **dig-node-control-interface (this crate)** — the CONTROL METHOD CATALOG a client sends +//! *inside* that authenticated channel (or over loopback-mTLS + a signed control token, per +//! CLAUDE.md §5.3): the method names, parameter/result types, and error taxonomy. +//! +//! This crate is deliberately **transport-agnostic** — it describes WHAT a client can ask a node to +//! do and what the node replies, not HOW the bytes travel. Consumers pick the transport and carry +//! these types over it. +//! +//! ## Example — build a typed request and parse the response +//! +//! ``` +//! use dig_node_control_interface::{ +//! params::SetCapParams, +//! traits::{build_request, parse_response}, +//! envelope::JsonRpcResponse, +//! }; +//! use serde_json::json; +//! +//! let call = SetCapParams { cap_bytes: 128 * 1024 * 1024 }; +//! let req = build_request(1.into(), &call); +//! assert_eq!(req.method, "control.cache.setCap"); +//! +//! // The node replies with the applied cap; parse it back into the typed result. +//! let resp = JsonRpcResponse::success(1.into(), json!({ "cap_bytes": 128 * 1024 * 1024 })); +//! let out = parse_response::(resp).unwrap(); +//! assert_eq!(out.cap_bytes, 128 * 1024 * 1024); +//! ``` #![forbid(unsafe_code)] #![warn(missing_docs)] -/// Reserved for the client↔node control method catalog (request/response types, error taxonomy). -/// -/// Populated by epic #1110 T2 (dig_ecosystem#1147) — the reconciliation decision on migrating the -/// `Control` tier out of dig-rpc-protocol, plus the exact method/type/error set this crate owns. -pub mod control { - /// The crate's semantic version, exposed so consumers can assert compatibility at runtime - /// without re-parsing `Cargo.toml` (mirrors the pattern used by the sibling contract crates). - pub const CRATE_VERSION: &str = env!("CARGO_PKG_VERSION"); -} +pub mod envelope; +pub mod error; +pub mod method; +pub mod params; +pub mod results; +pub mod traits; #[cfg(test)] -mod tests { - use super::control; +mod kats; + +pub use error::{ControlError, ControlErrorCode, ControlErrorData}; +pub use method::{Category, ControlMethod, Routing}; +pub use traits::{ControlCall, ControlClient, ControlHandler, DefaultControlClient}; - /// The skeleton's only behaviour today: the crate reports its own version. This is a canary - /// that the crate builds, links, and exposes `control` as expected — the real conformance KATs - /// arrive with the method catalog in T2. - #[test] - fn crate_version_is_exposed_and_matches_cargo_toml() { - assert_eq!(control::CRATE_VERSION, "0.1.0"); - } -} +/// The crate's semantic version, exposed so consumers can assert compatibility at runtime without +/// re-parsing `Cargo.toml`. +pub const CRATE_VERSION: &str = env!("CARGO_PKG_VERSION"); diff --git a/src/method.rs b/src/method.rs new file mode 100644 index 0000000..61f2c84 --- /dev/null +++ b/src/method.rs @@ -0,0 +1,411 @@ +//! The canonical control-method catalog. +//! +//! [`ControlMethod`] enumerates every method a client can send to a running dig-node's CONTROL +//! plane, its stable wire name, whether it requires the local control token, whether it is a +//! pairing-administration method (which requires the MASTER token specifically), and how the node +//! routes it (owned by the service shell, delegated to the embedded node engine, or an open +//! pairing-bootstrap method reachable without a token). +//! +//! This is the SINGLE source of truth for "what can be controlled". The node dispatchers, the +//! client SDKs (CLI `dign`, the extension, dig-app, hub), the OpenRPC/discovery surface, and the +//! conformance KATs all read this one table, so the method set can never drift between them. +//! +//! Mirrors the live dig-node surface: the shell-owned methods in +//! `dig-node-service/src/control.rs` (`CONTROL_METHODS`) plus the peer/subscription methods +//! delegated to `dig-node-core` (`control.peerStatus` / `control.peers.*` / `control.subscribe` +//! / `control.unsubscribe` / `control.listSubscriptions`), and the two OPEN pairing-bootstrap +//! methods (`pairing.request` / `pairing.poll`) a token-less MV3 extension uses to obtain a +//! scoped token after local operator approval. + +/// How the node resolves a control method — the routing source of truth. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum Routing { + /// Answered by the dig-node service shell itself (config/status/cache/pins/sync/updater/pairing-admin). + Owned, + /// Delegated to the embedded dig-node engine's own control surface (peers + subscriptions). + Delegated, + /// An OPEN bootstrap method reachable WITHOUT the control token (pairing handshake). + OpenBootstrap, +} + +/// The functional area a control method belongs to — for grouping in UIs and docs. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum Category { + /// Node status snapshot. + Status, + /// Node configuration (upstream override). + Config, + /// Live log-level control. + Log, + /// On-disk content cache. + Cache, + /// Hosted/pinned stores. + HostedStores, + /// §21 authenticated whole-store sync. + Sync, + /// The DIG auto-update beacon proxy. + Updater, + /// Control-token pairing lifecycle. + Pairing, + /// The L7 peer network. + Peers, + /// The node's subscribed-store set. + Subscriptions, +} + +/// A dig-node CONTROL method. +/// +/// `#[non_exhaustive]` so adding a method in a minor release is additive; downstream matches must +/// carry a `_ => …` arm. Convert to/from the wire name with [`ControlMethod::name`] / +/// [`ControlMethod::from_name`]. +#[non_exhaustive] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum ControlMethod { + // ---- Status / config / log (shell-owned) ---- + /// `control.status` — a rich node status snapshot. + Status, + /// `control.config.get` — the node's effective configuration. + ConfigGet, + /// `control.config.setUpstream` — persist an upstream-RPC override (effective on restart). + ConfigSetUpstream, + /// `control.log.setLevel` — live-swap the running node's tracing level filter. + LogSetLevel, + + // ---- Cache (shell-owned) ---- + /// `control.cache.get` — the on-disk cache view (cap/used/dir/shared). + CacheGet, + /// `control.cache.setCap` — set the cache size cap (floored at 64 MiB). + CacheSetCap, + /// `control.cache.clear` — delete all locally cached content. + CacheClear, + + // ---- Hosted stores (shell-owned) ---- + /// `control.hostedStores.list` — every held/pinned store with its cached capsules. + HostedStoresList, + /// `control.hostedStores.pin` — pin a store (and pre-fetch when a root is given). + HostedStoresPin, + /// `control.hostedStores.unpin` — unpin a store and evict its cached capsules. + HostedStoresUnpin, + /// `control.hostedStores.status` — per-store pinned flag + cached capsules. + HostedStoresStatus, + + // ---- §21 sync (shell-owned) ---- + /// `control.sync.status` — whether authenticated whole-store sync is available + pin coverage. + SyncStatus, + /// `control.sync.trigger` — trigger a §21 sync for one capsule (storeId + root). + SyncTrigger, + + // ---- Updater beacon proxy (shell-owned) ---- + /// `control.updater.status` — the DIG auto-update beacon's current status. + UpdaterStatus, + /// `control.updater.setChannel` — set the beacon's update channel. + UpdaterSetChannel, + /// `control.updater.pause` — suspend auto-updates (optionally until a unix time). + UpdaterPause, + /// `control.updater.resume` — resume auto-updates. + UpdaterResume, + /// `control.updater.checkNow` — force an immediate update check. + UpdaterCheckNow, + + // ---- Pairing administration (shell-owned, MASTER-token only) ---- + /// `control.pairing.list` — list pending pairing requests + issued paired tokens. + PairingList, + /// `control.pairing.approve` — approve a pending pairing, minting a scoped token. + PairingApprove, + /// `control.pairing.revoke` — revoke an issued paired token. + PairingRevoke, + + // ---- Peers (delegated to the engine) ---- + /// `control.peerStatus` — live peer-pool + relay-reservation snapshot. + PeerStatus, + /// `control.peers.connect` — dial a peer by address / resolve a connected peer_id. + PeersConnect, + /// `control.peers.disconnect` — drop a pooled peer by peer_id. + PeersDisconnect, + + // ---- Subscriptions (delegated to the engine) ---- + /// `control.subscribe` — subscribe the node to a store (watch + gap-fill). + Subscribe, + /// `control.unsubscribe` — stop watching a store. + Unsubscribe, + /// `control.listSubscriptions` — the node's persisted subscription set. + ListSubscriptions, + + // ---- Pairing bootstrap (OPEN — no token) ---- + /// `pairing.request` — request a control-token pairing (returns a code to compare). + PairingRequest, + /// `pairing.poll` — poll a pairing; once the operator approves, returns the scoped token once. + PairingPoll, +} + +impl ControlMethod { + /// The stable JSON-RPC wire name. Never derived from anything else — the published contract. + pub const fn name(self) -> &'static str { + match self { + ControlMethod::Status => "control.status", + ControlMethod::ConfigGet => "control.config.get", + ControlMethod::ConfigSetUpstream => "control.config.setUpstream", + ControlMethod::LogSetLevel => "control.log.setLevel", + ControlMethod::CacheGet => "control.cache.get", + ControlMethod::CacheSetCap => "control.cache.setCap", + ControlMethod::CacheClear => "control.cache.clear", + ControlMethod::HostedStoresList => "control.hostedStores.list", + ControlMethod::HostedStoresPin => "control.hostedStores.pin", + ControlMethod::HostedStoresUnpin => "control.hostedStores.unpin", + ControlMethod::HostedStoresStatus => "control.hostedStores.status", + ControlMethod::SyncStatus => "control.sync.status", + ControlMethod::SyncTrigger => "control.sync.trigger", + ControlMethod::UpdaterStatus => "control.updater.status", + ControlMethod::UpdaterSetChannel => "control.updater.setChannel", + ControlMethod::UpdaterPause => "control.updater.pause", + ControlMethod::UpdaterResume => "control.updater.resume", + ControlMethod::UpdaterCheckNow => "control.updater.checkNow", + ControlMethod::PairingList => "control.pairing.list", + ControlMethod::PairingApprove => "control.pairing.approve", + ControlMethod::PairingRevoke => "control.pairing.revoke", + ControlMethod::PeerStatus => "control.peerStatus", + ControlMethod::PeersConnect => "control.peers.connect", + ControlMethod::PeersDisconnect => "control.peers.disconnect", + ControlMethod::Subscribe => "control.subscribe", + ControlMethod::Unsubscribe => "control.unsubscribe", + ControlMethod::ListSubscriptions => "control.listSubscriptions", + ControlMethod::PairingRequest => "pairing.request", + ControlMethod::PairingPoll => "pairing.poll", + } + } + + /// Resolve a wire name back to its [`ControlMethod`], or `None` for an unknown name. + pub fn from_name(name: &str) -> Option { + ControlMethod::ALL + .iter() + .copied() + .find(|m| m.name() == name) + } + + /// Does calling this method require the local control token? + /// + /// Every `control.*` method is token-gated; the two OPEN pairing-bootstrap methods + /// (`pairing.request` / `pairing.poll`) are not, so a token-less client can obtain a token. + pub const fn requires_auth(self) -> bool { + !matches!( + self, + ControlMethod::PairingRequest | ControlMethod::PairingPoll + ) + } + + /// Is this a PAIRING-ADMINISTRATION method that requires the MASTER control token specifically? + /// + /// A paired (scoped) token can drive ordinary `control.*` mutations but MUST NOT mint more + /// tokens or revoke itself — so listing/approving/revoking pairings requires the master token + /// (a local file read), never a paired token. + pub const fn is_pairing_admin(self) -> bool { + matches!( + self, + ControlMethod::PairingList + | ControlMethod::PairingApprove + | ControlMethod::PairingRevoke + ) + } + + /// How the node routes this method (shell-owned, engine-delegated, or open bootstrap). + pub const fn routing(self) -> Routing { + match self { + ControlMethod::PeerStatus + | ControlMethod::PeersConnect + | ControlMethod::PeersDisconnect + | ControlMethod::Subscribe + | ControlMethod::Unsubscribe + | ControlMethod::ListSubscriptions => Routing::Delegated, + ControlMethod::PairingRequest | ControlMethod::PairingPoll => Routing::OpenBootstrap, + _ => Routing::Owned, + } + } + + /// The functional area this method belongs to. + pub const fn category(self) -> Category { + match self { + ControlMethod::Status => Category::Status, + ControlMethod::ConfigGet | ControlMethod::ConfigSetUpstream => Category::Config, + ControlMethod::LogSetLevel => Category::Log, + ControlMethod::CacheGet | ControlMethod::CacheSetCap | ControlMethod::CacheClear => { + Category::Cache + } + ControlMethod::HostedStoresList + | ControlMethod::HostedStoresPin + | ControlMethod::HostedStoresUnpin + | ControlMethod::HostedStoresStatus => Category::HostedStores, + ControlMethod::SyncStatus | ControlMethod::SyncTrigger => Category::Sync, + ControlMethod::UpdaterStatus + | ControlMethod::UpdaterSetChannel + | ControlMethod::UpdaterPause + | ControlMethod::UpdaterResume + | ControlMethod::UpdaterCheckNow => Category::Updater, + ControlMethod::PairingList + | ControlMethod::PairingApprove + | ControlMethod::PairingRevoke + | ControlMethod::PairingRequest + | ControlMethod::PairingPoll => Category::Pairing, + ControlMethod::PeerStatus + | ControlMethod::PeersConnect + | ControlMethod::PeersDisconnect => Category::Peers, + ControlMethod::Subscribe + | ControlMethod::Unsubscribe + | ControlMethod::ListSubscriptions => Category::Subscriptions, + } + } + + /// A one-line human/agent description for the discovery catalogue. + pub const fn summary(self) -> &'static str { + match self { + ControlMethod::Status => "A rich node status snapshot (version, uptime, addr, cache, hosted/pinned counts, sync availability).", + ControlMethod::ConfigGet => "The node's effective configuration (addr/port, upstream + override, cache dir/shared, config path, sync availability).", + ControlMethod::ConfigSetUpstream => "Persist an upstream-RPC override; takes effect on next node start (requires_restart).", + ControlMethod::LogSetLevel => "Live-swap the running node's tracing EnvFilter directive (not persisted).", + ControlMethod::CacheGet => "The on-disk content-cache view: cap_bytes, used_bytes, dir, shared.", + ControlMethod::CacheSetCap => "Set the on-disk cache size cap in bytes (floored at 64 MiB).", + ControlMethod::CacheClear => "Delete all locally cached DIG content.", + ControlMethod::HostedStoresList => "Every held/pinned store, merged, with each store's cached capsules and a pinned flag.", + ControlMethod::HostedStoresPin => "Pin a store (storeId[:rootHash]); pre-fetches the capsule when a root is given and §21 sync is available.", + ControlMethod::HostedStoresUnpin => "Unpin a store and evict its cached capsules.", + ControlMethod::HostedStoresStatus => "Per-store status: pinned flag, cached capsules, total bytes.", + ControlMethod::SyncStatus => "Whether authenticated §21 whole-store sync is available, plus pinned-store cache coverage.", + ControlMethod::SyncTrigger => "Trigger a §21 sync for one capsule (storeId + root).", + ControlMethod::UpdaterStatus => "The DIG auto-update beacon's current status (proxied from dig-updater).", + ControlMethod::UpdaterSetChannel => "Set the beacon's update channel (\"nightly\" | \"stable\").", + ControlMethod::UpdaterPause => "Suspend the beacon's auto-updates (optionally until a unix time).", + ControlMethod::UpdaterResume => "Resume the beacon's auto-updates.", + ControlMethod::UpdaterCheckNow => "Force an immediate beacon update check.", + ControlMethod::PairingList => "List pending pairing requests and issued paired tokens (MASTER token only).", + ControlMethod::PairingApprove => "Approve a pending pairing, minting a scoped token (MASTER token only).", + ControlMethod::PairingRevoke => "Revoke an issued paired token by token_id (MASTER token only).", + ControlMethod::PeerStatus => "Live peer-pool + relay-reservation snapshot, including the per-peer connected array.", + ControlMethod::PeersConnect => "Dial a peer by address, or resolve an already-connected peer_id, via the live gossip pool.", + ControlMethod::PeersDisconnect => "Drop a pooled peer by peer_id, closing its mTLS link (idempotent).", + ControlMethod::Subscribe => "Subscribe the node to a store it actively watches and gap-fills.", + ControlMethod::Unsubscribe => "Stop watching a store.", + ControlMethod::ListSubscriptions => "The node's persisted subscription set + count.", + ControlMethod::PairingRequest => "OPEN: request a control-token pairing; returns a pairing_id + pairing_code to compare.", + ControlMethod::PairingPoll => "OPEN: poll a pairing by id; once the operator approves, returns the scoped token once.", + } + } + + /// Every catalogued method, in a stable order — the enumeration a machine reads to discover the + /// full control surface, and the anchor the conformance KATs pin against. + pub const ALL: &'static [ControlMethod] = &[ + ControlMethod::Status, + ControlMethod::ConfigGet, + ControlMethod::ConfigSetUpstream, + ControlMethod::LogSetLevel, + ControlMethod::CacheGet, + ControlMethod::CacheSetCap, + ControlMethod::CacheClear, + ControlMethod::HostedStoresList, + ControlMethod::HostedStoresPin, + ControlMethod::HostedStoresUnpin, + ControlMethod::HostedStoresStatus, + ControlMethod::SyncStatus, + ControlMethod::SyncTrigger, + ControlMethod::UpdaterStatus, + ControlMethod::UpdaterSetChannel, + ControlMethod::UpdaterPause, + ControlMethod::UpdaterResume, + ControlMethod::UpdaterCheckNow, + ControlMethod::PairingList, + ControlMethod::PairingApprove, + ControlMethod::PairingRevoke, + ControlMethod::PeerStatus, + ControlMethod::PeersConnect, + ControlMethod::PeersDisconnect, + ControlMethod::Subscribe, + ControlMethod::Unsubscribe, + ControlMethod::ListSubscriptions, + ControlMethod::PairingRequest, + ControlMethod::PairingPoll, + ]; +} + +#[cfg(test)] +mod tests { + use super::*; + use std::collections::BTreeSet; + + #[test] + fn every_method_has_a_unique_wire_name() { + let names: BTreeSet<&str> = ControlMethod::ALL.iter().map(|m| m.name()).collect(); + assert_eq!( + names.len(), + ControlMethod::ALL.len(), + "duplicate or missing wire names in the catalog" + ); + } + + #[test] + fn from_name_round_trips_every_method() { + for &m in ControlMethod::ALL { + assert_eq!(ControlMethod::from_name(m.name()), Some(m)); + } + assert_eq!(ControlMethod::from_name("control.nope"), None); + assert_eq!(ControlMethod::from_name(""), None); + } + + #[test] + fn only_pairing_bootstrap_is_open() { + for &m in ControlMethod::ALL { + let open = matches!( + m, + ControlMethod::PairingRequest | ControlMethod::PairingPoll + ); + assert_eq!(m.requires_auth(), !open, "{} auth mismatch", m.name()); + assert_eq!( + m.routing() == Routing::OpenBootstrap, + open, + "{} routing mismatch", + m.name() + ); + } + } + + #[test] + fn pairing_admin_methods_are_exactly_three() { + let admin: Vec<&str> = ControlMethod::ALL + .iter() + .filter(|m| m.is_pairing_admin()) + .map(|m| m.name()) + .collect(); + assert_eq!( + admin, + vec![ + "control.pairing.list", + "control.pairing.approve", + "control.pairing.revoke" + ] + ); + } + + #[test] + fn delegated_set_matches_the_engine_surface() { + let delegated: BTreeSet<&str> = ControlMethod::ALL + .iter() + .filter(|m| m.routing() == Routing::Delegated) + .map(|m| m.name()) + .collect(); + let expected: BTreeSet<&str> = [ + "control.peerStatus", + "control.peers.connect", + "control.peers.disconnect", + "control.subscribe", + "control.unsubscribe", + "control.listSubscriptions", + ] + .into_iter() + .collect(); + assert_eq!(delegated, expected); + } + + #[test] + fn every_method_has_a_nonempty_summary() { + for &m in ControlMethod::ALL { + assert!(!m.summary().is_empty(), "{} has no summary", m.name()); + } + } +} diff --git a/src/params.rs b/src/params.rs new file mode 100644 index 0000000..199e973 --- /dev/null +++ b/src/params.rs @@ -0,0 +1,262 @@ +//! Typed request params for the control methods, each bound to its method + result via +//! [`ControlCall`](crate::traits::ControlCall). +//! +//! One params type per method (even where two methods share the same field shape, e.g. the four +//! `{ store }` methods) so the compile-time method↔params↔result binding is exact: a caller passes +//! `PinParams { store }` and the type system yields a [`PinResult`](crate::results::PinResult). +//! Field names are the exact wire names dig-node reads. + +use serde::{Deserialize, Serialize}; + +use crate::method::ControlMethod; +use crate::results; +use crate::traits::ControlCall; + +/// Bind a params type to its wire method + typed result. +macro_rules! control_call { + ($ty:ty => $method:expr, $out:ty) => { + impl ControlCall for $ty { + const METHOD: ControlMethod = $method; + type Output = $out; + } + }; +} + +/// Define a no-param call: an empty params struct (serializes to `{}`) bound to its method + result. +macro_rules! no_params { + ($(#[$doc:meta])* $name:ident => $method:expr, $out:ty) => { + $(#[$doc])* + #[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] + pub struct $name {} + control_call!($name => $method, $out); + }; +} + +no_params!( + /// `control.status` params (none). + StatusParams => ControlMethod::Status, results::StatusResult +); +no_params!( + /// `control.config.get` params (none). + ConfigGetParams => ControlMethod::ConfigGet, results::ConfigResult +); +no_params!( + /// `control.cache.get` params (none). + CacheGetParams => ControlMethod::CacheGet, results::CacheView +); +no_params!( + /// `control.cache.clear` params (none). + CacheClearParams => ControlMethod::CacheClear, results::CacheClearResult +); +no_params!( + /// `control.hostedStores.list` params (none). + HostedStoresListParams => ControlMethod::HostedStoresList, results::HostedStoresListResult +); +no_params!( + /// `control.sync.status` params (none). + SyncStatusParams => ControlMethod::SyncStatus, results::SyncStatusResult +); +no_params!( + /// `control.updater.status` params (none). Result is the proxied beacon status. + UpdaterStatusParams => ControlMethod::UpdaterStatus, serde_json::Value +); +no_params!( + /// `control.updater.resume` params (none). + UpdaterResumeParams => ControlMethod::UpdaterResume, serde_json::Value +); +no_params!( + /// `control.updater.checkNow` params (none). + UpdaterCheckNowParams => ControlMethod::UpdaterCheckNow, serde_json::Value +); +no_params!( + /// `control.pairing.list` params (none). Result is the pending + issued-token list. + PairingListParams => ControlMethod::PairingList, serde_json::Value +); +no_params!( + /// `control.peerStatus` params (none). Result is the peer-pool snapshot. + PeerStatusParams => ControlMethod::PeerStatus, serde_json::Value +); +no_params!( + /// `control.listSubscriptions` params (none). + ListSubscriptionsParams => ControlMethod::ListSubscriptions, results::ListSubscriptionsResult +); + +/// `control.config.setUpstream` params. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct SetUpstreamParams { + /// The upstream DIG RPC URL to persist (blank clears the override). + pub upstream: String, +} +control_call!(SetUpstreamParams => ControlMethod::ConfigSetUpstream, results::SetUpstreamResult); + +/// `control.log.setLevel` params. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct SetLevelParams { + /// An `EnvFilter` directive, e.g. `"debug"` or `"info,dig_node_core=debug"`. + pub filter: String, +} +control_call!(SetLevelParams => ControlMethod::LogSetLevel, results::SetLevelResult); + +/// `control.cache.setCap` params. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct SetCapParams { + /// The cache size cap in bytes (floored at 64 MiB by the node). + pub cap_bytes: u64, +} +control_call!(SetCapParams => ControlMethod::CacheSetCap, results::SetCapResult); + +/// `control.hostedStores.pin` params. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct PinParams { + /// A store reference: `storeId` or `storeId:rootHash`. + pub store: String, +} +control_call!(PinParams => ControlMethod::HostedStoresPin, results::PinResult); + +/// `control.hostedStores.unpin` params. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct UnpinParams { + /// A store reference: `storeId` or `storeId:rootHash`. + pub store: String, +} +control_call!(UnpinParams => ControlMethod::HostedStoresUnpin, results::UnpinResult); + +/// `control.hostedStores.status` params. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct HostedStoreStatusParams { + /// A store reference: `storeId` or `storeId:rootHash`. + pub store: String, +} +control_call!(HostedStoreStatusParams => ControlMethod::HostedStoresStatus, results::HostedStoreStatusResult); + +/// `control.sync.trigger` params. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct SyncTriggerParams { + /// A capsule reference: `storeId:rootHash` (a concrete root is required). + pub store: String, +} +control_call!(SyncTriggerParams => ControlMethod::SyncTrigger, results::SyncTriggerResult); + +/// `control.updater.setChannel` params. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct SetChannelParams { + /// The update channel (`"nightly"` | `"stable"`; the beacon CLI is the sole validator). + pub channel: String, +} +control_call!(SetChannelParams => ControlMethod::UpdaterSetChannel, serde_json::Value); + +/// `control.updater.pause` params. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct PauseParams { + /// The unix-seconds time to pause until; omit to pause indefinitely. + #[serde(skip_serializing_if = "Option::is_none", default)] + pub until: Option, +} +control_call!(PauseParams => ControlMethod::UpdaterPause, serde_json::Value); + +/// `control.pairing.approve` params. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ApproveParams { + /// The pending pairing's id (from `pairing.request`). + pub pairing_id: String, +} +control_call!(ApproveParams => ControlMethod::PairingApprove, results::PairingApproveResult); + +/// `control.pairing.revoke` params. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct RevokeParams { + /// The short id of the paired token to revoke. + pub token_id: String, +} +control_call!(RevokeParams => ControlMethod::PairingRevoke, results::PairingRevokeResult); + +/// `control.peers.connect` params. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct PeersConnectParams { + /// A peer address to dial, or an already-connected peer_id to resolve. + pub peer: String, +} +control_call!(PeersConnectParams => ControlMethod::PeersConnect, results::PeersConnectResult); + +/// `control.peers.disconnect` params. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct PeersDisconnectParams { + /// The peer_id to drop. + pub peer: String, +} +control_call!(PeersDisconnectParams => ControlMethod::PeersDisconnect, results::PeersDisconnectResult); + +/// `control.subscribe` params. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct SubscribeParams { + /// The store id to subscribe to. + pub store_id: String, +} +control_call!(SubscribeParams => ControlMethod::Subscribe, results::SubscribeResult); + +/// `control.unsubscribe` params. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct UnsubscribeParams { + /// The store id to stop watching. + pub store_id: String, +} +control_call!(UnsubscribeParams => ControlMethod::Unsubscribe, results::UnsubscribeResult); + +/// `pairing.request` params (OPEN — no token). +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct RequestParams { + /// A human-readable name for the requesting client (shown to the operator). + pub client_name: String, +} +control_call!(RequestParams => ControlMethod::PairingRequest, results::PairingRequestResult); + +/// `pairing.poll` params (OPEN — no token). +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct PollParams { + /// The pairing id to poll. + pub pairing_id: String, +} +control_call!(PollParams => ControlMethod::PairingPoll, results::PairingPollResult); + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::build_request; + use serde_json::json; + + #[test] + fn no_param_call_serializes_params_to_empty_object() { + let req = build_request(1.into(), &StatusParams {}); + assert_eq!(req.method, "control.status"); + assert_eq!(req.params, json!({})); + } + + #[test] + fn data_param_call_carries_its_fields() { + let req = build_request(2.into(), &SetCapParams { cap_bytes: 128 }); + assert_eq!(req.method, "control.cache.setCap"); + assert_eq!(req.params, json!({ "cap_bytes": 128 })); + } + + #[test] + fn pause_omits_until_when_indefinite() { + assert_eq!( + serde_json::to_value(PauseParams { until: None }).unwrap(), + json!({}) + ); + assert_eq!( + serde_json::to_value(PauseParams { until: Some(99) }).unwrap(), + json!({ "until": 99 }) + ); + } + + #[test] + fn method_binding_matches_the_catalog_name() { + assert_eq!( + SetUpstreamParams::METHOD.name(), + "control.config.setUpstream" + ); + assert_eq!(RequestParams::METHOD.name(), "pairing.request"); + assert_eq!(PollParams::METHOD.name(), "pairing.poll"); + } +} diff --git a/src/results.rs b/src/results.rs new file mode 100644 index 0000000..d353fc1 --- /dev/null +++ b/src/results.rs @@ -0,0 +1,359 @@ +//! Typed result payloads for the control methods. +//! +//! Each struct is field-for-field identical to what dig-node emits (snake_case wire fields), so a +//! client deserializes the node's real response and re-serializes the same bytes — the property the +//! conformance KATs pin. Genuinely open/proxied shapes (the updater beacon's status, the peer-pool +//! snapshot, the pairing list) stay [`serde_json::Value`] on the call's `Output` rather than being +//! frozen into a struct that would drift from the proxied source. + +use serde::{Deserialize, Serialize}; + +/// The on-disk content-cache view (`control.cache.get`, and embedded in [`StatusResult`]). +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct CacheView { + /// The configured cache size cap, in bytes. + pub cap_bytes: u64, + /// Bytes currently used on disk. + pub used_bytes: u64, + /// The cache directory. + pub dir: String, + /// Whether the cache directory is the machine-wide shared cache. + pub shared: bool, +} + +/// The §21 sync availability flag embedded in [`StatusResult`]. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct SyncAvailability { + /// Whether authenticated §21 whole-store sync is available on this node. + pub available: bool, +} + +/// `control.status` — a rich node status snapshot. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct StatusResult { + /// Always `true` for a responding node. + pub running: bool, + /// The service name (`"dig-node"`). + pub service: String, + /// The node binary's semantic version. + pub version: String, + /// The git commit the binary was built from (or `"unknown"`). + pub commit: String, + /// The DIG read protocol version the node speaks. + pub protocol: String, + /// Process uptime in seconds. + pub uptime_secs: u64, + /// The loopback `host:port` the node is bound to. + pub addr: String, + /// The upstream DIG RPC the node proxies/syncs to. + pub upstream: String, + /// The on-disk cache view. + pub cache: CacheView, + /// Distinct stores held (from the cache). + pub hosted_store_count: u64, + /// Cached capsule count. + pub cached_capsule_count: u64, + /// Pinned-store count. + pub pinned_store_count: u64, + /// §21 sync availability. + pub sync: SyncAvailability, +} + +/// `control.config.get` — the node's effective configuration. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ConfigResult { + /// The bound `host:port`. + pub addr: String, + /// The bound port, as a string. + pub port: String, + /// The effective upstream DIG RPC. + pub upstream: String, + /// The persisted upstream override, or `null` when unset. + pub upstream_override: Option, + /// The cache directory. + pub cache_dir: String, + /// Whether the cache is the machine-wide shared cache. + pub cache_shared: bool, + /// The node's config.json path. + pub config_path: String, + /// Whether authenticated §21 sync is available. + pub sync_available: bool, +} + +/// `control.config.setUpstream` — the persisted override + a restart hint. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct SetUpstreamResult { + /// The normalized upstream that was persisted. + pub upstream: String, + /// Always `true` — the change takes effect on next node start. + pub requires_restart: bool, +} + +/// `control.log.setLevel` — the applied filter directive. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct SetLevelResult { + /// The EnvFilter directive now in effect. + pub filter: String, +} + +/// `control.cache.setCap` — the applied cap (after the 64 MiB floor). +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct SetCapResult { + /// The cache cap now in effect, in bytes. + pub cap_bytes: u64, +} + +/// `control.cache.clear` — the clear acknowledgement. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct CacheClearResult { + /// Always `true`. + pub cleared: bool, +} + +/// One cached capsule of a store, as listed by the hosted-stores methods. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct CapsuleEntry { + /// The capsule reference (`storeId:rootHash`). + pub capsule: String, + /// The capsule root hash. + pub root: String, + /// The capsule size on disk, in bytes. + pub size_bytes: u64, + /// When the capsule was last served, in unix milliseconds. + pub last_used_unix_ms: u64, +} + +/// One hosted/pinned store (`control.hostedStores.list`). +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct HostedStore { + /// The canonical lowercase 64-hex store id. + pub store_id: String, + /// Whether the operator has pinned this store. + pub pinned: bool, + /// The number of cached capsules of this store. + pub capsule_count: u64, + /// The total cached bytes across this store's capsules. + pub total_bytes: u64, + /// The cached capsules of this store. + pub capsules: Vec, +} + +/// `control.hostedStores.list` — every held/pinned store. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct HostedStoresListResult { + /// The stores, one entry per distinct store id. + pub stores: Vec, +} + +/// `control.hostedStores.pin` — the pin acknowledgement + the pre-fetch outcome. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct PinResult { + /// The store id that was pinned. + pub store_id: String, + /// The pinned root, or `null` when pinned at store level. + pub root: Option, + /// Always `true`. + pub pinned: bool, + /// The in-band pre-fetch outcome (`{status, …}`) — its shape varies with the fetch path. + pub fetch: serde_json::Value, +} + +/// `control.hostedStores.unpin` — the unpin acknowledgement + eviction count. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct UnpinResult { + /// The store id that was unpinned. + pub store_id: String, + /// Whether a pin registry entry was actually removed. + pub unpinned: bool, + /// How many cached capsules of the store were evicted. + pub evicted_capsules: u64, +} + +/// `control.hostedStores.status` — per-store pinned flag + cached capsules. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct HostedStoreStatusResult { + /// The store id queried. + pub store_id: String, + /// Whether the store is pinned. + pub pinned: bool, + /// The number of cached capsules. + pub capsule_count: u64, + /// The total cached bytes. + pub total_bytes: u64, + /// The cached capsules. + pub capsules: Vec, +} + +/// `control.sync.status` — §21 sync availability + pin coverage. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct SyncStatusResult { + /// Whether authenticated §21 whole-store sync is available. + pub available: bool, + /// The sync method name. + pub method: String, + /// The number of pinned stores. + pub pinned_total: u64, + /// How many pinned stores currently have a cached capsule. + pub pinned_synced: u64, + /// Whether whole-store (root-less) sync is supported by this build. + pub whole_store_trigger_supported: bool, +} + +/// `control.sync.trigger` — the synced-capsule outcome. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct SyncTriggerResult { + /// The store id synced. + pub store_id: String, + /// The capsule root synced. + pub root: String, + /// The outcome status (`"synced"`). + pub status: String, + /// The synced capsule size, in bytes. + pub size_bytes: u64, + /// The served root the node verified against. + pub served_root: String, +} + +/// `control.pairing.approve` — the mint acknowledgement + the new token's id. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct PairingApproveResult { + /// Always `true`. + pub approved: bool, + /// The requesting client's declared name. + pub client_name: String, + /// The short id of the minted paired token (used to revoke it). + pub token_id: String, +} + +/// `control.pairing.revoke` — the revoke acknowledgement. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct PairingRevokeResult { + /// Whether a token was actually removed. + pub revoked: bool, + /// The token id that was targeted. + pub token_id: String, +} + +/// `control.peers.connect` — the connected peer's id. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct PeersConnectResult { + /// Always `true` on success. + pub connected: bool, + /// The connected peer's id. + pub peer_id: String, +} + +/// `control.peers.disconnect` — the dropped peer's id. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct PeersDisconnectResult { + /// Always `true` (idempotent — dropping an absent peer still succeeds). + pub disconnected: bool, + /// The peer id that was targeted (trimmed + lower-cased). + pub peer_id: String, +} + +/// `control.subscribe` — the subscription acknowledgement. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct SubscribeResult { + /// Always `true`. + pub subscribed: bool, + /// Whether the store was newly added (vs already subscribed). + pub added: bool, + /// The canonical persisted store id (trimmed + lower-cased). + pub store_id: String, +} + +/// `control.unsubscribe` — the unsubscription acknowledgement. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct UnsubscribeResult { + /// Always `false`. + pub subscribed: bool, + /// Whether the store was actually removed. + pub removed: bool, + /// The canonical store id. + pub store_id: String, +} + +/// `control.listSubscriptions` — the node's persisted subscription set. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ListSubscriptionsResult { + /// The subscribed store ids. + pub subscriptions: Vec, + /// The subscription count. + pub count: u64, +} + +/// `pairing.request` — the pairing handshake bootstrap (OPEN, no token). +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct PairingRequestResult { + /// The opaque pairing id to poll with. + pub pairing_id: String, + /// A short numeric code the operator compares before approving. + pub pairing_code: String, + /// When the pending pairing expires, in unix milliseconds. + pub expires_ms: u64, +} + +/// `pairing.poll` — the pairing poll outcome (OPEN, no token). +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct PairingPollResult { + /// The pairing status (`"pending"` / `"approved"` / …). + pub status: String, + /// The minted scoped token, present exactly once after approval. + #[serde(skip_serializing_if = "Option::is_none", default)] + pub token: Option, +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn status_result_round_trips_the_node_shape() { + let v = json!({ + "running": true, "service": "dig-node", "version": "0.30.0", "commit": "abc", + "protocol": "21", "uptime_secs": 5, "addr": "127.0.0.1:9256", "upstream": "https://rpc.dig.net", + "cache": {"cap_bytes": 1024, "used_bytes": 10, "dir": "/c", "shared": false}, + "hosted_store_count": 2, "cached_capsule_count": 3, "pinned_store_count": 1, + "sync": {"available": true} + }); + let parsed: StatusResult = serde_json::from_value(v.clone()).unwrap(); + assert_eq!(serde_json::to_value(&parsed).unwrap(), v); + } + + #[test] + fn config_result_keeps_upstream_override_null_when_unset() { + let parsed = ConfigResult { + addr: "127.0.0.1:9256".into(), + port: "9256".into(), + upstream: "https://rpc.dig.net".into(), + upstream_override: None, + cache_dir: "/c".into(), + cache_shared: false, + config_path: "/c/config.json".into(), + sync_available: true, + }; + let v = serde_json::to_value(&parsed).unwrap(); + assert_eq!(v["upstream_override"], json!(null)); + assert!(v.as_object().unwrap().contains_key("upstream_override")); + } + + #[test] + fn pairing_poll_omits_token_until_approved() { + let pending = PairingPollResult { + status: "pending".into(), + token: None, + }; + let v = serde_json::to_value(&pending).unwrap(); + assert_eq!(v, json!({"status": "pending"})); + let approved = PairingPollResult { + status: "approved".into(), + token: Some("deadbeef".into()), + }; + assert_eq!( + serde_json::to_value(&approved).unwrap(), + json!({"status": "approved", "token": "deadbeef"}) + ); + } +} diff --git a/src/traits.rs b/src/traits.rs new file mode 100644 index 0000000..04bbf5a --- /dev/null +++ b/src/traits.rs @@ -0,0 +1,286 @@ +//! The two contract traits: the client-facing call builder/parser and the node-facing handler. +//! +//! * [`ControlCall`] binds a typed params struct to its [`ControlMethod`] and its typed result — so +//! a caller writes `client.request(&SetCapParams { cap_bytes })` and gets back a `SetCapResult`, +//! never a stringly-typed `Value`. +//! * [`ControlClient`] is what a CLIENT depends on: build a JSON-RPC request from a typed call, and +//! parse a response back into the typed result (or a [`ControlError`]). Pure — no transport; the +//! consumer carries the bytes over dig-ipc / loopback-mTLS itself. +//! * [`ControlHandler`] is what a NODE implements to SERVE the surface: one typed method per control +//! method, plus a provided [`dispatch`](ControlHandler::dispatch) that routes a raw request to the +//! right method — the single anti-drift seam the conformance KATs exercise. + +use async_trait::async_trait; +use serde::de::DeserializeOwned; +use serde::Serialize; +use serde_json::Value; + +use crate::envelope::{JsonRpcRequest, JsonRpcResponse, RequestId}; +use crate::error::{ControlError, ControlErrorCode}; +use crate::method::ControlMethod; +use crate::params; +use crate::results; + +/// A typed control call: a params struct that knows its [`ControlMethod`] and its result type. +/// +/// Implemented by every struct in [`crate::params`]; this is what makes [`ControlClient::request`] +/// return the right typed result for each method at compile time. +pub trait ControlCall: Serialize { + /// The wire method this call invokes. + const METHOD: ControlMethod; + /// The typed result this call returns on success. + type Output: DeserializeOwned; +} + +/// Serialize a typed call's params into a JSON object (`{}` for a no-param call, never `null`). +fn params_value(call: &C) -> Value { + match serde_json::to_value(call) { + Ok(Value::Null) => Value::Object(Default::default()), + Ok(v) => v, + Err(_) => Value::Object(Default::default()), + } +} + +/// Build the JSON-RPC request envelope for a typed control call. Pure. +pub fn build_request(id: RequestId, call: &C) -> JsonRpcRequest { + JsonRpcRequest::new(id, C::METHOD.name(), params_value(call)) +} + +/// Parse a JSON-RPC response into a typed result, or the [`ControlError`] it carried. Pure. +pub fn parse_response( + response: JsonRpcResponse, +) -> Result { + let value = response.into_result()?; + serde_json::from_value(value).map_err(|e| { + ControlError::of( + ControlErrorCode::ControlError, + format!("failed to parse {} result: {e}", C::METHOD.name()), + ) + }) +} + +/// The client-facing half of the contract: turn typed calls into requests and responses back into +/// typed results. +/// +/// The default implementations cover every client; a consumer implements this trait only to +/// customise request construction (e.g. attaching the control token in a bespoke way). The blanket +/// [`DefaultControlClient`] gives callers the standard behaviour for free. +pub trait ControlClient { + /// Build the request envelope for a typed call with the given request `id`. + fn build_request(&self, id: RequestId, call: &C) -> JsonRpcRequest { + build_request(id, call) + } + + /// Parse a response envelope into the typed result for call type `C`. + fn parse_response( + &self, + response: JsonRpcResponse, + ) -> Result { + parse_response::(response) + } +} + +/// The standard, zero-configuration [`ControlClient`] using the default request/response behaviour. +#[derive(Debug, Clone, Copy, Default)] +pub struct DefaultControlClient; + +impl ControlClient for DefaultControlClient {} + +/// The node-facing half of the contract: a running node implements this to SERVE the control +/// surface. Each method is typed to the catalog's params/results; the provided +/// [`dispatch`](ControlHandler::dispatch) routes a raw [`JsonRpcRequest`] to the right method so a +/// server needs only one entry point and can never mis-route. +/// +/// Open/proxied shapes (the updater beacon status, the pairing list, the peer-pool snapshot) return +/// [`Value`] rather than a frozen struct, matching the catalog's [`ControlCall::Output`] for those +/// methods. +#[async_trait] +pub trait ControlHandler: Sync { + /// `control.status` + async fn status(&self) -> Result; + /// `control.config.get` + async fn config_get(&self) -> Result; + /// `control.config.setUpstream` + async fn config_set_upstream( + &self, + params: params::SetUpstreamParams, + ) -> Result; + /// `control.log.setLevel` + async fn log_set_level( + &self, + params: params::SetLevelParams, + ) -> Result; + /// `control.cache.get` + async fn cache_get(&self) -> Result; + /// `control.cache.setCap` + async fn cache_set_cap( + &self, + params: params::SetCapParams, + ) -> Result; + /// `control.cache.clear` + async fn cache_clear(&self) -> Result; + /// `control.hostedStores.list` + async fn hosted_stores_list(&self) -> Result; + /// `control.hostedStores.pin` + async fn hosted_stores_pin( + &self, + params: params::PinParams, + ) -> Result; + /// `control.hostedStores.unpin` + async fn hosted_stores_unpin( + &self, + params: params::UnpinParams, + ) -> Result; + /// `control.hostedStores.status` + async fn hosted_stores_status( + &self, + params: params::HostedStoreStatusParams, + ) -> Result; + /// `control.sync.status` + async fn sync_status(&self) -> Result; + /// `control.sync.trigger` + async fn sync_trigger( + &self, + params: params::SyncTriggerParams, + ) -> Result; + /// `control.updater.status` + async fn updater_status(&self) -> Result; + /// `control.updater.setChannel` + async fn updater_set_channel( + &self, + params: params::SetChannelParams, + ) -> Result; + /// `control.updater.pause` + async fn updater_pause(&self, params: params::PauseParams) -> Result; + /// `control.updater.resume` + async fn updater_resume(&self) -> Result; + /// `control.updater.checkNow` + async fn updater_check_now(&self) -> Result; + /// `control.pairing.list` + async fn pairing_list(&self) -> Result; + /// `control.pairing.approve` + async fn pairing_approve( + &self, + params: params::ApproveParams, + ) -> Result; + /// `control.pairing.revoke` + async fn pairing_revoke( + &self, + params: params::RevokeParams, + ) -> Result; + /// `control.peerStatus` + async fn peer_status(&self) -> Result; + /// `control.peers.connect` + async fn peers_connect( + &self, + params: params::PeersConnectParams, + ) -> Result; + /// `control.peers.disconnect` + async fn peers_disconnect( + &self, + params: params::PeersDisconnectParams, + ) -> Result; + /// `control.subscribe` + async fn subscribe( + &self, + params: params::SubscribeParams, + ) -> Result; + /// `control.unsubscribe` + async fn unsubscribe( + &self, + params: params::UnsubscribeParams, + ) -> Result; + /// `control.listSubscriptions` + async fn list_subscriptions(&self) -> Result; + /// `pairing.request` (OPEN) + async fn pairing_request( + &self, + params: params::RequestParams, + ) -> Result; + /// `pairing.poll` (OPEN) + async fn pairing_poll( + &self, + params: params::PollParams, + ) -> Result; + + /// Route a raw JSON-RPC request to the right typed method and build the response envelope. + /// + /// Deserializes the params for methods that take them, calls the handler, and serializes the + /// typed result. An unknown method → `METHOD_NOT_FOUND`; malformed params → `INVALID_PARAMS`. + /// This is the single seam a server dispatches through — the KATs exercise it end-to-end. + async fn dispatch(&self, request: JsonRpcRequest) -> JsonRpcResponse { + let id = request.id.clone(); + let Some(method) = ControlMethod::from_name(&request.method) else { + return JsonRpcResponse::error( + id, + ControlError::of( + ControlErrorCode::MethodNotFound, + format!("unknown control method: {}", request.method), + ), + ); + }; + match self.dispatch_method(method, request.params).await { + Ok(result) => JsonRpcResponse::success(id, result), + Err(err) => JsonRpcResponse::error(id, err), + } + } + + /// Route to the typed method by [`ControlMethod`], returning the result as a [`Value`]. Split + /// from [`dispatch`](ControlHandler::dispatch) so the envelope wrapping stays in one place. + #[doc(hidden)] + async fn dispatch_method( + &self, + method: ControlMethod, + params: Value, + ) -> Result { + /// Deserialize a method's params, mapping a shape error to `INVALID_PARAMS`. + fn decode(params: Value) -> Result { + serde_json::from_value(params) + .map_err(|e| ControlError::of(ControlErrorCode::InvalidParams, e.to_string())) + } + /// Serialize a typed result to a `Value` (infallible for our derive-Serialize results). + fn encode(value: T) -> Result { + serde_json::to_value(value) + .map_err(|e| ControlError::of(ControlErrorCode::ControlError, e.to_string())) + } + match method { + ControlMethod::Status => encode(self.status().await?), + ControlMethod::ConfigGet => encode(self.config_get().await?), + ControlMethod::ConfigSetUpstream => { + encode(self.config_set_upstream(decode(params)?).await?) + } + ControlMethod::LogSetLevel => encode(self.log_set_level(decode(params)?).await?), + ControlMethod::CacheGet => encode(self.cache_get().await?), + ControlMethod::CacheSetCap => encode(self.cache_set_cap(decode(params)?).await?), + ControlMethod::CacheClear => encode(self.cache_clear().await?), + ControlMethod::HostedStoresList => encode(self.hosted_stores_list().await?), + ControlMethod::HostedStoresPin => { + encode(self.hosted_stores_pin(decode(params)?).await?) + } + ControlMethod::HostedStoresUnpin => { + encode(self.hosted_stores_unpin(decode(params)?).await?) + } + ControlMethod::HostedStoresStatus => { + encode(self.hosted_stores_status(decode(params)?).await?) + } + ControlMethod::SyncStatus => encode(self.sync_status().await?), + ControlMethod::SyncTrigger => encode(self.sync_trigger(decode(params)?).await?), + ControlMethod::UpdaterStatus => self.updater_status().await, + ControlMethod::UpdaterSetChannel => self.updater_set_channel(decode(params)?).await, + ControlMethod::UpdaterPause => self.updater_pause(decode(params)?).await, + ControlMethod::UpdaterResume => self.updater_resume().await, + ControlMethod::UpdaterCheckNow => self.updater_check_now().await, + ControlMethod::PairingList => self.pairing_list().await, + ControlMethod::PairingApprove => encode(self.pairing_approve(decode(params)?).await?), + ControlMethod::PairingRevoke => encode(self.pairing_revoke(decode(params)?).await?), + ControlMethod::PeerStatus => self.peer_status().await, + ControlMethod::PeersConnect => encode(self.peers_connect(decode(params)?).await?), + ControlMethod::PeersDisconnect => encode(self.peers_disconnect(decode(params)?).await?), + ControlMethod::Subscribe => encode(self.subscribe(decode(params)?).await?), + ControlMethod::Unsubscribe => encode(self.unsubscribe(decode(params)?).await?), + ControlMethod::ListSubscriptions => encode(self.list_subscriptions().await?), + ControlMethod::PairingRequest => encode(self.pairing_request(decode(params)?).await?), + ControlMethod::PairingPoll => encode(self.pairing_poll(decode(params)?).await?), + } + } +}