Skip to main content

core_api/
lib.rs

1#![allow(clippy::derivable_impls, clippy::should_implement_trait)]
2
3mod delivery;
4mod external_rpc;
5pub mod interaction_flow;
6pub mod llm;
7pub mod messaging;
8pub mod node;
9mod node_commissioning;
10mod node_service;
11pub use node_commissioning::*;
12mod recipient;
13mod storage;
14
15pub use delivery::*;
16pub use external_rpc::*;
17pub use node_service::*;
18pub use recipient::*;
19pub use storage::*;
20
21use serde::{Deserialize, Serialize};
22use std::str::FromStr;
23
24/// Shared MWS transport limits. Both websocket peers must apply these values so
25/// an envelope accepted by one side is never rejected solely due to asymmetric
26/// transport configuration.
27pub const MWS_MAX_MESSAGE_SIZE: usize = 16 * 1024 * 1024;
28pub const MWS_MAX_FRAME_SIZE: usize = 4 * 1024 * 1024;
29pub const MWS_MAX_WRITE_BUFFER_SIZE: usize = 32 * 1024 * 1024;
30
31#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
32pub enum ExceptionCode {
33    Unsupported,
34    NullData,
35    ErrorSubscribe,
36    ErrorUnsubscribe,
37    ErrorProcessDataReport,
38    ErrorProcessDataPoll,
39    ErrorLock,
40    ErrorUnlock,
41    FunctionNoImpl,
42    Unreachable,
43    Unauthenticated,
44    Unauthorized,
45    PreconditionFail,
46    SessionExpired,
47    Internal,
48    Unknown,
49    NotFound,
50    AlreadyExists,
51    BadRequest,
52    InvalidWidgetDefinition,
53    DeadlineExceeded,
54    TemporaryUnavailable,
55    ResourceLocked,
56    WsConnectionLost,
57    BillAutomationExceed,
58    BillTokenExceed,
59    MissingContext,
60}
61
62impl ExceptionCode {
63    pub fn from_str(s: &str) -> Self {
64        match s {
65            "UNSUPPORTED" => Self::Unsupported,
66            "NULL_DATA" => Self::NullData,
67            "ERROR_SUBSCRIBE" => Self::ErrorSubscribe,
68            "ERROR_UNSUBSCRIBE" => Self::ErrorUnsubscribe,
69            "ERROR_PROCESS_DATA_REPORT" => Self::ErrorProcessDataReport,
70            "ERROR_PROCESS_DATA_POLL" => Self::ErrorProcessDataPoll,
71            "ERROR_LOCK" => Self::ErrorLock,
72            "ERROR_UNLOCK" => Self::ErrorUnlock,
73            "FUNCTION_NO_IMPL" => Self::FunctionNoImpl,
74            "UNREACHABLE" => Self::Unreachable,
75            "UNAUTHENTICATED" => Self::Unauthenticated,
76            "UNAUTHORIZED" => Self::Unauthorized,
77            "PRECONDITION_FAIL" => Self::PreconditionFail,
78            "SESSION_EXPIRED" => Self::SessionExpired,
79            "INTERNAL" => Self::Internal,
80            "NOT_FOUND" => Self::NotFound,
81            "ALREADY_EXISTS" => Self::AlreadyExists,
82            "BAD_REQUEST" => Self::BadRequest,
83            "INVALID_WIDGET_DEFINITION" => Self::InvalidWidgetDefinition,
84            "DEADLINE_EXCEEDED" => Self::DeadlineExceeded,
85            "TEMPORARY_UNAVAILABLE" => Self::TemporaryUnavailable,
86            "RESOURCE_LOCKED" => Self::ResourceLocked,
87            "WS_CONNECTION_LOST" => Self::WsConnectionLost,
88            "BILL_AUTOMATION_EXCEED" => Self::BillAutomationExceed,
89            "BILL_TOKEN_EXCEED" => Self::BillTokenExceed,
90            "MISSING_CONTEXT" => Self::MissingContext,
91            _ => Self::Unknown,
92        }
93    }
94
95    pub fn as_str(&self) -> &'static str {
96        match self {
97            Self::Unsupported => "UNSUPPORTED",
98            Self::NullData => "NULL_DATA",
99            Self::ErrorSubscribe => "ERROR_SUBSCRIBE",
100            Self::ErrorUnsubscribe => "ERROR_UNSUBSCRIBE",
101            Self::ErrorProcessDataReport => "ERROR_PROCESS_DATA_REPORT",
102            Self::ErrorProcessDataPoll => "ERROR_PROCESS_DATA_POLL",
103            Self::ErrorLock => "ERROR_LOCK",
104            Self::ErrorUnlock => "ERROR_UNLOCK",
105            Self::FunctionNoImpl => "FUNCTION_NO_IMPL",
106            Self::Unreachable => "UNREACHABLE",
107            Self::Unauthenticated => "UNAUTHENTICATED",
108            Self::Unauthorized => "UNAUTHORIZED",
109            Self::PreconditionFail => "PRECONDITION_FAIL",
110            Self::SessionExpired => "SESSION_EXPIRED",
111            Self::Internal => "INTERNAL",
112            Self::Unknown => "UNKNOWN",
113            Self::NotFound => "NOT_FOUND",
114            Self::AlreadyExists => "ALREADY_EXISTS",
115            Self::BadRequest => "BAD_REQUEST",
116            Self::InvalidWidgetDefinition => "INVALID_WIDGET_DEFINITION",
117            Self::DeadlineExceeded => "DEADLINE_EXCEEDED",
118            Self::TemporaryUnavailable => "TEMPORARY_UNAVAILABLE",
119            Self::ResourceLocked => "RESOURCE_LOCKED",
120            Self::WsConnectionLost => "WS_CONNECTION_LOST",
121            Self::BillAutomationExceed => "BILL_AUTOMATION_EXCEED",
122            Self::BillTokenExceed => "BILL_TOKEN_EXCEED",
123            Self::MissingContext => "MISSING_CONTEXT",
124        }
125    }
126}
127
128impl From<ExceptionCode> for String {
129    fn from(value: ExceptionCode) -> Self {
130        value.as_str().to_string()
131    }
132}
133
134#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
135pub struct ErrorResponse {
136    pub error: String,
137    pub message: String,
138    #[serde(default, skip_serializing_if = "Option::is_none")]
139    pub details: Option<serde_json::Value>,
140}
141
142impl ErrorResponse {
143    pub fn new(error_code: impl Into<String>, message: impl Into<String>) -> Self {
144        Self {
145            error: error_code.into(),
146            message: message.into(),
147            details: None,
148        }
149    }
150
151    pub fn with_details(mut self, details: serde_json::Value) -> Self {
152        self.details = Some(details);
153        self
154    }
155}
156
157pub struct ClientIds;
158
159impl ClientIds {
160    pub fn from_cloud(peer_id: &str, ws_id: &str) -> String {
161        format!("C:{}:{}", peer_id, ws_id)
162    }
163
164    pub fn from_local(ws_id: &str) -> String {
165        format!("L:{}", ws_id)
166    }
167
168    pub fn from_telegram(bot_id: &str, chat_id: i64) -> String {
169        format!("M:telegram:{}:{}", bot_id, chat_id)
170    }
171
172    pub fn is_cloud(client_id: &str) -> bool {
173        client_id.starts_with("C:")
174    }
175
176    pub fn is_local(client_id: &str) -> bool {
177        client_id.starts_with("L:")
178    }
179
180    pub fn is_telegram(client_id: &str) -> bool {
181        client_id.starts_with("M:telegram:")
182    }
183
184    pub fn is_messaging(client_id: &str) -> bool {
185        client_id.starts_with("M:")
186    }
187
188    pub fn to_telegram_bot_id(client_id: &str) -> Option<i64> {
189        let parts: Vec<&str> = client_id.splitn(6, ':').collect();
190        parts.get(2)?.parse::<i64>().ok()
191    }
192
193    pub fn to_telegram_chat_id(client_id: &str) -> Option<i64> {
194        let parts: Vec<&str> = client_id.splitn(6, ':').collect();
195        parts.get(3)?.parse::<i64>().ok()
196    }
197
198    pub fn to_peer_id(client_id: &str) -> Option<String> {
199        let parts: Vec<&str> = client_id.splitn(3, ':').collect();
200        parts.get(1).map(|s| s.to_string())
201    }
202
203    pub fn to_device_id(client_id: &str) -> Option<String> {
204        let parts: Vec<&str> = client_id.splitn(3, ':').collect();
205        parts.get(1).map(|s| s.to_string())
206    }
207}
208
209pub struct MwsMessageType;
210
211impl MwsMessageType {
212    pub const HUB_REQ: &'static str = "hrq";
213    pub const HUB_RESP: &'static str = "hrp";
214    pub const HUB_DATA: &'static str = "hd";
215    pub const NODE_REQ: &'static str = "nrq";
216    pub const NODE_RESP: &'static str = "nrp";
217    pub const NODE_DATA: &'static str = "nd";
218    pub const AGENT_REQ: &'static str = "grq";
219    pub const AGENT_RESP: &'static str = "grp";
220    pub const AGENT_DATA: &'static str = "gd";
221    pub const CLOUD_REQ: &'static str = "crq";
222    pub const CLOUD_RESP: &'static str = "crp";
223    pub const CLOUD_DATA: &'static str = "cd";
224    pub const APP_REQ: &'static str = "arq";
225    pub const APP_RESP: &'static str = "arp";
226    pub const SERVER_REQ: &'static str = "srq";
227    pub const SERVER_RESP: &'static str = "srp";
228    pub const APP_DATA: &'static str = "ad";
229    pub const SERVER_DATA: &'static str = "sd";
230    pub const PING: &'static str = "pi";
231    pub const PONG: &'static str = "po";
232}
233
234pub struct MwsSource;
235
236impl MwsSource {
237    pub const IOS: &'static str = "ios";
238    pub const ANDROID: &'static str = "android";
239    pub const WINDOWS: &'static str = "windows";
240    pub const MAC: &'static str = "macos";
241    pub const LINUX: &'static str = "linux";
242    pub const WEB: &'static str = "web";
243    pub const PWA: &'static str = "pwa";
244    pub const MESSAGING: &'static str = "messaging";
245
246    pub fn is_desktop(source: &str) -> bool {
247        matches!(source, Self::MAC | Self::WINDOWS | Self::LINUX)
248    }
249}
250
251#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
252#[serde(rename_all = "camelCase")]
253pub struct MwsClientInfo {
254    #[serde(skip_serializing_if = "Option::is_none")]
255    pub client_id: Option<String>,
256    #[serde(skip_serializing_if = "Option::is_none")]
257    pub user_id: Option<String>,
258}
259
260impl MwsClientInfo {
261    pub fn new(client_id: String, user_id: String) -> Self {
262        Self {
263            client_id: Some(client_id),
264            user_id: Some(user_id),
265        }
266    }
267
268    pub fn from_ws_id(ws_id: String) -> Self {
269        Self {
270            client_id: Some(ClientIds::from_local(&ws_id)),
271            user_id: None,
272        }
273    }
274
275    pub fn from_client_id(client_id: String) -> Self {
276        Self {
277            client_id: Some(client_id),
278            user_id: None,
279        }
280    }
281
282    pub fn to_client_id(&self) -> String {
283        self.client_id
284            .clone()
285            .unwrap_or_else(|| ClientIds::from_local("unknown"))
286    }
287}
288
289#[derive(Debug, Clone, Serialize, Deserialize)]
290#[serde(rename_all = "camelCase")]
291pub struct MwsMessage {
292    #[serde(skip_serializing_if = "Option::is_none")]
293    pub scope_id: Option<String>,
294    #[serde(skip_serializing_if = "Option::is_none")]
295    pub from: Option<String>,
296    #[serde(skip_serializing_if = "Option::is_none")]
297    pub target: Option<String>,
298    #[serde(skip_serializing_if = "Option::is_none")]
299    pub sig: Option<String>,
300    #[serde(skip_serializing_if = "Option::is_none")]
301    pub r#type: Option<String>,
302    #[serde(skip_serializing_if = "Option::is_none")]
303    pub payload: Option<String>,
304    #[serde(skip_serializing_if = "Option::is_none")]
305    pub error: Option<ErrorResponse>,
306    #[serde(skip_serializing_if = "Option::is_none")]
307    pub client_info: Option<MwsClientInfo>,
308}
309
310impl MwsMessage {
311    pub fn create(
312        scope_id: Option<String>,
313        target: String,
314        sig: String,
315        payload: String,
316        msg_type: String,
317    ) -> Self {
318        Self {
319            scope_id,
320            target: Some(target),
321            sig: Some(sig),
322            payload: Some(payload),
323            r#type: Some(msg_type),
324            from: None,
325            error: None,
326            client_info: None,
327        }
328    }
329
330    pub fn dummy() -> Self {
331        Self {
332            scope_id: None,
333            from: None,
334            target: None,
335            sig: None,
336            r#type: None,
337            payload: None,
338            error: None,
339            client_info: None,
340        }
341    }
342
343    pub fn server_response(
344        target: String,
345        sig: String,
346        payload: String,
347        client_info: MwsClientInfo,
348    ) -> Self {
349        Self {
350            scope_id: None,
351            from: None,
352            target: Some(target),
353            sig: Some(sig),
354            r#type: Some(MwsMessageType::SERVER_RESP.to_string()),
355            payload: Some(payload),
356            error: None,
357            client_info: Some(client_info),
358        }
359    }
360
361    pub fn server_response_error(
362        target: String,
363        sig: String,
364        error: ErrorResponse,
365        client_info: MwsClientInfo,
366    ) -> Self {
367        Self {
368            scope_id: None,
369            from: None,
370            target: Some(target),
371            sig: Some(sig),
372            r#type: Some(MwsMessageType::SERVER_RESP.to_string()),
373            payload: None,
374            error: Some(error),
375            client_info: Some(client_info),
376        }
377    }
378
379    pub fn unscoped_server_data(
380        target: String,
381        payload: String,
382        client_info: MwsClientInfo,
383    ) -> Self {
384        Self {
385            scope_id: None,
386            from: None,
387            target: Some(target),
388            sig: None,
389            r#type: Some(MwsMessageType::SERVER_DATA.to_string()),
390            payload: Some(payload),
391            error: None,
392            client_info: Some(client_info),
393        }
394    }
395
396    pub fn scoped_server_data(
397        scope_id: String,
398        target: String,
399        payload: String,
400        client_info: Option<MwsClientInfo>,
401    ) -> Self {
402        Self {
403            scope_id: Some(scope_id),
404            from: None,
405            target: Some(target),
406            sig: None,
407            r#type: Some(MwsMessageType::SERVER_DATA.to_string()),
408            payload: Some(payload),
409            error: None,
410            client_info,
411        }
412    }
413
414    pub fn hub_response(
415        target: String,
416        sig: String,
417        payload: String,
418        client_info: MwsClientInfo,
419    ) -> Self {
420        Self {
421            scope_id: None,
422            from: None,
423            target: Some(target),
424            sig: Some(sig),
425            r#type: Some(MwsMessageType::HUB_RESP.to_string()),
426            payload: Some(payload),
427            error: None,
428            client_info: Some(client_info),
429        }
430    }
431
432    pub fn hub_response_error(
433        target: String,
434        sig: String,
435        error: ErrorResponse,
436        client_info: MwsClientInfo,
437    ) -> Self {
438        Self {
439            scope_id: None,
440            from: None,
441            target: Some(target),
442            sig: Some(sig),
443            r#type: Some(MwsMessageType::HUB_RESP.to_string()),
444            payload: None,
445            error: Some(error),
446            client_info: Some(client_info),
447        }
448    }
449
450    pub fn hub_request(
451        scope_id: Option<String>,
452        target: String,
453        sig: String,
454        payload: String,
455    ) -> Self {
456        Self {
457            scope_id,
458            from: None,
459            target: Some(target),
460            sig: Some(sig),
461            r#type: Some(MwsMessageType::HUB_REQ.to_string()),
462            payload: Some(payload),
463            error: None,
464            client_info: None,
465        }
466    }
467
468    pub fn hub_data(
469        scope_id: Option<String>,
470        target: String,
471        sig: String,
472        payload: String,
473    ) -> Self {
474        Self {
475            scope_id,
476            from: None,
477            target: Some(target),
478            sig: Some(sig),
479            r#type: Some(MwsMessageType::HUB_DATA.to_string()),
480            payload: Some(payload),
481            error: None,
482            client_info: None,
483        }
484    }
485
486    pub fn set_from(&mut self, from: String) {
487        self.from = Some(from);
488    }
489
490    pub fn set_target(&mut self, target: String) {
491        self.target = Some(target);
492    }
493
494    pub fn set_sig(&mut self, sig: String) {
495        self.sig = Some(sig);
496    }
497
498    pub fn set_payload(&mut self, payload: String) {
499        self.payload = Some(payload);
500    }
501
502    pub fn set_type(&mut self, msg_type: String) {
503        self.r#type = Some(msg_type);
504    }
505
506    pub fn set_client_info(&mut self, client_info: MwsClientInfo) {
507        self.client_info = Some(client_info);
508    }
509}
510
511impl Default for MwsMessage {
512    fn default() -> Self {
513        Self::dummy()
514    }
515}
516
517#[derive(Debug, Clone, Serialize, Deserialize)]
518#[serde(rename_all = "camelCase")]
519pub struct NodeAuthRequest {
520    pub hub_id: String,
521    pub token: String,
522    pub node_type: String,
523    #[serde(default)]
524    pub node_id: String,
525    #[serde(default, skip_serializing_if = "Option::is_none")]
526    pub scope_id: Option<String>,
527    pub host_id: String,
528    #[serde(default, skip_serializing_if = "Option::is_none")]
529    pub host_name: Option<String>,
530    pub fingerprint: String,
531}
532
533#[derive(Debug, Clone, Serialize, Deserialize)]
534#[serde(rename_all = "camelCase")]
535pub struct NodeAuthResponse {
536    pub ok: bool,
537    #[serde(skip_serializing_if = "Option::is_none")]
538    pub session_id: Option<String>,
539    #[serde(skip_serializing_if = "Option::is_none")]
540    pub hub_id: Option<String>,
541    #[serde(skip_serializing_if = "Option::is_none")]
542    pub tenant_id: Option<String>,
543    #[serde(skip_serializing_if = "Option::is_none")]
544    pub scope_id: Option<String>,
545    #[serde(skip_serializing_if = "Option::is_none")]
546    pub error: Option<String>,
547    #[serde(skip_serializing_if = "Option::is_none")]
548    pub error_code: Option<String>,
549    #[serde(skip_serializing_if = "Option::is_none")]
550    pub recovery_action: Option<String>,
551    #[serde(skip_serializing_if = "Option::is_none")]
552    pub node_id: Option<String>,
553}
554
555#[derive(Debug, Clone, Serialize, Deserialize)]
556#[serde(rename_all = "camelCase")]
557pub struct NodeTokenIssueRequest {
558    pub transaction_id: String,
559    pub node_type: String,
560    pub candidate_host_id: String,
561    pub candidate_fingerprint: String,
562    pub challenge: NodeChallengeEvidence,
563}
564
565#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
566#[serde(rename_all = "camelCase")]
567pub enum NodeInstancePolicy {
568    Multiple,
569    Singleton,
570    Shared,
571}
572
573impl NodeInstancePolicy {
574    pub fn as_str(self) -> &'static str {
575        match self {
576            Self::Multiple => "multiple",
577            Self::Singleton => "singleton",
578            Self::Shared => "shared",
579        }
580    }
581}
582
583impl Default for NodeInstancePolicy {
584    fn default() -> Self {
585        Self::Multiple
586    }
587}
588
589impl std::fmt::Display for NodeInstancePolicy {
590    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
591        f.write_str(self.as_str())
592    }
593}
594
595impl FromStr for NodeInstancePolicy {
596    type Err = String;
597
598    fn from_str(value: &str) -> Result<Self, Self::Err> {
599        match value.trim() {
600            "multiple" | "Multiple" => Ok(Self::Multiple),
601            "singleton" | "Singleton" => Ok(Self::Singleton),
602            "shared" | "Shared" => Ok(Self::Shared),
603            other => Err(format!("unsupported node instance policy: {other}")),
604        }
605    }
606}
607
608#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
609#[serde(rename_all = "camelCase")]
610pub enum NodeBindingStatus {
611    Pending,
612    Active,
613    Revoked,
614    Expired,
615    Failed,
616}
617
618impl NodeBindingStatus {
619    pub fn as_str(self) -> &'static str {
620        match self {
621            Self::Pending => "pending",
622            Self::Active => "active",
623            Self::Revoked => "revoked",
624            Self::Expired => "expired",
625            Self::Failed => "failed",
626        }
627    }
628}
629
630impl Default for NodeBindingStatus {
631    fn default() -> Self {
632        Self::Active
633    }
634}
635
636impl std::fmt::Display for NodeBindingStatus {
637    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
638        f.write_str(self.as_str())
639    }
640}
641
642impl FromStr for NodeBindingStatus {
643    type Err = String;
644
645    fn from_str(value: &str) -> Result<Self, Self::Err> {
646        match value.trim() {
647            "pending" | "Pending" => Ok(Self::Pending),
648            "active" | "Active" => Ok(Self::Active),
649            "revoked" | "Revoked" => Ok(Self::Revoked),
650            "expired" | "Expired" => Ok(Self::Expired),
651            "failed" | "Failed" => Ok(Self::Failed),
652            other => Err(format!("unsupported node binding status: {other}")),
653        }
654    }
655}
656
657pub const NODE_ONBOARDING_CHALLENGE_PROTOCOL: &str = "meow.node.onboarding.challenge";
658pub const NODE_ONBOARDING_CHALLENGE_AUDIENCE: &str = "meow-core:node-onboarding";
659pub const NODE_ONBOARDING_CHALLENGE_ALGORITHM: &str = "Ed25519";
660pub const NODE_ONBOARDING_TOKEN_ISSUER_PREFIX: &str = "meow-core:hub:";
661pub const NODE_ONBOARDING_TOKEN_AUDIENCE: &str = "meow-node:onboarding";
662pub const NODE_ONBOARDING_ES256_ALGORITHM: &str = "ES256";
663pub const NODE_ONBOARDING_TRANSACTION_TTL_SECONDS: u64 = 5 * 60;
664pub const NODE_ONBOARDING_START_TARGET: &str = "/identity/node/onboarding/start";
665pub const NODE_TOKEN_ISSUE_TARGET: &str = "/identity/node/issue";
666pub const NODE_TOKEN_REVOKE_TARGET: &str = "/identity/node/revoke";
667pub const NODE_TOKEN_LIST_TARGET: &str = "/identity/node/list";
668pub const HUB_CONNECTION_PROOF_TARGET: &str = "/identity/hub/prove";
669pub const HUB_CONNECTION_PROOF_PROTOCOL: &str = "meow.hub.connection.proof.v1";
670
671#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
672#[serde(rename_all = "camelCase", deny_unknown_fields)]
673pub struct NodeOnboardingStartRequest {
674    pub node_type: String,
675    pub candidate_host_id: String,
676    pub candidate_fingerprint: String,
677    pub idempotency_key: String,
678}
679
680#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
681#[serde(rename_all = "camelCase", deny_unknown_fields)]
682pub struct NodeOnboardingStartResponse {
683    pub transaction_id: String,
684    pub nonce: String,
685    pub expires_at: i64,
686}
687
688#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
689#[serde(rename_all = "camelCase")]
690pub struct HubConnectionProofRequest {
691    pub protocol: String,
692    pub hub_id: String,
693    pub tenant_id: String,
694    pub scope_id: String,
695    pub nonce: String,
696}
697
698#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
699#[serde(rename_all = "camelCase")]
700pub struct HubConnectionProofResponse {
701    pub protocol: String,
702    pub hub_id: String,
703    pub tenant_id: String,
704    pub scope_id: String,
705    pub nonce: String,
706    pub key_id: String,
707    pub signature: String,
708}
709
710/// Canonical, domain-separated bytes signed by the Hub for one physical WS connection.
711/// Length prefixes keep the encoding unambiguous without relying on JSON object ordering.
712pub fn hub_connection_proof_signing_payload(request: &HubConnectionProofRequest) -> Vec<u8> {
713    let fields = [
714        request.protocol.as_str(),
715        request.hub_id.as_str(),
716        request.tenant_id.as_str(),
717        request.scope_id.as_str(),
718        request.nonce.as_str(),
719    ];
720    let mut payload = Vec::new();
721    for field in fields {
722        payload.extend_from_slice(&(field.len() as u64).to_be_bytes());
723        payload.extend_from_slice(field.as_bytes());
724    }
725    payload
726}
727
728#[derive(Debug, Clone, Serialize, Deserialize)]
729#[serde(rename_all = "camelCase")]
730pub struct NodeChallengeEvidence {
731    pub protocol: String,
732    pub algorithm: String,
733    pub payload: String,
734    pub signature: String,
735    pub public_key: String,
736    pub fingerprint: String,
737}
738
739#[derive(Debug, Clone, Serialize, Deserialize)]
740#[serde(rename_all = "camelCase")]
741pub struct NodeChallengePayload {
742    pub protocol: String,
743    pub aud: String,
744    pub nonce: String,
745    pub scope_id: String,
746    pub node_type: String,
747    pub host_id: String,
748    pub fingerprint: String,
749    pub service_instance_id: String,
750    pub instance_policy: NodeInstancePolicy,
751    pub instance_slot: String,
752}
753
754#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
755#[serde(rename_all = "camelCase")]
756pub struct NodeTokenIssueResponse {
757    pub token: String,
758    pub node_id: String,
759    pub expires_in: i64,
760    pub hub_id: String,
761}
762
763#[derive(Debug, Clone, Serialize, Deserialize)]
764#[serde(rename_all = "camelCase")]
765pub struct NodeTokenRevokeRequest {
766    pub node_type: String,
767    #[serde(default, skip_serializing_if = "Option::is_none")]
768    pub node_id: Option<String>,
769}
770
771#[derive(Debug, Clone, Serialize, Deserialize)]
772#[serde(rename_all = "camelCase")]
773pub struct NodeTokenRevokeResponse {
774    pub success: bool,
775}
776
777#[derive(Debug, Clone, Serialize, Deserialize)]
778#[serde(rename_all = "camelCase")]
779pub struct NodeTokenListItem {
780    pub node_id: String,
781    pub node_type: String,
782    pub hub_id: String,
783    pub host_id: String,
784    pub fingerprint: String,
785    pub service_instance_id: String,
786    pub instance_policy: NodeInstancePolicy,
787    pub instance_slot: String,
788    pub status: NodeBindingStatus,
789    pub issued_at: i64,
790    pub expires_at: i64,
791}
792
793#[derive(Debug, Clone, Serialize, Deserialize)]
794#[serde(rename_all = "camelCase")]
795pub struct NodeTokenListResponse {
796    #[serde(default)]
797    pub nodes: Vec<NodeTokenListItem>,
798}
799
800#[derive(Debug, Clone, Serialize, Deserialize)]
801#[serde(rename_all = "camelCase")]
802pub struct NodeInstanceListItem {
803    pub node_id: String,
804    pub node_type: String,
805    pub hub_id: String,
806    pub host_id: String,
807    #[serde(default, skip_serializing_if = "Option::is_none")]
808    pub host_name: Option<String>,
809    pub fingerprint: String,
810    pub service_instance_id: String,
811    pub instance_policy: NodeInstancePolicy,
812    pub instance_slot: String,
813    pub binding_status: NodeBindingStatus,
814    pub issued_at: i64,
815    pub expires_at: i64,
816    pub connected: bool,
817    pub runtime_status: String,
818}
819
820#[derive(Debug, Clone, Serialize, Deserialize)]
821#[serde(rename_all = "camelCase")]
822pub struct NodeInstanceListResponse {
823    #[serde(default)]
824    pub instances: Vec<NodeInstanceListItem>,
825}
826
827#[derive(Debug, Clone, Serialize, Deserialize)]
828#[serde(rename_all = "camelCase")]
829pub struct AuthenticatedSession {
830    pub tenant_id: String,
831    pub scope_id: String,
832    pub user_id: String,
833    pub is_test: bool,
834}
835
836#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
837#[serde(rename_all = "snake_case")]
838pub enum ScopeAccessRequirement {
839    Member,
840    Owner,
841}
842
843#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
844#[serde(rename_all = "camelCase", deny_unknown_fields)]
845pub struct ScopeAuthorizationRequest {
846    pub tenant_id: String,
847    pub scope_id: String,
848    pub user_id: String,
849    pub requirement: ScopeAccessRequirement,
850}
851
852#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
853#[serde(rename_all = "camelCase", deny_unknown_fields)]
854pub struct ScopeMembershipListRequest {
855    pub tenant_id: String,
856    pub user_id: String,
857}
858
859#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
860#[serde(rename_all = "camelCase", deny_unknown_fields)]
861pub struct ScopeMembership {
862    pub tenant_id: String,
863    pub scope_id: String,
864    pub user_id: String,
865}
866
867/// A service-owned AppClient materialized from an authoritative external model.
868/// This boundary intentionally carries no acting user: authorization belongs to
869/// the service that owns the source model, while Core validates the projection.
870#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
871#[serde(rename_all = "camelCase", deny_unknown_fields)]
872pub struct ServiceAppClientProjection {
873    pub tenant_id: String,
874    pub scope_id: String,
875    pub app_client_id: String,
876    #[serde(default, skip_serializing_if = "Option::is_none")]
877    pub user_id: Option<String>,
878    pub source: String,
879    pub device_type: String,
880    #[serde(default)]
881    pub scope_owned: bool,
882}
883
884#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
885#[serde(rename_all = "camelCase", deny_unknown_fields)]
886pub struct ServiceAppClientProjectionKey {
887    pub tenant_id: String,
888    pub scope_id: String,
889    pub app_client_id: String,
890}
891
892#[derive(Debug, Clone, Serialize, Deserialize)]
893pub enum ServiceCoreInput {
894    ClientAuth {
895        message: MwsMessage,
896        ws_id: String,
897        scope_id: String,
898    },
899    ClientRequest {
900        target: String,
901        payload: String,
902        ws_id: String,
903        tenant_id: String,
904        scope_id: String,
905        user_id: String,
906        is_test: bool,
907        #[serde(default, skip_serializing_if = "Option::is_none")]
908        surface_id: Option<String>,
909    },
910    /// Invokes the canonical Conversation application boundary without a
911    /// websocket/client transport identity.
912    ConversationRequest {
913        target: String,
914        payload: String,
915        tenant_id: String,
916        scope_id: String,
917        actor_user_id: String,
918        surface_id: String,
919        is_test: bool,
920    },
921    /// Authorizes a trusted headless actor before a non-Conversation operation
922    /// such as binding a shared messaging surface.
923    ScopeAuthorization {
924        request: ScopeAuthorizationRequest,
925    },
926    /// Lists authoritative scope memberships for a trusted headless principal.
927    ScopeMembershipList {
928        request: ScopeMembershipListRequest,
929    },
930    /// Idempotently materializes a trusted service-owned AppClient.
931    AppClientProjectionPut {
932        projection: ServiceAppClientProjection,
933    },
934    /// Idempotently removes a trusted service-owned AppClient.
935    AppClientProjectionDelete {
936        key: ServiceAppClientProjectionKey,
937    },
938    NodeAuth {
939        request: NodeAuthRequest,
940    },
941    HubConnectionProof {
942        request: HubConnectionProofRequest,
943    },
944    NodeRequest {
945        target: String,
946        payload: String,
947        tenant_id: String,
948        scope_id: String,
949        node_type: String,
950        node_id: String,
951    },
952    /// Reports status from an already authenticated Node transport. Core uses
953    /// `connection_key` to bind the untrusted payload to the authoritative
954    /// Node session before accepting it into the Hub-owned status projection.
955    NodeStatusObserved {
956        connection_key: String,
957        status: Box<node::status::StatusPayload>,
958    },
959    ClientResponse {
960        message: MwsMessage,
961    },
962    NodeResponse {
963        message: MwsMessage,
964    },
965    IssueLocalSessionToken {
966        tenant_id: String,
967        scope_id: String,
968        user_id: String,
969        app_client_id: String,
970    },
971    SetLocalAppClientFocus {
972        ws_id: String,
973        focused: bool,
974    },
975    RefreshLocalAppClient {
976        ws_id: String,
977    },
978}
979
980#[derive(Debug, Clone, Serialize, Deserialize)]
981pub enum ServiceCoreResponse {
982    ClientAuth {
983        session: Option<AuthenticatedSession>,
984        response: MwsMessage,
985    },
986    ClientRequest {
987        response_payload: Option<String>,
988        client_info: MwsClientInfo,
989    },
990    ConversationRequest {
991        response_payload: Option<String>,
992    },
993    ScopeAuthorization {
994        authorized: bool,
995    },
996    ScopeMembershipList {
997        memberships: Vec<ScopeMembership>,
998    },
999    AppClientProjection {
1000        applied: bool,
1001    },
1002    NodeAuth(NodeAuthResponse),
1003    HubConnectionProof(HubConnectionProofResponse),
1004    NodeRequest {
1005        response_payload: Option<String>,
1006    },
1007    LocalSessionToken(String),
1008    Ack {
1009        handled: bool,
1010    },
1011}
1012
1013#[derive(Debug, Clone, Serialize, Deserialize, Default)]
1014#[serde(deny_unknown_fields)]
1015pub struct ServiceCoreOutput {
1016    #[serde(skip_serializing_if = "Option::is_none")]
1017    pub response: Option<ServiceCoreResponse>,
1018}
1019
1020#[derive(Debug, Clone, Serialize, Deserialize)]
1021#[serde(rename_all = "camelCase")]
1022pub struct UserSetting {
1023    #[serde(default)]
1024    pub script_mode: bool,
1025    #[serde(default)]
1026    pub debug_mode: bool,
1027    #[serde(default)]
1028    pub eng_account: bool,
1029}
1030
1031impl UserSetting {
1032    pub fn new() -> Self {
1033        Self {
1034            script_mode: false,
1035            debug_mode: false,
1036            eng_account: false,
1037        }
1038    }
1039}
1040
1041impl Default for UserSetting {
1042    fn default() -> Self {
1043        Self::new()
1044    }
1045}
1046
1047#[derive(Debug, Clone, Serialize, Deserialize)]
1048#[serde(rename_all = "camelCase")]
1049pub struct UserInfo {
1050    #[serde(skip_serializing_if = "Option::is_none")]
1051    pub id: Option<String>,
1052    #[serde(skip_serializing_if = "Option::is_none")]
1053    pub name: Option<String>,
1054    #[serde(skip_serializing_if = "Option::is_none")]
1055    pub email: Option<String>,
1056    #[serde(skip_serializing_if = "Option::is_none")]
1057    pub setting: Option<UserSetting>,
1058    #[serde(default)]
1059    pub eng: bool,
1060}
1061
1062impl UserInfo {
1063    pub fn new(id: String) -> Self {
1064        Self {
1065            id: Some(id),
1066            name: None,
1067            email: None,
1068            setting: None,
1069            eng: false,
1070        }
1071    }
1072}
1073
1074#[derive(Debug, Clone, Serialize, Deserialize)]
1075#[serde(rename_all = "camelCase")]
1076pub struct ScopeMember {
1077    pub id: Option<String>,
1078    pub email: Option<String>,
1079    pub name: Option<String>,
1080    pub pending: bool,
1081    pub role: Option<String>,
1082}
1083
1084impl Default for ScopeMember {
1085    fn default() -> Self {
1086        Self {
1087            id: None,
1088            email: None,
1089            name: None,
1090            pending: false,
1091            role: None,
1092        }
1093    }
1094}
1095
1096#[derive(Debug, Clone, Serialize, Deserialize)]
1097#[serde(rename_all = "camelCase")]
1098pub struct ScopeInfo {
1099    pub name: Option<String>,
1100    pub id: Option<String>,
1101    pub pending: bool,
1102    pub members: Option<Vec<ScopeMember>>,
1103    pub execution_env: Option<String>,
1104    pub mode: Option<String>,
1105    pub connection_mode: Option<String>,
1106    pub agent_mode: Option<String>,
1107    pub default_active: bool,
1108    #[serde(default)]
1109    pub is_test: bool,
1110}
1111
1112impl Default for ScopeInfo {
1113    fn default() -> Self {
1114        Self {
1115            name: None,
1116            id: None,
1117            pending: false,
1118            members: None,
1119            execution_env: None,
1120            mode: None,
1121            connection_mode: None,
1122            agent_mode: None,
1123            default_active: false,
1124            is_test: false,
1125        }
1126    }
1127}
1128
1129#[derive(Debug, Clone, Serialize, Deserialize)]
1130#[serde(rename_all = "camelCase")]
1131pub struct AuthConfig {
1132    pub tenant_id: String,
1133    pub heartbeat_interval: i32,
1134    pub command_timeout: i32,
1135    pub user_info: UserInfo,
1136    pub scope: ScopeInfo,
1137    #[serde(skip_serializing_if = "Option::is_none")]
1138    pub hub_id: Option<String>,
1139    #[serde(skip_serializing_if = "Option::is_none")]
1140    pub jwt_token: Option<String>,
1141}
1142
1143impl AuthConfig {
1144    pub fn new(
1145        tenant_id: String,
1146        heartbeat_interval: i32,
1147        command_timeout: i32,
1148        user_info: UserInfo,
1149        scope: ScopeInfo,
1150    ) -> Self {
1151        Self {
1152            tenant_id,
1153            heartbeat_interval,
1154            command_timeout,
1155            user_info,
1156            scope,
1157            hub_id: None,
1158            jwt_token: None,
1159        }
1160    }
1161}
1162
1163impl Default for AuthConfig {
1164    fn default() -> Self {
1165        Self {
1166            tenant_id: String::new(),
1167            heartbeat_interval: 240,
1168            command_timeout: 10,
1169            user_info: UserInfo::new(String::new()),
1170            scope: ScopeInfo::default(),
1171            hub_id: None,
1172            jwt_token: None,
1173        }
1174    }
1175}
1176
1177#[derive(Debug, Clone, Serialize, Deserialize)]
1178#[serde(rename_all = "camelCase")]
1179pub struct AuthRequest {
1180    pub token: String,
1181    pub source: String,
1182    pub scope_id: String,
1183    pub device_id: String,
1184    pub client_source: String,
1185    #[serde(default)]
1186    pub tenant_id: Option<String>,
1187    #[serde(default)]
1188    pub hub_id: Option<String>,
1189}
1190
1191#[derive(Debug, Clone, Serialize, Deserialize)]
1192#[serde(rename_all = "camelCase")]
1193pub struct HubMdnsInstanceRecord {
1194    pub tenant_id: String,
1195    pub scope_id: String,
1196    pub hub_id: String,
1197    pub scope_name: String,
1198}
1199
1200#[cfg(test)]
1201mod tests {
1202    use super::{
1203        hub_connection_proof_signing_payload, HubConnectionProofRequest, MwsMessage,
1204        MwsMessageType, NodeOnboardingStartRequest, HUB_CONNECTION_PROOF_PROTOCOL,
1205    };
1206
1207    #[test]
1208    fn hub_connection_proof_payload_is_unambiguous_and_nonce_bound() {
1209        let request = HubConnectionProofRequest {
1210            protocol: HUB_CONNECTION_PROOF_PROTOCOL.to_string(),
1211            hub_id: "hub-1".to_string(),
1212            tenant_id: "tenant-1".to_string(),
1213            scope_id: "scope-1".to_string(),
1214            nonce: "nonce-1".to_string(),
1215        };
1216        let payload = hub_connection_proof_signing_payload(&request);
1217        let mut changed = request.clone();
1218        changed.nonce = "nonce-2".to_string();
1219
1220        assert_ne!(payload, hub_connection_proof_signing_payload(&changed));
1221        assert!(payload.starts_with(&(HUB_CONNECTION_PROOF_PROTOCOL.len() as u64).to_be_bytes()));
1222    }
1223
1224    #[test]
1225    fn scoped_server_data_builds_scope_envelope() {
1226        let message = MwsMessage::scoped_server_data(
1227            "scope-1".to_string(),
1228            "/dialog".to_string(),
1229            "{}".to_string(),
1230            None,
1231        );
1232
1233        assert_eq!(message.scope_id.as_deref(), Some("scope-1"));
1234        assert_eq!(message.target.as_deref(), Some("/dialog"));
1235        assert_eq!(message.r#type.as_deref(), Some(MwsMessageType::SERVER_DATA));
1236        assert_eq!(message.payload.as_deref(), Some("{}"));
1237        assert!(message.client_info.is_none());
1238    }
1239
1240    #[test]
1241    fn node_onboarding_start_contract_uses_camel_case_and_rejects_unknown_fields() {
1242        let request = NodeOnboardingStartRequest {
1243            node_type: "matter".to_string(),
1244            candidate_host_id: "host-1".to_string(),
1245            candidate_fingerprint: "sha256:abc".to_string(),
1246            idempotency_key: "attempt-1".to_string(),
1247        };
1248
1249        let json = serde_json::to_value(&request).expect("serialize start request");
1250        assert_eq!(json["candidateHostId"], "host-1");
1251        assert_eq!(json["idempotencyKey"], "attempt-1");
1252
1253        let invalid = serde_json::json!({
1254            "nodeType": "matter",
1255            "candidateHostId": "host-1",
1256            "candidateFingerprint": "sha256:abc",
1257            "idempotencyKey": "attempt-1",
1258            "nonce": "caller-must-not-supply-this"
1259        });
1260        assert!(serde_json::from_value::<NodeOnboardingStartRequest>(invalid).is_err());
1261    }
1262}