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