1use crate::config::{DataMode, InterfaceConfig};
2use crate::events::{Event, EventPayload};
3use std::fmt;
4
5pub const INTERFACE_BLOCKED_EVENT: &str = "interfaces.blocked";
6pub const INTERFACE_PLAN_EVENT: &str = "interfaces.plan";
7pub const INTERFACE_REQUEST_PLAN_EVENT: &str = "interfaces.request_plan";
8pub const INTERFACE_ROUTE_PLAN_EVENT: &str = "interfaces.route_plan";
9
10#[derive(Debug, Clone, PartialEq, Eq)]
11pub struct InterfacePlan {
12 pub transactions_enabled: bool,
13 pub state_enabled: bool,
14 pub batches_enabled: bool,
15 pub archive_enabled: bool,
16 pub blocked_reason: Option<String>,
17}
18
19#[derive(Debug, Clone, PartialEq, Eq)]
20pub struct InterfaceRequest {
21 pub name: String,
22 pub requested: bool,
23 pub port: u16,
24}
25
26#[derive(Debug, Clone, PartialEq, Eq)]
27pub struct InterfaceRequestPlan {
28 pub requests: Vec<InterfaceRequest>,
29}
30
31#[derive(Debug, Clone, Copy, PartialEq, Eq)]
32pub enum InterfaceKind {
33 Transactions,
34 State,
35 Batches,
36 Archive,
37}
38
39#[derive(Debug, Clone, PartialEq, Eq)]
40pub struct InterfaceRoute {
41 pub kind: InterfaceKind,
42 pub name: String,
43 pub path: String,
44 pub port: u16,
45 pub state: InterfaceState,
46 pub max_body_bytes: usize,
47}
48
49#[derive(Debug, Clone, PartialEq, Eq)]
50pub struct InterfaceRoutePlan {
51 pub routes: Vec<InterfaceRoute>,
52}
53
54#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
55pub struct InterfaceRouteCounts {
56 pub closed: usize,
57 pub planned: usize,
58 pub open: usize,
59}
60
61impl InterfaceRouteCounts {
62 pub fn total(self) -> usize {
63 self.closed + self.planned + self.open
64 }
65}
66
67impl InterfaceRequestPlan {
68 pub fn requested_count(&self) -> usize {
69 self.requests
70 .iter()
71 .filter(|request| request.requested)
72 .count()
73 }
74
75 pub fn summary_line(&self) -> String {
76 let requested = self.requested_count();
77 format!(
78 "interface_requests total={} requested={} unrequested={}",
79 self.requests.len(),
80 requested,
81 self.requests.len().saturating_sub(requested)
82 )
83 }
84
85 pub fn to_event(&self) -> Event {
86 Event::new(
87 INTERFACE_REQUEST_PLAN_EVENT,
88 EventPayload::Text(self.summary_line()),
89 )
90 }
91
92 pub fn event_batch(&self) -> Vec<Event> {
93 vec![self.to_event()]
94 }
95}
96
97impl InterfacePlan {
98 pub fn from_config(interfaces: &InterfaceConfig, data_mode: DataMode) -> Self {
99 let any_public_interface = interfaces.transaction_port != 0
100 || interfaces.state_port != 0
101 || interfaces.batch_port != 0;
102 let blocked_reason = if any_public_interface && data_mode != DataMode::Full {
103 Some("public interfaces require ACROPOLIS_DATA_MODE=full".to_string())
104 } else {
105 None
106 };
107 Self {
108 transactions_enabled: interfaces.transaction_port != 0 && blocked_reason.is_none(),
109 state_enabled: interfaces.state_port != 0 && blocked_reason.is_none(),
110 batches_enabled: interfaces.batch_port != 0 && blocked_reason.is_none(),
111 archive_enabled: interfaces.archive_port != 0,
112 blocked_reason,
113 }
114 }
115
116 pub fn request_plan(config: &InterfaceConfig) -> InterfaceRequestPlan {
117 InterfaceRequestPlan {
118 requests: vec![
119 InterfaceRequest {
120 name: "transactions".to_string(),
121 requested: config.transaction_port != 0,
122 port: config.transaction_port,
123 },
124 InterfaceRequest {
125 name: "state".to_string(),
126 requested: config.state_port != 0,
127 port: config.state_port,
128 },
129 InterfaceRequest {
130 name: "batches".to_string(),
131 requested: config.batch_port != 0,
132 port: config.batch_port,
133 },
134 InterfaceRequest {
135 name: "archive".to_string(),
136 requested: config.archive_port != 0,
137 port: config.archive_port,
138 },
139 ],
140 }
141 }
142
143 pub fn route_plan(config: &InterfaceConfig, data_mode: DataMode) -> InterfaceRoutePlan {
144 let plan = Self::from_config(config, data_mode);
145 InterfaceRoutePlan {
146 routes: vec![
147 InterfaceRoute::new(
148 InterfaceKind::Transactions,
149 "transactions",
150 "/transactions",
151 config.transaction_port,
152 plan.transactions_enabled,
153 ),
154 InterfaceRoute::new(
155 InterfaceKind::State,
156 "state",
157 "/state",
158 config.state_port,
159 plan.state_enabled,
160 ),
161 InterfaceRoute::new(
162 InterfaceKind::Batches,
163 "batches",
164 "/batches",
165 config.batch_port,
166 plan.batches_enabled,
167 ),
168 InterfaceRoute::new(
169 InterfaceKind::Archive,
170 "archive",
171 "/archive",
172 config.archive_port,
173 plan.archive_enabled,
174 ),
175 ],
176 }
177 }
178
179 pub fn events(&self) -> Vec<Event> {
180 let Some(reason) = &self.blocked_reason else {
181 return Vec::new();
182 };
183 vec![Event::new(
184 INTERFACE_BLOCKED_EVENT,
185 EventPayload::Text(format!(
186 "transactions={} state={} batches={} archive={} reason={reason}",
187 self.transactions_enabled,
188 self.state_enabled,
189 self.batches_enabled,
190 self.archive_enabled
191 )),
192 )]
193 }
194
195 pub fn summary_line(&self) -> String {
196 format!(
197 "interface_plan transactions={} state={} batches={} archive={} blocked={}",
198 self.transactions_enabled,
199 self.state_enabled,
200 self.batches_enabled,
201 self.archive_enabled,
202 self.blocked_reason.is_some()
203 )
204 }
205
206 pub fn to_event(&self) -> Event {
207 Event::new(
208 INTERFACE_PLAN_EVENT,
209 EventPayload::Text(self.summary_line()),
210 )
211 }
212
213 pub fn event_batch(&self) -> Vec<Event> {
214 let mut events = vec![self.to_event()];
215 events.extend(self.events());
216 events
217 }
218}
219
220impl InterfaceRoute {
221 pub fn new(
222 kind: InterfaceKind,
223 name: impl Into<String>,
224 path: impl Into<String>,
225 port: u16,
226 enabled: bool,
227 ) -> Self {
228 Self {
229 kind,
230 name: name.into(),
231 path: path.into(),
232 port,
233 state: if enabled {
234 InterfaceState::Planned
235 } else {
236 InterfaceState::Closed
237 },
238 max_body_bytes: match kind {
239 InterfaceKind::Transactions => 16 * 1024,
240 InterfaceKind::State => 1024 * 1024,
241 InterfaceKind::Batches => 64 * 1024,
242 InterfaceKind::Archive => 4 * 1024 * 1024,
243 },
244 }
245 }
246}
247
248impl InterfaceRoutePlan {
249 pub fn summary_line(&self) -> String {
250 let counts = self.state_counts();
251 format!(
252 "interface_routes total={} closed={} planned={} open={}",
253 counts.total(),
254 counts.closed,
255 counts.planned,
256 counts.open
257 )
258 }
259
260 pub fn to_event(&self) -> Event {
261 Event::new(
262 INTERFACE_ROUTE_PLAN_EVENT,
263 EventPayload::Text(self.summary_line()),
264 )
265 }
266
267 pub fn event_batch(&self) -> Vec<Event> {
268 vec![self.to_event()]
269 }
270
271 pub fn planned(&self) -> Vec<&InterfaceRoute> {
272 self.routes
273 .iter()
274 .filter(|route| route.state == InterfaceState::Planned)
275 .collect()
276 }
277
278 pub fn state_counts(&self) -> InterfaceRouteCounts {
279 let mut counts = InterfaceRouteCounts::default();
280 for route in &self.routes {
281 match route.state {
282 InterfaceState::Closed => counts.closed += 1,
283 InterfaceState::Planned => counts.planned += 1,
284 InterfaceState::Open => counts.open += 1,
285 }
286 }
287 counts
288 }
289
290 pub fn handle_local(
291 &self,
292 kind: InterfaceKind,
293 body: &[u8],
294 ) -> Result<InterfaceResponse, InterfaceError> {
295 let route = self
296 .routes
297 .iter()
298 .find(|route| route.kind == kind)
299 .ok_or(InterfaceError::UnknownRoute(kind))?;
300 route.handle_local(body)
301 }
302}
303
304impl InterfaceRoute {
305 pub fn handle_local(&self, body: &[u8]) -> Result<InterfaceResponse, InterfaceError> {
306 if self.state == InterfaceState::Closed {
307 return Err(InterfaceError::RouteClosed(self.name.clone()));
308 }
309 if body.len() > self.max_body_bytes {
310 return Err(InterfaceError::BodyTooLarge {
311 name: self.name.clone(),
312 max: self.max_body_bytes,
313 actual: body.len(),
314 });
315 }
316 if matches!(
317 self.kind,
318 InterfaceKind::Transactions | InterfaceKind::Batches
319 ) && body.is_empty()
320 {
321 return Err(InterfaceError::EmptyBody(self.name.clone()));
322 }
323 let status_code = match self.kind {
324 InterfaceKind::Transactions | InterfaceKind::Batches => 202,
325 InterfaceKind::State | InterfaceKind::Archive => 200,
326 };
327 Ok(InterfaceResponse {
328 kind: self.kind,
329 status_code,
330 body: format!(
331 "{} handled locally at {} bytes={}",
332 self.name,
333 self.path,
334 body.len()
335 ),
336 })
337 }
338}
339
340#[derive(Debug, Clone, PartialEq, Eq)]
341pub struct InterfaceResponse {
342 pub kind: InterfaceKind,
343 pub status_code: u16,
344 pub body: String,
345}
346
347#[derive(Debug, Clone, Copy, PartialEq, Eq)]
348pub enum InterfaceState {
349 Closed,
350 Planned,
351 Open,
352}
353
354#[derive(Debug, Clone, PartialEq, Eq)]
355pub struct InterfaceLatch {
356 pub name: String,
357 pub state: InterfaceState,
358}
359
360impl InterfaceLatch {
361 pub fn new(name: impl Into<String>) -> Self {
362 Self {
363 name: name.into(),
364 state: InterfaceState::Closed,
365 }
366 }
367
368 pub fn plan(&mut self) {
369 if self.state == InterfaceState::Closed {
370 self.state = InterfaceState::Planned;
371 }
372 }
373
374 pub fn open(&mut self, allow_paths: bool) -> Result<(), InterfaceError> {
375 if !allow_paths {
376 return Err(InterfaceError::SafetyClosed(self.name.clone()));
377 }
378 self.state = InterfaceState::Open;
379 Ok(())
380 }
381
382 pub fn close(&mut self) {
383 self.state = InterfaceState::Closed;
384 }
385}
386
387#[derive(Debug, Clone, PartialEq, Eq)]
388pub enum InterfaceError {
389 SafetyClosed(String),
390 RouteClosed(String),
391 UnknownRoute(InterfaceKind),
392 EmptyBody(String),
393 BodyTooLarge {
394 name: String,
395 max: usize,
396 actual: usize,
397 },
398}
399
400impl fmt::Display for InterfaceError {
401 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
402 match self {
403 Self::SafetyClosed(name) => write!(f, "interface {name} blocked by safety config"),
404 Self::RouteClosed(name) => write!(f, "interface route {name} is closed"),
405 Self::UnknownRoute(kind) => write!(f, "unknown interface route {kind:?}"),
406 Self::EmptyBody(name) => write!(f, "interface route {name} requires a body"),
407 Self::BodyTooLarge { name, max, actual } => write!(
408 f,
409 "interface route {name} body too large: max={max} actual={actual}"
410 ),
411 }
412 }
413}
414
415impl std::error::Error for InterfaceError {}
416
417#[cfg(test)]
418mod tests {
419 use super::*;
420
421 #[test]
422 fn interface_latch_stays_closed_without_safety_permission() {
423 let mut latch = InterfaceLatch::new("transactions");
424 latch.plan();
425 assert_eq!(latch.state, InterfaceState::Planned);
426 assert_eq!(
427 latch.open(false),
428 Err(InterfaceError::SafetyClosed("transactions".to_string()))
429 );
430 assert_eq!(latch.state, InterfaceState::Planned);
431 latch.close();
432 assert_eq!(latch.state, InterfaceState::Closed);
433 }
434
435 #[test]
436 fn request_plan_lists_closed_interface_requests() {
437 let config = InterfaceConfig {
438 transaction_port: 10,
439 state_port: 0,
440 batch_port: 0,
441 archive_port: 20,
442 archive_base_url: None,
443 };
444 let plan = InterfacePlan::request_plan(&config);
445 assert_eq!(plan.requested_count(), 2);
446 assert_eq!(plan.event_batch().len(), 1);
447 assert_eq!(
448 plan.event_batch()[0].name.as_str(),
449 INTERFACE_REQUEST_PLAN_EVENT
450 );
451 assert_eq!(
452 plan.summary_line(),
453 "interface_requests total=4 requested=2 unrequested=2"
454 );
455 }
456
457 #[test]
458 fn route_plan_keeps_public_interfaces_closed_without_full_data() {
459 let config = InterfaceConfig {
460 transaction_port: 10,
461 state_port: 11,
462 batch_port: 12,
463 archive_port: 13,
464 archive_base_url: None,
465 };
466 let plan = InterfacePlan::route_plan(&config, DataMode::Core);
467 assert_eq!(plan.planned().len(), 1);
468 assert_eq!(plan.planned()[0].kind, InterfaceKind::Archive);
469 assert_eq!(plan.routes[0].state, InterfaceState::Closed);
470 assert_eq!(
471 plan.state_counts(),
472 InterfaceRouteCounts {
473 closed: 3,
474 planned: 1,
475 open: 0,
476 }
477 );
478 assert_eq!(plan.state_counts().total(), 4);
479 assert_eq!(plan.event_batch().len(), 1);
480 assert_eq!(
481 plan.event_batch()[0].name.as_str(),
482 INTERFACE_ROUTE_PLAN_EVENT
483 );
484 assert_eq!(
485 plan.summary_line(),
486 "interface_routes total=4 closed=3 planned=1 open=0"
487 );
488 }
489
490 #[test]
491 fn interface_plan_emits_blocked_event_without_opening_paths() {
492 let config = InterfaceConfig {
493 transaction_port: 10,
494 state_port: 11,
495 batch_port: 12,
496 archive_port: 13,
497 archive_base_url: None,
498 };
499 let plan = InterfacePlan::from_config(&config, DataMode::Core);
500 let events = plan.events();
501
502 assert_eq!(events.len(), 1);
503 assert_eq!(events[0].name.as_str(), INTERFACE_BLOCKED_EVENT);
504 assert_eq!(
505 events[0].payload,
506 EventPayload::Text(
507 "transactions=false state=false batches=false archive=true reason=public interfaces require ACROPOLIS_DATA_MODE=full"
508 .to_string()
509 )
510 );
511 let event_batch = plan.event_batch();
512 assert_eq!(event_batch.len(), 2);
513 assert_eq!(event_batch[0].name.as_str(), INTERFACE_PLAN_EVENT);
514 assert_eq!(
515 plan.summary_line(),
516 "interface_plan transactions=false state=false batches=false archive=true blocked=true"
517 );
518 }
519
520 #[test]
521 fn local_interface_handler_accepts_planned_transaction_without_opening_paths() {
522 let config = InterfaceConfig {
523 transaction_port: 10,
524 state_port: 11,
525 batch_port: 0,
526 archive_port: 0,
527 archive_base_url: None,
528 };
529 let plan = InterfacePlan::route_plan(&config, DataMode::Full);
530
531 let response = plan
532 .handle_local(InterfaceKind::Transactions, b"transaction-bytes")
533 .unwrap();
534 assert_eq!(response.kind, InterfaceKind::Transactions);
535 assert_eq!(response.status_code, 202);
536 assert!(response.body.contains("handled locally"));
537 }
538
539 #[test]
540 fn local_interface_handler_rejects_closed_route() {
541 let config = InterfaceConfig {
542 transaction_port: 10,
543 state_port: 0,
544 batch_port: 0,
545 archive_port: 0,
546 archive_base_url: None,
547 };
548 let plan = InterfacePlan::route_plan(&config, DataMode::Core);
549
550 assert_eq!(
551 plan.handle_local(InterfaceKind::Transactions, b"transaction-bytes"),
552 Err(InterfaceError::RouteClosed("transactions".to_string()))
553 );
554 }
555
556 #[test]
557 fn local_interface_handler_rejects_oversized_body() {
558 let route =
559 InterfaceRoute::new(InterfaceKind::Transactions, "transactions", "/tx", 10, true);
560 let body = vec![0; route.max_body_bytes + 1];
561
562 assert_eq!(
563 route.handle_local(&body),
564 Err(InterfaceError::BodyTooLarge {
565 name: "transactions".to_string(),
566 max: 16 * 1024,
567 actual: 16 * 1024 + 1,
568 })
569 );
570 }
571}