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