Skip to main content

app_facade_api/
routing.rs

1//! Transport-neutral routing policy for public `/app/*` operations.
2//!
3//! This module describes where an App Facade operation is authoritative. It
4//! deliberately does not describe sockets, connection fallback, authentication,
5//! or Lion's legacy controller routing. Route data is generated from
6//! `manifest/routing.v1.json`, the language-neutral source of truth.
7
8#[derive(Debug, Clone, Copy, PartialEq, Eq)]
9pub enum ExecutionSelector {
10    /// Execute against the cloud control plane.
11    Cloud,
12    /// Follow the active Space mode: cloud Space or its selected Hub.
13    ScopeMode,
14    /// Execute on the selected Space Hub, independent of Space mode.
15    Hub,
16    /// Read `input.placement` from the canonical [`crate::FacadeCall`].
17    RequestPlacement,
18}
19
20#[derive(Debug, Clone, Copy, PartialEq, Eq)]
21pub enum RelayPolicy {
22    /// The client must reach the selected executor directly.
23    DirectOnly,
24    /// A Hub-owned request may use the cloud connection as a transport relay.
25    CloudRelayAllowed,
26}
27
28#[derive(Debug, Clone, Copy, PartialEq, Eq)]
29pub struct RoutePolicy {
30    pub execution: ExecutionSelector,
31    pub relay: RelayPolicy,
32}
33
34const DIRECT_CLOUD: RoutePolicy = RoutePolicy {
35    execution: ExecutionSelector::Cloud,
36    relay: RelayPolicy::DirectOnly,
37};
38const DIRECT_SCOPE: RoutePolicy = RoutePolicy {
39    execution: ExecutionSelector::ScopeMode,
40    relay: RelayPolicy::DirectOnly,
41};
42const DIRECT_HUB: RoutePolicy = RoutePolicy {
43    execution: ExecutionSelector::Hub,
44    relay: RelayPolicy::DirectOnly,
45};
46const RELAYABLE_HUB: RoutePolicy = RoutePolicy {
47    execution: ExecutionSelector::Hub,
48    relay: RelayPolicy::CloudRelayAllowed,
49};
50const REQUEST_PLACEMENT: RoutePolicy = RoutePolicy {
51    execution: ExecutionSelector::RequestPlacement,
52    relay: RelayPolicy::DirectOnly,
53};
54
55include!("routing.generated.rs");
56
57/// Resolve routing metadata for a canonical App Facade target.
58///
59/// Exact exceptions are evaluated before domain prefixes. Unknown `/app/*`
60/// targets intentionally receive the normal Space-mode policy; protocol
61/// support remains the receiving facade's responsibility.
62pub fn route_policy_for_target(target: &str) -> Option<RoutePolicy> {
63    if !target.starts_with(TARGET_PREFIX) {
64        return None;
65    }
66
67    // Every exact exception is resolved before any domain prefix.
68    if SCOPE_MODE_TARGETS.contains(&target) {
69        return Some(DIRECT_SCOPE);
70    }
71    if CLOUD_TARGETS.contains(&target) {
72        return Some(DIRECT_CLOUD);
73    }
74    if CLOUD_RELAY_TARGETS.contains(&target) {
75        return Some(RELAYABLE_HUB);
76    }
77
78    if REQUEST_PLACEMENT_PREFIXES
79        .iter()
80        .any(|prefix| target.starts_with(prefix))
81    {
82        return Some(REQUEST_PLACEMENT);
83    }
84    if HUB_PREFIXES.iter().any(|prefix| target.starts_with(prefix)) {
85        return Some(DIRECT_HUB);
86    }
87    if CLOUD_PREFIXES
88        .iter()
89        .any(|prefix| target.starts_with(prefix))
90    {
91        return Some(DIRECT_CLOUD);
92    }
93
94    Some(DIRECT_SCOPE)
95}
96
97/// Read request-controlled placement from the strict App Facade envelope.
98/// Domain payloads without the canonical `input` boundary are rejected.
99pub fn request_placement(payload: &str) -> Option<crate::messaging::Placement> {
100    let call: crate::FacadeCall = serde_json::from_str(payload).ok()?;
101    serde_json::from_value(call.input.get("placement")?.clone()).ok()
102}
103
104#[cfg(test)]
105mod tests {
106    use super::*;
107
108    #[test]
109    fn exact_exceptions_win_over_domain_defaults() {
110        assert_eq!(
111            route_policy_for_target("/app/scope/context/get"),
112            Some(DIRECT_SCOPE)
113        );
114        assert_eq!(
115            route_policy_for_target(crate::plugin::INSTALLED_LIST_TARGET),
116            Some(RELAYABLE_HUB)
117        );
118        assert_eq!(
119            route_policy_for_target(crate::plugin::ADD_START_TARGET),
120            Some(DIRECT_HUB)
121        );
122    }
123
124    #[test]
125    fn domains_select_their_authoritative_executor() {
126        assert_eq!(
127            route_policy_for_target("/app/scope/member/list"),
128            Some(DIRECT_CLOUD)
129        );
130        assert_eq!(
131            route_policy_for_target("/app/messaging/provider/list"),
132            Some(REQUEST_PLACEMENT)
133        );
134        assert_eq!(
135            route_policy_for_target("/app/object-storage/session/acquire"),
136            Some(DIRECT_HUB)
137        );
138        assert_eq!(
139            route_policy_for_target("/app/runtime/status/list"),
140            Some(DIRECT_HUB)
141        );
142        assert_eq!(
143            route_policy_for_target("/app/device/list"),
144            Some(DIRECT_SCOPE)
145        );
146    }
147
148    #[test]
149    fn legacy_targets_are_outside_the_contract() {
150        assert_eq!(route_policy_for_target("/scope/get"), None);
151        assert_eq!(route_policy_for_target("/chat"), None);
152    }
153
154    #[test]
155    fn placement_is_read_only_from_the_canonical_input() {
156        assert_eq!(
157            request_placement(r#"{"control":{"mode":"direct"},"input":{"placement":"local"}}"#),
158            Some(crate::messaging::Placement::Local)
159        );
160        assert_eq!(request_placement(r#"{"placement":"cloud"}"#), None);
161        assert_eq!(
162            request_placement(r#"{"control":{"mode":"direct"},"input":{}}"#),
163            None
164        );
165    }
166}