1use serde::{Deserialize, Serialize};
8use serde_json::Value;
9use std::collections::{HashMap, HashSet};
10
11pub const PROTOCOL_VERSION: u32 = 1;
12pub const NODE_PROTOCOL_SCHEMA: &str =
13 include_str!("../schema/interaction-flow-node.v1.schema.json");
14pub const MAX_STEPS: usize = 64;
15pub const MAX_RESOLVERS: usize = 128;
16pub const MAX_FIELDS_PER_STEP: usize = 64;
17pub const MAX_PRESENTATION_BYTES: usize = 64 * 1024;
18pub const MAX_VALUE_REF_PATH: usize = 32;
19pub const MAX_HANDLER_ARGS: usize = 128;
20pub const MAX_OPTIONS: usize = 1024;
21pub const MAX_TITLE_CHARS: usize = 1024;
22pub const MAX_DESCRIPTION_CHARS: usize = 16 * 1024;
23pub const MAX_CONTENT_CHARS: usize = 64 * 1024;
24pub const MAX_LABEL_CHARS: usize = 1024;
25pub const MAX_UNAVAILABLE_TEXT_CHARS: usize = 4096;
26pub const MAX_MIN_ROWS: u32 = 100;
27
28pub fn definition_target(node_type: &str) -> String {
29 format!("/{node_type}/interaction-flow/definition")
30}
31
32pub fn execute_target(node_type: &str) -> String {
33 format!("/{node_type}/interaction-flow/execute")
34}
35
36pub fn close_target(node_type: &str) -> String {
37 format!("/{node_type}/interaction-flow/close")
38}
39
40#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
41#[serde(rename_all = "camelCase", deny_unknown_fields)]
42pub struct FlowValueRef {
43 pub root: String,
44 #[serde(default, skip_serializing_if = "Vec::is_empty")]
45 pub path: Vec<String>,
46}
47
48#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
49#[serde(
50 tag = "type",
51 rename_all = "camelCase",
52 rename_all_fields = "camelCase",
53 deny_unknown_fields
54)]
55pub enum FlowArgument {
56 Value { value: Value },
57 Ref { value_ref: FlowValueRef },
58}
59
60#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
61#[serde(rename_all = "camelCase")]
62pub enum FlowRenderer {
63 TypedForm,
64 #[serde(skip)]
68 CustomConnectMdx,
69}
70
71#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
72#[serde(rename_all = "camelCase", deny_unknown_fields)]
73pub struct FlowPresentation {
74 pub renderer: FlowRenderer,
75 #[serde(default, skip_serializing_if = "Option::is_none")]
76 pub title: Option<String>,
77 #[serde(default, skip_serializing_if = "Option::is_none")]
78 pub description: Option<String>,
79 #[serde(default, skip_serializing_if = "Option::is_none")]
80 pub content: Option<String>,
81}
82
83#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
84#[serde(rename_all = "camelCase", deny_unknown_fields)]
85pub struct FlowForm {
86 #[serde(default)]
87 pub fields: Vec<FlowFormField>,
88}
89
90#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
91#[serde(rename_all = "camelCase")]
92pub enum FlowFieldValueType {
93 String,
94 Boolean,
95}
96
97#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
98#[serde(rename_all = "camelCase")]
99pub enum FlowFieldControl {
100 Text,
101 Password,
102 LongText,
103 Select,
104 Boolean,
105}
106
107#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
108#[serde(rename_all = "camelCase", deny_unknown_fields)]
109pub struct FlowFormField {
110 pub var: String,
111 pub value_type: FlowFieldValueType,
112 pub control: FlowFieldControl,
113 #[serde(default)]
114 pub required: bool,
115 #[serde(default)]
116 pub repeat: bool,
117 #[serde(default, skip_serializing_if = "Option::is_none")]
118 pub label: Option<String>,
119 #[serde(default, skip_serializing_if = "Option::is_none")]
120 pub default_value: Option<Value>,
121 #[serde(default, skip_serializing_if = "Option::is_none")]
122 pub default_value_ref: Option<FlowValueRef>,
123 #[serde(default, skip_serializing_if = "Option::is_none")]
124 pub options: Option<Vec<Value>>,
125 #[serde(default, skip_serializing_if = "Option::is_none")]
126 pub options_ref: Option<FlowValueRef>,
127 #[serde(default, skip_serializing_if = "Option::is_none")]
128 pub option_unavailable_text: Option<String>,
129 #[serde(default)]
130 pub read_only: bool,
131 #[serde(default, skip_serializing_if = "Option::is_none")]
132 pub min_rows: Option<u32>,
133}
134
135#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
136#[serde(
137 tag = "type",
138 rename_all = "camelCase",
139 rename_all_fields = "camelCase",
140 deny_unknown_fields
141)]
142pub enum FlowTransition {
143 Step { step_id: String },
144 Dynamic { value_ref: FlowValueRef },
145 Finish,
146}
147
148#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
149#[serde(rename_all = "camelCase", deny_unknown_fields)]
150pub struct FlowHandler {
151 pub operation: String,
152 #[serde(default)]
153 pub args: Vec<FlowArgument>,
154}
155
156#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
157#[serde(rename_all = "camelCase", deny_unknown_fields)]
158pub struct FlowValueResolver {
159 pub output_var: String,
160 pub handler: FlowHandler,
161}
162
163#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
164#[serde(rename_all = "camelCase")]
165pub enum ExternalAuthCompletionMode {
166 Callback,
167 Poll,
168}
169
170#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
171#[serde(
172 tag = "type",
173 rename_all = "camelCase",
174 rename_all_fields = "camelCase",
175 deny_unknown_fields
176)]
177pub enum FlowStepContent {
178 Form {
179 presentation: FlowPresentation,
180 form: FlowForm,
181 #[serde(default)]
182 render_refs: Vec<FlowValueRef>,
183 },
184 ExternalAuth {
185 presentation: FlowPresentation,
186 authorization_url_ref: FlowValueRef,
187 completion_mode: ExternalAuthCompletionMode,
188 #[serde(default, skip_serializing_if = "Option::is_none")]
189 poll_after_ms: Option<u64>,
190 },
191}
192
193#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
194#[serde(rename_all = "camelCase", deny_unknown_fields)]
195pub struct InteractionFlowStep {
196 pub id: String,
197 pub content: FlowStepContent,
198 pub transition: FlowTransition,
199}
200
201#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
202#[serde(rename_all = "camelCase", deny_unknown_fields)]
203pub struct FlowLifecycleHandlers {
204 #[serde(default, skip_serializing_if = "Option::is_none")]
205 pub complete: Option<FlowHandler>,
206 #[serde(default, skip_serializing_if = "Option::is_none")]
207 pub cancel: Option<FlowHandler>,
208 #[serde(default, skip_serializing_if = "Option::is_none")]
209 pub expire: Option<FlowHandler>,
210}
211
212#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
213#[serde(rename_all = "camelCase", deny_unknown_fields)]
214pub struct InteractionFlowDefinition {
215 pub protocol_version: u32,
216 pub flow_id: String,
217 pub start_step_id: String,
218 pub steps: HashMap<String, InteractionFlowStep>,
219 #[serde(default)]
220 pub resolvers: HashMap<String, FlowValueResolver>,
221 #[serde(default)]
222 pub lifecycle: FlowLifecycleHandlers,
223}
224
225#[derive(Debug, Clone, PartialEq, Eq)]
226pub struct FlowValidationError {
227 message: String,
228}
229
230impl FlowValidationError {
231 fn new(message: impl Into<String>) -> Self {
232 Self {
233 message: message.into(),
234 }
235 }
236}
237
238impl std::fmt::Display for FlowValidationError {
239 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
240 formatter.write_str(&self.message)
241 }
242}
243
244impl std::error::Error for FlowValidationError {}
245
246impl InteractionFlowDefinition {
247 pub fn validate(&self) -> Result<(), FlowValidationError> {
248 if self.protocol_version != PROTOCOL_VERSION {
249 return Err(FlowValidationError::new(
250 "unsupported interaction-flow protocol version",
251 ));
252 }
253 require_identifier("flow id", &self.flow_id)?;
254 require_identifier("start step id", &self.start_step_id)?;
255 if self.steps.is_empty() || self.steps.len() > MAX_STEPS {
256 return Err(FlowValidationError::new(
257 "interaction-flow step count is out of bounds",
258 ));
259 }
260 if self.resolvers.len() > MAX_RESOLVERS {
261 return Err(FlowValidationError::new(
262 "interaction-flow resolver count is out of bounds",
263 ));
264 }
265 if !self.steps.contains_key(&self.start_step_id) {
266 return Err(FlowValidationError::new(
267 "interaction-flow start step does not exist",
268 ));
269 }
270
271 let mut inputs = HashSet::new();
272 let mut input_shapes = HashMap::new();
273 for (step_key, step) in &self.steps {
274 require_identifier("step id", step_key)?;
275 if step.id != *step_key {
276 return Err(FlowValidationError::new(
277 "interaction-flow step key/id mismatch",
278 ));
279 }
280 validate_presentation(step.presentation())?;
281 if let FlowStepContent::Form { form, .. } = &step.content {
282 if form.fields.len() > MAX_FIELDS_PER_STEP {
283 return Err(FlowValidationError::new(
284 "interaction-flow field count is out of bounds",
285 ));
286 }
287 let mut step_inputs = HashSet::new();
288 for field in &form.fields {
289 require_variable("input variable", &field.var)?;
290 if is_builtin(&field.var) || !step_inputs.insert(field.var.as_str()) {
291 return Err(FlowValidationError::new(format!(
292 "duplicate or reserved interaction-flow input variable: {}",
293 field.var
294 )));
295 }
296 inputs.insert(field.var.as_str());
297 validate_field_shape(field)?;
298 if let Some((value_type, repeat)) = input_shapes.get(&field.var) {
299 if *value_type != field.value_type || *repeat != field.repeat {
300 return Err(FlowValidationError::new(format!(
301 "interaction-flow input changes value type or cardinality across steps: {}",
302 field.var
303 )));
304 }
305 } else {
306 input_shapes.insert(field.var.clone(), (field.value_type, field.repeat));
307 }
308 }
309 }
310 }
311
312 for (name, resolver) in &self.resolvers {
313 require_variable("resolver output", name)?;
314 if resolver.output_var != *name {
315 return Err(FlowValidationError::new(
316 "interaction-flow resolver key/output mismatch",
317 ));
318 }
319 if is_builtin(name) || inputs.contains(name.as_str()) {
320 return Err(FlowValidationError::new(format!(
321 "duplicate or reserved interaction-flow resolver output: {name}"
322 )));
323 }
324 }
325
326 let known = |name: &str| {
327 is_builtin(name) || inputs.contains(name) || self.resolvers.contains_key(name)
328 };
329 for step in self.steps.values() {
330 match &step.transition {
331 FlowTransition::Step { step_id } if !self.steps.contains_key(step_id) => {
332 return Err(FlowValidationError::new(format!(
333 "unknown interaction-flow step: {step_id}"
334 )));
335 }
336 FlowTransition::Dynamic { value_ref } => {
337 validate_ref(value_ref, &known)?;
338 if !self.resolvers.contains_key(&value_ref.root) {
339 return Err(FlowValidationError::new(
340 "dynamic interaction-flow transition must be resolver-backed",
341 ));
342 }
343 }
344 _ => {}
345 }
346 match &step.content {
347 FlowStepContent::Form {
348 form, render_refs, ..
349 } => {
350 for value_ref in render_refs {
351 validate_ref(value_ref, &known)?;
352 }
353 for field in &form.fields {
354 for value_ref in
355 [field.default_value_ref.as_ref(), field.options_ref.as_ref()]
356 .into_iter()
357 .flatten()
358 {
359 validate_ref(value_ref, &known)?;
360 }
361 }
362 }
363 FlowStepContent::ExternalAuth {
364 authorization_url_ref,
365 poll_after_ms,
366 completion_mode,
367 ..
368 } => {
369 validate_ref(authorization_url_ref, &known)?;
370 if matches!(completion_mode, ExternalAuthCompletionMode::Poll)
371 && poll_after_ms.is_none_or(|value| !(250..=60_000).contains(&value))
372 {
373 return Err(FlowValidationError::new(
374 "polling external-auth steps require pollAfterMs between 250 and 60000",
375 ));
376 }
377 }
378 }
379 }
380
381 for resolver in self.resolvers.values() {
382 validate_handler(&resolver.handler, &known)?;
383 }
384 for handler in [
385 self.lifecycle.complete.as_ref(),
386 self.lifecycle.cancel.as_ref(),
387 self.lifecycle.expire.as_ref(),
388 ]
389 .into_iter()
390 .flatten()
391 {
392 validate_handler(handler, &known)?;
393 }
394 validate_resolver_graph(&self.resolvers)
395 }
396}
397
398impl InteractionFlowStep {
399 pub fn presentation(&self) -> &FlowPresentation {
400 match &self.content {
401 FlowStepContent::Form { presentation, .. }
402 | FlowStepContent::ExternalAuth { presentation, .. } => presentation,
403 }
404 }
405}
406
407fn validate_presentation(presentation: &FlowPresentation) -> Result<(), FlowValidationError> {
408 if presentation
409 .title
410 .as_ref()
411 .is_some_and(|value| value.chars().count() > MAX_TITLE_CHARS)
412 || presentation
413 .description
414 .as_ref()
415 .is_some_and(|value| value.chars().count() > MAX_DESCRIPTION_CHARS)
416 || presentation
417 .content
418 .as_ref()
419 .is_some_and(|value| value.chars().count() > MAX_CONTENT_CHARS)
420 {
421 return Err(FlowValidationError::new(
422 "interaction-flow presentation field is too large",
423 ));
424 }
425 let total = presentation.title.as_deref().unwrap_or_default().len()
426 + presentation
427 .description
428 .as_deref()
429 .unwrap_or_default()
430 .len()
431 + presentation.content.as_deref().unwrap_or_default().len();
432 if total > MAX_PRESENTATION_BYTES {
433 return Err(FlowValidationError::new(
434 "interaction-flow presentation is too large",
435 ));
436 }
437 if matches!(presentation.renderer, FlowRenderer::CustomConnectMdx)
438 && presentation.content.as_deref().is_none_or(str::is_empty)
439 {
440 return Err(FlowValidationError::new(
441 "custom-connect presentation requires content",
442 ));
443 }
444 Ok(())
445}
446
447fn validate_field_shape(field: &FlowFormField) -> Result<(), FlowValidationError> {
448 if field
449 .label
450 .as_ref()
451 .is_some_and(|value| value.chars().count() > MAX_LABEL_CHARS)
452 || field
453 .option_unavailable_text
454 .as_ref()
455 .is_some_and(|value| value.chars().count() > MAX_UNAVAILABLE_TEXT_CHARS)
456 || field
457 .min_rows
458 .is_some_and(|value| value == 0 || value > MAX_MIN_ROWS)
459 || field
460 .options
461 .as_ref()
462 .is_some_and(|options| options.len() > MAX_OPTIONS)
463 {
464 return Err(FlowValidationError::new(format!(
465 "interaction-flow field metadata is out of bounds: {}",
466 field.var
467 )));
468 }
469 let compatible = matches!(
470 (field.value_type, field.control),
471 (FlowFieldValueType::String, FlowFieldControl::Text)
472 | (FlowFieldValueType::String, FlowFieldControl::Password)
473 | (FlowFieldValueType::String, FlowFieldControl::LongText)
474 | (FlowFieldValueType::String, FlowFieldControl::Select)
475 | (FlowFieldValueType::Boolean, FlowFieldControl::Boolean)
476 );
477 if !compatible {
478 return Err(FlowValidationError::new(format!(
479 "interaction-flow field has incompatible value type and control: {}",
480 field.var
481 )));
482 }
483 if field.repeat
484 && !matches!(
485 field.control,
486 FlowFieldControl::Text | FlowFieldControl::Password | FlowFieldControl::LongText
487 )
488 {
489 return Err(FlowValidationError::new(format!(
490 "interaction-flow field control cannot repeat: {}",
491 field.var
492 )));
493 }
494 if field.default_value.is_some() && field.default_value_ref.is_some() {
495 return Err(FlowValidationError::new(format!(
496 "interaction-flow field has two default sources: {}",
497 field.var
498 )));
499 }
500 if matches!(field.control, FlowFieldControl::Password)
501 && (field.default_value.is_some() || field.default_value_ref.is_some() || field.read_only)
502 {
503 return Err(FlowValidationError::new(format!(
504 "interaction-flow password fields cannot have defaults or be read-only: {}",
505 field.var
506 )));
507 }
508 if field.options.is_some() && field.options_ref.is_some() {
509 return Err(FlowValidationError::new(format!(
510 "interaction-flow field has two option sources: {}",
511 field.var
512 )));
513 }
514 if matches!(field.control, FlowFieldControl::Select)
515 && field.options.is_none()
516 && field.options_ref.is_none()
517 {
518 return Err(FlowValidationError::new(format!(
519 "interaction-flow select field requires options: {}",
520 field.var
521 )));
522 }
523 if !matches!(field.control, FlowFieldControl::Select)
524 && (field.options.is_some()
525 || field.options_ref.is_some()
526 || field.option_unavailable_text.is_some())
527 {
528 return Err(FlowValidationError::new(format!(
529 "interaction-flow options require a select control: {}",
530 field.var
531 )));
532 }
533 if field.min_rows.is_some() && !matches!(field.control, FlowFieldControl::LongText) {
534 return Err(FlowValidationError::new(format!(
535 "interaction-flow minRows requires a long-text control: {}",
536 field.var
537 )));
538 }
539 if let Some(default) = &field.default_value {
540 validate_static_default(field, default)?;
541 if let Some(options) = &field.options {
542 let selected = if field.repeat {
543 default.as_array().map(Vec::as_slice).unwrap_or_default()
544 } else {
545 std::slice::from_ref(default)
546 };
547 if selected.iter().any(|selected| {
548 !options
549 .iter()
550 .any(|option| option_value(option) == Some(selected))
551 }) {
552 return Err(FlowValidationError::new(format!(
553 "interaction-flow default is not in static options: {}",
554 field.var
555 )));
556 }
557 }
558 }
559 if let Some(options) = &field.options {
560 for option in options {
561 let value = option_value(option).ok_or_else(|| {
562 FlowValidationError::new(format!(
563 "interaction-flow option has an invalid shape: {}",
564 field.var
565 ))
566 })?;
567 validate_static_scalar(field, value, "option")?;
568 }
569 }
570 Ok(())
571}
572
573fn validate_static_default(
574 field: &FlowFormField,
575 value: &Value,
576) -> Result<(), FlowValidationError> {
577 if field.repeat {
578 let values = value.as_array().ok_or_else(|| {
579 FlowValidationError::new(format!(
580 "interaction-flow repeat default must be an array: {}",
581 field.var
582 ))
583 })?;
584 for value in values {
585 validate_static_scalar(field, value, "default")?;
586 }
587 } else {
588 validate_static_scalar(field, value, "default")?;
589 }
590 Ok(())
591}
592
593fn validate_static_scalar(
594 field: &FlowFormField,
595 value: &Value,
596 kind: &str,
597) -> Result<(), FlowValidationError> {
598 let valid = match field.value_type {
599 FlowFieldValueType::String => value.is_string(),
600 FlowFieldValueType::Boolean => value.is_boolean(),
601 };
602 if !valid {
603 return Err(FlowValidationError::new(format!(
604 "interaction-flow {kind} has the wrong type: {}",
605 field.var
606 )));
607 }
608 Ok(())
609}
610
611fn option_value(option: &Value) -> Option<&Value> {
612 let Value::Object(object) = option else {
613 return Some(option);
614 };
615 if object.get("label").is_some_and(|label| !label.is_string()) {
616 return None;
617 }
618 object.get("value")
619}
620
621fn validate_handler(
622 handler: &FlowHandler,
623 known: &impl Fn(&str) -> bool,
624) -> Result<(), FlowValidationError> {
625 require_identifier("handler operation", &handler.operation)?;
626 if handler.args.len() > MAX_HANDLER_ARGS {
627 return Err(FlowValidationError::new(
628 "interaction-flow handler argument count is out of bounds",
629 ));
630 }
631 for argument in &handler.args {
632 if let FlowArgument::Ref { value_ref } = argument {
633 validate_ref(value_ref, known)?;
634 }
635 }
636 Ok(())
637}
638
639fn validate_ref(
640 value_ref: &FlowValueRef,
641 known: &impl Fn(&str) -> bool,
642) -> Result<(), FlowValidationError> {
643 require_variable("value reference", &value_ref.root)?;
644 if !known(&value_ref.root)
645 || value_ref.path.len() > MAX_VALUE_REF_PATH
646 || value_ref.path.iter().any(|part| !is_identifier(part))
647 {
648 return Err(FlowValidationError::new(format!(
649 "unknown or invalid interaction-flow value reference: {}",
650 value_ref.root
651 )));
652 }
653 Ok(())
654}
655
656fn validate_resolver_graph(
657 resolvers: &HashMap<String, FlowValueResolver>,
658) -> Result<(), FlowValidationError> {
659 fn visit<'a>(
660 name: &'a str,
661 resolvers: &'a HashMap<String, FlowValueResolver>,
662 visiting: &mut HashSet<&'a str>,
663 visited: &mut HashSet<&'a str>,
664 ) -> Result<(), FlowValidationError> {
665 if visited.contains(name) {
666 return Ok(());
667 }
668 if !visiting.insert(name) {
669 return Err(FlowValidationError::new(
670 "interaction-flow resolver dependency cycle",
671 ));
672 }
673 let resolver = resolvers
674 .get(name)
675 .ok_or_else(|| FlowValidationError::new("interaction-flow resolver is missing"))?;
676 for dependency in resolver
677 .handler
678 .args
679 .iter()
680 .filter_map(|argument| match argument {
681 FlowArgument::Ref { value_ref } if resolvers.contains_key(&value_ref.root) => {
682 Some(value_ref.root.as_str())
683 }
684 _ => None,
685 })
686 {
687 visit(dependency, resolvers, visiting, visited)?;
688 }
689 visiting.remove(name);
690 visited.insert(name);
691 Ok(())
692 }
693
694 let mut visiting = HashSet::new();
695 let mut visited = HashSet::new();
696 for name in resolvers.keys() {
697 visit(name, resolvers, &mut visiting, &mut visited)?;
698 }
699 Ok(())
700}
701
702fn require_identifier(kind: &str, value: &str) -> Result<(), FlowValidationError> {
703 if value.is_empty()
704 || value.len() > 128
705 || !value.chars().all(|character| {
706 character.is_ascii_alphanumeric() || matches!(character, '_' | '-' | '.' | '/')
707 })
708 {
709 return Err(FlowValidationError::new(format!(
710 "invalid interaction-flow {kind}: {value}"
711 )));
712 }
713 Ok(())
714}
715
716fn require_variable(kind: &str, value: &str) -> Result<(), FlowValidationError> {
717 let Some(name) = value.strip_prefix('$') else {
718 return Err(FlowValidationError::new(format!(
719 "invalid interaction-flow {kind}: {value}"
720 )));
721 };
722 if name.is_empty()
723 || name.len() > 127
724 || !name
725 .chars()
726 .next()
727 .is_some_and(|character| character.is_ascii_alphabetic() || character == '_')
728 || !name
729 .chars()
730 .all(|character| character.is_ascii_alphanumeric() || character == '_')
731 {
732 return Err(FlowValidationError::new(format!(
733 "invalid interaction-flow {kind}: {value}"
734 )));
735 }
736 Ok(())
737}
738
739fn is_identifier(value: &str) -> bool {
740 !value.is_empty()
741 && value.len() <= 128
742 && value
743 .chars()
744 .next()
745 .is_some_and(|character| character.is_ascii_alphabetic() || character == '_')
746 && value
747 .chars()
748 .all(|character| character.is_ascii_alphanumeric() || character == '_')
749}
750
751fn is_builtin(value: &str) -> bool {
752 matches!(value, "$env" | "$context")
753}
754
755#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
756#[serde(rename_all = "camelCase", deny_unknown_fields)]
757pub struct FlowDefinitionRequest {
758 pub flow_id: String,
759 #[serde(default, skip_serializing_if = "Option::is_none")]
760 pub subject_id: Option<String>,
761}
762
763#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
764#[serde(rename_all = "camelCase", deny_unknown_fields)]
765pub struct FlowDefinitionResponse {
766 pub definition_revision: String,
767 pub source_session_id: String,
768 pub definition: InteractionFlowDefinition,
769}
770
771#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
772#[serde(rename_all = "camelCase", deny_unknown_fields)]
773pub struct FlowExecuteRequest {
774 pub flow_id: String,
775 pub source_session_id: String,
776 pub definition_revision: String,
777 pub operation: String,
778 pub operation_id: String,
779 #[serde(default)]
780 pub args: Vec<Value>,
781}
782
783#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
784#[serde(rename_all = "camelCase")]
785pub struct FlowExecuteResponse {
786 pub data: Value,
787}
788
789#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
790#[serde(rename_all = "camelCase")]
791pub enum FlowCloseReason {
792 Completed,
793 Cancelled,
794 Expired,
795 StartFailed,
796}
797
798#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
799#[serde(rename_all = "camelCase", deny_unknown_fields)]
800pub struct FlowCloseRequest {
801 pub flow_id: String,
802 pub source_session_id: String,
803 pub operation_id: String,
804 pub reason: FlowCloseReason,
805}
806
807#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
808#[serde(rename_all = "camelCase")]
809pub struct FlowCloseResponse {
810 pub closed: bool,
811}
812
813#[cfg(test)]
814mod tests {
815 use super::*;
816 use serde_json::json;
817
818 fn definition() -> InteractionFlowDefinition {
819 InteractionFlowDefinition {
820 protocol_version: PROTOCOL_VERSION,
821 flow_id: "provider.connect".into(),
822 start_step_id: "credentials".into(),
823 steps: HashMap::from([(
824 "credentials".into(),
825 InteractionFlowStep {
826 id: "credentials".into(),
827 content: FlowStepContent::Form {
828 presentation: FlowPresentation {
829 renderer: FlowRenderer::TypedForm,
830 title: Some("Connect".into()),
831 description: None,
832 content: None,
833 },
834 form: FlowForm {
835 fields: vec![FlowFormField {
836 var: "$host".into(),
837 value_type: FlowFieldValueType::String,
838 control: FlowFieldControl::Text,
839 required: true,
840 repeat: false,
841 label: Some("Host".into()),
842 default_value: None,
843 default_value_ref: None,
844 options: None,
845 options_ref: None,
846 option_unavailable_text: None,
847 read_only: false,
848 min_rows: None,
849 }],
850 },
851 render_refs: Vec::new(),
852 },
853 transition: FlowTransition::Finish,
854 },
855 )]),
856 resolvers: HashMap::new(),
857 lifecycle: FlowLifecycleHandlers {
858 complete: Some(FlowHandler {
859 operation: "provider.save".into(),
860 args: vec![FlowArgument::Ref {
861 value_ref: FlowValueRef {
862 root: "$host".into(),
863 path: Vec::new(),
864 },
865 }],
866 }),
867 ..FlowLifecycleHandlers::default()
868 },
869 }
870 }
871
872 #[test]
873 fn canonical_definition_round_trips_and_validates() {
874 let definition = definition();
875 definition.validate().unwrap();
876 let value = serde_json::to_value(&definition).unwrap();
877 assert_eq!(value["lifecycle"]["complete"]["operation"], "provider.save");
878 assert!(value.to_string().contains("\"type\":\"ref\""));
879 assert_eq!(
880 serde_json::from_value::<InteractionFlowDefinition>(value).unwrap(),
881 definition
882 );
883 }
884
885 #[test]
886 fn dependencies_are_inferred_from_typed_arguments_and_cycles_are_rejected() {
887 let mut definition = definition();
888 definition.resolvers.insert(
889 "$a".into(),
890 FlowValueResolver {
891 output_var: "$a".into(),
892 handler: FlowHandler {
893 operation: "resolve.a".into(),
894 args: vec![FlowArgument::Ref {
895 value_ref: FlowValueRef {
896 root: "$b".into(),
897 path: Vec::new(),
898 },
899 }],
900 },
901 },
902 );
903 definition.resolvers.insert(
904 "$b".into(),
905 FlowValueResolver {
906 output_var: "$b".into(),
907 handler: FlowHandler {
908 operation: "resolve.b".into(),
909 args: vec![FlowArgument::Ref {
910 value_ref: FlowValueRef {
911 root: "$a".into(),
912 path: Vec::new(),
913 },
914 }],
915 },
916 },
917 );
918 assert!(definition
919 .validate()
920 .unwrap_err()
921 .to_string()
922 .contains("cycle"));
923 }
924
925 #[test]
926 fn node_routes_and_requests_do_not_accept_routing_identity() {
927 assert_eq!(
928 definition_target("camera"),
929 "/camera/interaction-flow/definition"
930 );
931 assert_eq!(execute_target("camera"), "/camera/interaction-flow/execute");
932 assert_eq!(close_target("camera"), "/camera/interaction-flow/close");
933 let request = serde_json::from_value::<FlowExecuteRequest>(json!({
934 "flowId": "provider.connect",
935 "sourceSessionId": "source-1",
936 "definitionRevision": "sha256:abc",
937 "operation": "provider.discover",
938 "operationId": "operation-1",
939 "args": [],
940 "nodeId": "untrusted"
941 }));
942 assert!(request.is_err());
943 }
944
945 #[test]
946 fn node_renderer_is_typed_form_only_on_the_wire() {
947 let mut definition = definition();
948 let step = definition.steps.get_mut("credentials").unwrap();
949 let FlowStepContent::Form { presentation, .. } = &mut step.content else {
950 unreachable!();
951 };
952 presentation.renderer = FlowRenderer::CustomConnectMdx;
953 assert!(serde_json::to_value(definition).is_err());
954 }
955
956 #[test]
957 fn input_shape_is_stable_across_steps_and_passwords_are_user_owned() {
958 let mut invalid_definition = definition();
959 invalid_definition.steps.insert(
960 "second".into(),
961 InteractionFlowStep {
962 id: "second".into(),
963 content: FlowStepContent::Form {
964 presentation: FlowPresentation {
965 renderer: FlowRenderer::TypedForm,
966 title: None,
967 description: None,
968 content: None,
969 },
970 form: FlowForm {
971 fields: vec![FlowFormField {
972 var: "$host".into(),
973 value_type: FlowFieldValueType::Boolean,
974 control: FlowFieldControl::Boolean,
975 required: true,
976 repeat: false,
977 label: None,
978 default_value: None,
979 default_value_ref: None,
980 options: None,
981 options_ref: None,
982 option_unavailable_text: None,
983 read_only: false,
984 min_rows: None,
985 }],
986 },
987 render_refs: Vec::new(),
988 },
989 transition: FlowTransition::Finish,
990 },
991 );
992 assert!(invalid_definition.validate().is_err());
993
994 let mut definition = definition();
995 let step = definition.steps.get_mut("credentials").unwrap();
996 let FlowStepContent::Form { form, .. } = &mut step.content else {
997 unreachable!();
998 };
999 let field = form.fields.first_mut().unwrap();
1000 field.control = FlowFieldControl::Password;
1001 field.default_value = Some(json!("not-allowed"));
1002 assert!(definition.validate().is_err());
1003 }
1004
1005 #[test]
1006 fn conformance_fixture_matches_the_published_node_schema() {
1007 let schema: Value = serde_json::from_str(NODE_PROTOCOL_SCHEMA).unwrap();
1008 let fixture: Value = serde_json::from_str(include_str!(
1009 "../fixtures/interaction-flow-node.conformance.json"
1010 ))
1011 .unwrap();
1012 let validator = jsonschema::validator_for(&schema).unwrap();
1013 let errors = validator
1014 .iter_errors(&fixture)
1015 .map(|error| error.to_string())
1016 .collect::<Vec<_>>();
1017 assert!(errors.is_empty(), "fixture failed: {errors:?}");
1018 }
1019}