Skip to main content

macp_modes/mode/
passthrough.rs

1use crate::mode::{Mode, ModeResponse};
2use macp_core::error::MacpError;
3use macp_core::session::Session;
4use macp_pb::pb::Envelope;
5
6/// Generic extension mode handler for dynamically registered modes.
7///
8/// Accepts any message type listed in the mode descriptor. Commitment messages
9/// from the initiator resolve the session. All other messages are accepted and
10/// the payload is persisted as mode state.
11pub struct PassthroughMode {
12    pub allowed_message_types: Vec<String>,
13}
14
15impl Mode for PassthroughMode {
16    fn on_session_start(
17        &self,
18        _session: &Session,
19        _env: &Envelope,
20    ) -> Result<ModeResponse, MacpError> {
21        Ok(ModeResponse::NoOp)
22    }
23
24    fn on_message(&self, session: &Session, env: &Envelope) -> Result<ModeResponse, MacpError> {
25        if !self.allowed_message_types.is_empty()
26            && !self
27                .allowed_message_types
28                .iter()
29                .any(|t| t == &env.message_type)
30        {
31            return Err(MacpError::InvalidPayload);
32        }
33
34        if env.message_type == "Commitment" {
35            let commitment =
36                crate::mode::util::validate_commitment_payload_for_session(session, &env.payload)?;
37            let resolution = serde_json::json!({
38                "action": commitment.action,
39                "commitment_id": commitment.commitment_id,
40            })
41            .to_string()
42            .into_bytes();
43            return Ok(ModeResponse::Resolve(resolution));
44        }
45
46        Ok(ModeResponse::PersistState(env.payload.clone()))
47    }
48
49    fn authorize_sender(&self, session: &Session, env: &Envelope) -> Result<(), MacpError> {
50        if env.message_type == "Commitment" {
51            if env.sender != session.initiator_sender {
52                return Err(MacpError::Forbidden);
53            }
54            return Ok(());
55        }
56        if !session.participants.is_empty() && !session.participants.contains(&env.sender) {
57            return Err(MacpError::Forbidden);
58        }
59        Ok(())
60    }
61}
62
63#[cfg(test)]
64mod tests {
65    use super::*;
66    use macp_core::session::SessionState;
67    use macp_pb::pb::CommitmentPayload;
68    use prost::Message;
69
70    fn make_session() -> Session {
71        Session {
72            session_id: "s1".into(),
73            state: SessionState::Open,
74            ttl_expiry: i64::MAX,
75            ttl_ms: 60_000,
76            started_at_unix_ms: 1000,
77            resolution: None,
78            mode: "ext.test.v1".into(),
79            mode_state: vec![],
80            participants: vec!["alice".into(), "bob".into()],
81            seen_message_ids: std::collections::HashSet::new(),
82            intent: String::new(),
83            mode_version: "1.0.0".into(),
84            configuration_version: "cfg-1".into(),
85            policy_version: String::new(),
86            context_id: String::new(),
87            extensions: std::collections::HashMap::new(),
88            roots: vec![],
89            initiator_sender: "alice".into(),
90            participant_message_counts: std::collections::HashMap::new(),
91            participant_last_seen: std::collections::HashMap::new(),
92            policy_definition: None,
93            suspended_at_ms: None,
94            accumulated_suspended_ms: 0,
95        }
96    }
97
98    fn make_env(sender: &str, message_type: &str, payload: Vec<u8>) -> Envelope {
99        Envelope {
100            macp_version: "1.0".into(),
101            mode: "ext.test.v1".into(),
102            message_type: message_type.into(),
103            message_id: "m1".into(),
104            session_id: "s1".into(),
105            sender: sender.into(),
106            timestamp_unix_ms: 1000,
107            payload,
108        }
109    }
110
111    #[test]
112    fn accepts_any_message_when_no_filter() {
113        let mode = PassthroughMode {
114            allowed_message_types: vec![],
115        };
116        let session = make_session();
117        let env = make_env("alice", "CustomMessage", b"data".to_vec());
118        let result = mode.on_message(&session, &env).unwrap();
119        assert!(matches!(result, ModeResponse::PersistState(_)));
120    }
121
122    #[test]
123    fn rejects_unlisted_message_type() {
124        let mode = PassthroughMode {
125            allowed_message_types: vec!["Allowed".into()],
126        };
127        let session = make_session();
128        let env = make_env("alice", "NotAllowed", vec![]);
129        assert!(mode.on_message(&session, &env).is_err());
130    }
131
132    #[test]
133    fn commitment_resolves_session() {
134        let mode = PassthroughMode {
135            allowed_message_types: vec!["Commitment".into()],
136        };
137        let session = make_session();
138        let payload = CommitmentPayload {
139            commitment_id: "c1".into(),
140            action: "test.done".into(),
141            authority_scope: "test".into(),
142            reason: "done".into(),
143            mode_version: "1.0.0".into(),
144            policy_version: String::new(),
145            configuration_version: "cfg-1".into(),
146            outcome_positive: true,
147            supersedes: None,
148        }
149        .encode_to_vec();
150        let env = make_env("alice", "Commitment", payload);
151        let result = mode.on_message(&session, &env).unwrap();
152        assert!(matches!(result, ModeResponse::Resolve(_)));
153    }
154
155    #[test]
156    fn non_initiator_commitment_forbidden() {
157        let mode = PassthroughMode {
158            allowed_message_types: vec!["Commitment".into()],
159        };
160        let session = make_session();
161        let env = make_env("bob", "Commitment", vec![]);
162        assert!(mode.authorize_sender(&session, &env).is_err());
163    }
164}