Skip to main content

dig_node_control_interface/
method.rs

1//! The canonical control-method catalog.
2//!
3//! [`ControlMethod`] enumerates every method a client can send to a running dig-node's CONTROL
4//! plane, its stable wire name, whether it requires the local control token, whether it is a
5//! pairing-administration method (which requires the MASTER token specifically), and how the node
6//! routes it (owned by the service shell, delegated to the embedded node engine, or an open
7//! pairing-bootstrap method reachable without a token).
8//!
9//! This is the SINGLE source of truth for "what can be controlled". The node dispatchers, the
10//! client SDKs (CLI `dign`, the extension, dig-app, hub), the OpenRPC/discovery surface, and the
11//! conformance KATs all read this one table, so the method set can never drift between them.
12//!
13//! Mirrors the live dig-node surface: the shell-owned methods in
14//! `dig-node-service/src/control.rs` (`CONTROL_METHODS`) plus the peer/subscription methods
15//! delegated to `dig-node-core` (`control.peerStatus` / `control.peers.*` / `control.subscribe`
16//! / `control.unsubscribe` / `control.listSubscriptions`), and the two OPEN pairing-bootstrap
17//! methods (`pairing.request` / `pairing.poll`) a token-less MV3 extension uses to obtain a
18//! scoped token after local operator approval.
19
20/// How the node resolves a control method — the routing source of truth.
21#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
22pub enum Routing {
23    /// Answered by the dig-node service shell itself (config/status/cache/pins/sync/updater/pairing-admin).
24    Owned,
25    /// Delegated to the embedded dig-node engine's own control surface (peers + subscriptions).
26    Delegated,
27    /// An OPEN bootstrap method reachable WITHOUT the control token (pairing handshake).
28    OpenBootstrap,
29}
30
31/// The functional area a control method belongs to — for grouping in UIs and docs.
32#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
33pub enum Category {
34    /// Node status snapshot.
35    Status,
36    /// Node configuration (upstream override).
37    Config,
38    /// Live log-level control.
39    Log,
40    /// On-disk content cache.
41    Cache,
42    /// Hosted/pinned stores.
43    HostedStores,
44    /// §21 authenticated whole-store sync.
45    Sync,
46    /// The DIG auto-update beacon proxy.
47    Updater,
48    /// Control-token pairing lifecycle.
49    Pairing,
50    /// The L7 peer network.
51    Peers,
52    /// The node's subscribed-store set.
53    Subscriptions,
54    /// Read-only wallet chain reads (balance).
55    Wallet,
56}
57
58/// A dig-node CONTROL method.
59///
60/// `#[non_exhaustive]` so adding a method in a minor release is additive; downstream matches must
61/// carry a `_ => …` arm. Convert to/from the wire name with [`ControlMethod::name`] /
62/// [`ControlMethod::from_name`].
63#[non_exhaustive]
64#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
65pub enum ControlMethod {
66    // ---- Status / config / log (shell-owned) ----
67    /// `control.status` — a rich node status snapshot.
68    Status,
69    /// `control.config.get` — the node's effective configuration.
70    ConfigGet,
71    /// `control.config.setUpstream` — persist an upstream-RPC override (effective on restart).
72    ConfigSetUpstream,
73    /// `control.log.setLevel` — live-swap the running node's tracing level filter.
74    LogSetLevel,
75
76    // ---- Cache (shell-owned) ----
77    /// `control.cache.get` — the on-disk cache view (cap/used/dir/shared).
78    CacheGet,
79    /// `control.cache.setCap` — set the cache size cap (floored at 64 MiB).
80    CacheSetCap,
81    /// `control.cache.clear` — delete all locally cached content.
82    CacheClear,
83
84    // ---- Hosted stores (shell-owned) ----
85    /// `control.hostedStores.list` — every held/pinned store with its cached capsules.
86    HostedStoresList,
87    /// `control.hostedStores.pin` — pin a store (and pre-fetch when a root is given).
88    HostedStoresPin,
89    /// `control.hostedStores.unpin` — unpin a store and evict its cached capsules.
90    HostedStoresUnpin,
91    /// `control.hostedStores.status` — per-store pinned flag + cached capsules.
92    HostedStoresStatus,
93
94    // ---- §21 sync (shell-owned) ----
95    /// `control.sync.status` — whether authenticated whole-store sync is available + pin coverage.
96    SyncStatus,
97    /// `control.sync.trigger` — trigger a §21 sync for one capsule (storeId + root).
98    SyncTrigger,
99
100    // ---- Updater beacon proxy (shell-owned) ----
101    /// `control.updater.status` — the DIG auto-update beacon's current status.
102    UpdaterStatus,
103    /// `control.updater.setChannel` — set the beacon's update channel.
104    UpdaterSetChannel,
105    /// `control.updater.pause` — suspend auto-updates (optionally until a unix time).
106    UpdaterPause,
107    /// `control.updater.resume` — resume auto-updates.
108    UpdaterResume,
109    /// `control.updater.checkNow` — force an immediate update check.
110    UpdaterCheckNow,
111
112    // ---- Pairing administration (shell-owned, MASTER-token only) ----
113    /// `control.pairing.list` — list pending pairing requests + issued paired tokens.
114    PairingList,
115    /// `control.pairing.approve` — approve a pending pairing, minting a scoped token.
116    PairingApprove,
117    /// `control.pairing.revoke` — revoke an issued paired token.
118    PairingRevoke,
119
120    // ---- Peers (delegated to the engine) ----
121    /// `control.peerStatus` — live peer-pool + relay-reservation snapshot.
122    PeerStatus,
123    /// `control.peers.connect` — dial a peer by address / resolve a connected peer_id.
124    PeersConnect,
125    /// `control.peers.disconnect` — drop a pooled peer by peer_id.
126    PeersDisconnect,
127
128    // ---- Subscriptions (delegated to the engine) ----
129    /// `control.subscribe` — subscribe the node to a store (watch + gap-fill).
130    Subscribe,
131    /// `control.unsubscribe` — stop watching a store.
132    Unsubscribe,
133    /// `control.listSubscriptions` — the node's persisted subscription set.
134    ListSubscriptions,
135
136    // ---- Wallet (READ-only, delegated to the engine) ----
137    /// `control.wallet.balance` — read an address's confirmed spendable balance for an asset.
138    WalletBalance,
139
140    // ---- Pairing bootstrap (OPEN — no token) ----
141    /// `pairing.request` — request a control-token pairing (returns a code to compare).
142    PairingRequest,
143    /// `pairing.poll` — poll a pairing; once the operator approves, returns the scoped token once.
144    PairingPoll,
145}
146
147impl ControlMethod {
148    /// The stable JSON-RPC wire name. Never derived from anything else — the published contract.
149    pub const fn name(self) -> &'static str {
150        match self {
151            ControlMethod::Status => "control.status",
152            ControlMethod::ConfigGet => "control.config.get",
153            ControlMethod::ConfigSetUpstream => "control.config.setUpstream",
154            ControlMethod::LogSetLevel => "control.log.setLevel",
155            ControlMethod::CacheGet => "control.cache.get",
156            ControlMethod::CacheSetCap => "control.cache.setCap",
157            ControlMethod::CacheClear => "control.cache.clear",
158            ControlMethod::HostedStoresList => "control.hostedStores.list",
159            ControlMethod::HostedStoresPin => "control.hostedStores.pin",
160            ControlMethod::HostedStoresUnpin => "control.hostedStores.unpin",
161            ControlMethod::HostedStoresStatus => "control.hostedStores.status",
162            ControlMethod::SyncStatus => "control.sync.status",
163            ControlMethod::SyncTrigger => "control.sync.trigger",
164            ControlMethod::UpdaterStatus => "control.updater.status",
165            ControlMethod::UpdaterSetChannel => "control.updater.setChannel",
166            ControlMethod::UpdaterPause => "control.updater.pause",
167            ControlMethod::UpdaterResume => "control.updater.resume",
168            ControlMethod::UpdaterCheckNow => "control.updater.checkNow",
169            ControlMethod::PairingList => "control.pairing.list",
170            ControlMethod::PairingApprove => "control.pairing.approve",
171            ControlMethod::PairingRevoke => "control.pairing.revoke",
172            ControlMethod::PeerStatus => "control.peerStatus",
173            ControlMethod::PeersConnect => "control.peers.connect",
174            ControlMethod::PeersDisconnect => "control.peers.disconnect",
175            ControlMethod::Subscribe => "control.subscribe",
176            ControlMethod::Unsubscribe => "control.unsubscribe",
177            ControlMethod::ListSubscriptions => "control.listSubscriptions",
178            ControlMethod::WalletBalance => "control.wallet.balance",
179            ControlMethod::PairingRequest => "pairing.request",
180            ControlMethod::PairingPoll => "pairing.poll",
181        }
182    }
183
184    /// Resolve a wire name back to its [`ControlMethod`], or `None` for an unknown name.
185    pub fn from_name(name: &str) -> Option<ControlMethod> {
186        ControlMethod::ALL
187            .iter()
188            .copied()
189            .find(|m| m.name() == name)
190    }
191
192    /// Does calling this method require the local control token?
193    ///
194    /// Every `control.*` method is token-gated; the two OPEN pairing-bootstrap methods
195    /// (`pairing.request` / `pairing.poll`) are not, so a token-less client can obtain a token.
196    pub const fn requires_auth(self) -> bool {
197        !matches!(
198            self,
199            ControlMethod::PairingRequest | ControlMethod::PairingPoll
200        )
201    }
202
203    /// Is this a PAIRING-ADMINISTRATION method that requires the MASTER control token specifically?
204    ///
205    /// A paired (scoped) token can drive ordinary `control.*` mutations but MUST NOT mint more
206    /// tokens or revoke itself — so listing/approving/revoking pairings requires the master token
207    /// (a local file read), never a paired token.
208    pub const fn is_pairing_admin(self) -> bool {
209        matches!(
210            self,
211            ControlMethod::PairingList
212                | ControlMethod::PairingApprove
213                | ControlMethod::PairingRevoke
214        )
215    }
216
217    /// How the node routes this method (shell-owned, engine-delegated, or open bootstrap).
218    pub const fn routing(self) -> Routing {
219        match self {
220            ControlMethod::PeerStatus
221            | ControlMethod::PeersConnect
222            | ControlMethod::PeersDisconnect
223            | ControlMethod::Subscribe
224            | ControlMethod::Unsubscribe
225            | ControlMethod::ListSubscriptions
226            | ControlMethod::WalletBalance => Routing::Delegated,
227            ControlMethod::PairingRequest | ControlMethod::PairingPoll => Routing::OpenBootstrap,
228            _ => Routing::Owned,
229        }
230    }
231
232    /// The functional area this method belongs to.
233    pub const fn category(self) -> Category {
234        match self {
235            ControlMethod::Status => Category::Status,
236            ControlMethod::ConfigGet | ControlMethod::ConfigSetUpstream => Category::Config,
237            ControlMethod::LogSetLevel => Category::Log,
238            ControlMethod::CacheGet | ControlMethod::CacheSetCap | ControlMethod::CacheClear => {
239                Category::Cache
240            }
241            ControlMethod::HostedStoresList
242            | ControlMethod::HostedStoresPin
243            | ControlMethod::HostedStoresUnpin
244            | ControlMethod::HostedStoresStatus => Category::HostedStores,
245            ControlMethod::SyncStatus | ControlMethod::SyncTrigger => Category::Sync,
246            ControlMethod::UpdaterStatus
247            | ControlMethod::UpdaterSetChannel
248            | ControlMethod::UpdaterPause
249            | ControlMethod::UpdaterResume
250            | ControlMethod::UpdaterCheckNow => Category::Updater,
251            ControlMethod::PairingList
252            | ControlMethod::PairingApprove
253            | ControlMethod::PairingRevoke
254            | ControlMethod::PairingRequest
255            | ControlMethod::PairingPoll => Category::Pairing,
256            ControlMethod::PeerStatus
257            | ControlMethod::PeersConnect
258            | ControlMethod::PeersDisconnect => Category::Peers,
259            ControlMethod::Subscribe
260            | ControlMethod::Unsubscribe
261            | ControlMethod::ListSubscriptions => Category::Subscriptions,
262            ControlMethod::WalletBalance => Category::Wallet,
263        }
264    }
265
266    /// A one-line human/agent description for the discovery catalogue.
267    pub const fn summary(self) -> &'static str {
268        match self {
269            ControlMethod::Status => "A rich node status snapshot (version, uptime, addr, cache, hosted/pinned counts, sync availability).",
270            ControlMethod::ConfigGet => "The node's effective configuration (addr/port, upstream + override, cache dir/shared, config path, sync availability).",
271            ControlMethod::ConfigSetUpstream => "Persist an upstream-RPC override; takes effect on next node start (requires_restart).",
272            ControlMethod::LogSetLevel => "Live-swap the running node's tracing EnvFilter directive (not persisted).",
273            ControlMethod::CacheGet => "The on-disk content-cache view: cap_bytes, used_bytes, dir, shared.",
274            ControlMethod::CacheSetCap => "Set the on-disk cache size cap in bytes (floored at 64 MiB).",
275            ControlMethod::CacheClear => "Delete all locally cached DIG content.",
276            ControlMethod::HostedStoresList => "Every held/pinned store, merged, with each store's cached capsules and a pinned flag.",
277            ControlMethod::HostedStoresPin => "Pin a store (storeId[:rootHash]); pre-fetches the capsule when a root is given and §21 sync is available.",
278            ControlMethod::HostedStoresUnpin => "Unpin a store and evict its cached capsules.",
279            ControlMethod::HostedStoresStatus => "Per-store status: pinned flag, cached capsules, total bytes.",
280            ControlMethod::SyncStatus => "Whether authenticated §21 whole-store sync is available, plus pinned-store cache coverage.",
281            ControlMethod::SyncTrigger => "Trigger a §21 sync for one capsule (storeId + root).",
282            ControlMethod::UpdaterStatus => "The DIG auto-update beacon's current status (proxied from dig-updater).",
283            ControlMethod::UpdaterSetChannel => "Set the beacon's update channel (\"nightly\" | \"stable\").",
284            ControlMethod::UpdaterPause => "Suspend the beacon's auto-updates (optionally until a unix time).",
285            ControlMethod::UpdaterResume => "Resume the beacon's auto-updates.",
286            ControlMethod::UpdaterCheckNow => "Force an immediate beacon update check.",
287            ControlMethod::PairingList => "List pending pairing requests and issued paired tokens (MASTER token only).",
288            ControlMethod::PairingApprove => "Approve a pending pairing, minting a scoped token (MASTER token only).",
289            ControlMethod::PairingRevoke => "Revoke an issued paired token by token_id (MASTER token only).",
290            ControlMethod::PeerStatus => "Live peer-pool + relay-reservation snapshot, including the per-peer connected array.",
291            ControlMethod::PeersConnect => "Dial a peer by address, or resolve an already-connected peer_id, via the live gossip pool.",
292            ControlMethod::PeersDisconnect => "Drop a pooled peer by peer_id, closing its mTLS link (idempotent).",
293            ControlMethod::Subscribe => "Subscribe the node to a store it actively watches and gap-fills.",
294            ControlMethod::Unsubscribe => "Stop watching a store.",
295            ControlMethod::ListSubscriptions => "The node's persisted subscription set + count.",
296            ControlMethod::WalletBalance => "READ-only: the confirmed spendable balance for an address + asset (plus pending, sync freshness, and the peak height it reflects).",
297            ControlMethod::PairingRequest => "OPEN: request a control-token pairing; returns a pairing_id + pairing_code to compare.",
298            ControlMethod::PairingPoll => "OPEN: poll a pairing by id; once the operator approves, returns the scoped token once.",
299        }
300    }
301
302    /// Every catalogued method, in a stable order — the enumeration a machine reads to discover the
303    /// full control surface, and the anchor the conformance KATs pin against.
304    pub const ALL: &'static [ControlMethod] = &[
305        ControlMethod::Status,
306        ControlMethod::ConfigGet,
307        ControlMethod::ConfigSetUpstream,
308        ControlMethod::LogSetLevel,
309        ControlMethod::CacheGet,
310        ControlMethod::CacheSetCap,
311        ControlMethod::CacheClear,
312        ControlMethod::HostedStoresList,
313        ControlMethod::HostedStoresPin,
314        ControlMethod::HostedStoresUnpin,
315        ControlMethod::HostedStoresStatus,
316        ControlMethod::SyncStatus,
317        ControlMethod::SyncTrigger,
318        ControlMethod::UpdaterStatus,
319        ControlMethod::UpdaterSetChannel,
320        ControlMethod::UpdaterPause,
321        ControlMethod::UpdaterResume,
322        ControlMethod::UpdaterCheckNow,
323        ControlMethod::PairingList,
324        ControlMethod::PairingApprove,
325        ControlMethod::PairingRevoke,
326        ControlMethod::PeerStatus,
327        ControlMethod::PeersConnect,
328        ControlMethod::PeersDisconnect,
329        ControlMethod::Subscribe,
330        ControlMethod::Unsubscribe,
331        ControlMethod::ListSubscriptions,
332        ControlMethod::WalletBalance,
333        ControlMethod::PairingRequest,
334        ControlMethod::PairingPoll,
335    ];
336}
337
338#[cfg(test)]
339mod tests {
340    use super::*;
341    use std::collections::BTreeSet;
342
343    #[test]
344    fn every_method_has_a_unique_wire_name() {
345        let names: BTreeSet<&str> = ControlMethod::ALL.iter().map(|m| m.name()).collect();
346        assert_eq!(
347            names.len(),
348            ControlMethod::ALL.len(),
349            "duplicate or missing wire names in the catalog"
350        );
351    }
352
353    #[test]
354    fn from_name_round_trips_every_method() {
355        for &m in ControlMethod::ALL {
356            assert_eq!(ControlMethod::from_name(m.name()), Some(m));
357        }
358        assert_eq!(ControlMethod::from_name("control.nope"), None);
359        assert_eq!(ControlMethod::from_name(""), None);
360    }
361
362    #[test]
363    fn only_pairing_bootstrap_is_open() {
364        for &m in ControlMethod::ALL {
365            let open = matches!(
366                m,
367                ControlMethod::PairingRequest | ControlMethod::PairingPoll
368            );
369            assert_eq!(m.requires_auth(), !open, "{} auth mismatch", m.name());
370            assert_eq!(
371                m.routing() == Routing::OpenBootstrap,
372                open,
373                "{} routing mismatch",
374                m.name()
375            );
376        }
377    }
378
379    #[test]
380    fn pairing_admin_methods_are_exactly_three() {
381        let admin: Vec<&str> = ControlMethod::ALL
382            .iter()
383            .filter(|m| m.is_pairing_admin())
384            .map(|m| m.name())
385            .collect();
386        assert_eq!(
387            admin,
388            vec![
389                "control.pairing.list",
390                "control.pairing.approve",
391                "control.pairing.revoke"
392            ]
393        );
394    }
395
396    #[test]
397    fn delegated_set_matches_the_engine_surface() {
398        let delegated: BTreeSet<&str> = ControlMethod::ALL
399            .iter()
400            .filter(|m| m.routing() == Routing::Delegated)
401            .map(|m| m.name())
402            .collect();
403        let expected: BTreeSet<&str> = [
404            "control.peerStatus",
405            "control.peers.connect",
406            "control.peers.disconnect",
407            "control.subscribe",
408            "control.unsubscribe",
409            "control.listSubscriptions",
410            "control.wallet.balance",
411        ]
412        .into_iter()
413        .collect();
414        assert_eq!(delegated, expected);
415    }
416
417    #[test]
418    fn every_method_has_a_nonempty_summary() {
419        for &m in ControlMethod::ALL {
420            assert!(!m.summary().is_empty(), "{} has no summary", m.name());
421        }
422    }
423}