Skip to main content

core_api/
lib.rs

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