Skip to main content

core_api/
lib.rs

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