Skip to main content

qefro_backend_sdk/
lib.rs

1//! Qefro backend SDK — mirrors `@qefro-ai/backend` (TypeScript).
2//!
3//! Organizations expose one signed webhook (typically `POST /qefro`).
4//! Qefro Runtime calls `ping`, `tools.list`, `tool.invoke`, and `tool.resume`.
5
6mod customer_hub;
7
8pub use customer_hub::{
9    env_flag_true, hub_call, hub_customer_from_person, is_customer_hub_enabled,
10    is_customer_hub_optional, pick_identity, read_identity_phone, seed_from_person,
11    ConsentContext, CustomerState, MembershipContext, PlatformCapabilities,
12    PlatformCustomerBinding, PlatformCustomerContext, PlatformStorageBinding,
13    PlatformStorageContext, TimelineContext,
14};
15
16use std::collections::HashMap;
17use std::future::Future;
18use std::pin::Pin;
19use std::sync::{Arc, RwLock};
20
21use anyhow::{anyhow, Result};
22use async_trait::async_trait;
23use axum::body::Bytes;
24use axum::extract::State;
25use axum::http::{HeaderMap, StatusCode};
26use axum::response::IntoResponse;
27use axum::routing::post;
28use axum::{Json, Router};
29use chrono::Utc;
30use hmac::{Hmac, Mac};
31use serde::{Deserialize, Serialize};
32use serde_json::{json, Value};
33use sha2::Sha256;
34use subtle::ConstantTimeEq;
35use tokio::sync::Mutex;
36use uuid::Uuid;
37
38type HmacSha256 = Hmac<Sha256>;
39
40/// Package name reported to Qefro Runtime (`X-Qefro-SDK` / protocol payloads).
41pub const SDK_NAME: &str = "qefro-backend-sdk";
42/// Package version reported to Qefro Runtime (`sdk_version` / `X-Qefro-Version`).
43pub const SDK_VERSION: &str = env!("CARGO_PKG_VERSION");
44
45type ToolHandler = Arc<dyn Fn(ToolContext) -> ToolFuture + Send + Sync>;
46type ToolFuture = Pin<Box<dyn Future<Output = Result<Value>> + Send>>;
47type BeforeHook = Arc<dyn Fn(ToolContext) -> HookFuture + Send + Sync>;
48type AfterHook = Arc<dyn Fn(ToolContext, Value) -> AfterFuture + Send + Sync>;
49type HookFuture = Pin<Box<dyn Future<Output = Result<()>> + Send>>;
50type AfterFuture = Pin<Box<dyn Future<Output = Result<Value>> + Send>>;
51type MiddlewareFn = Arc<
52    dyn Fn(ToolContext, NextFn) -> Pin<Box<dyn Future<Output = Result<Value>> + Send>>
53        + Send
54        + Sync,
55>;
56type NextFn = Box<dyn FnOnce(ToolContext) -> Pin<Box<dyn Future<Output = Result<Value>> + Send>> + Send>;
57
58#[derive(Debug, Clone)]
59pub struct QefroConfig {
60    pub signing_secret: String,
61    pub protocol_version: String,
62    pub max_timestamp_skew_secs: i64,
63    pub endpoint_path: String,
64}
65
66impl QefroConfig {
67    pub fn new(signing_secret: impl Into<String>) -> Self {
68        Self {
69            signing_secret: signing_secret.into(),
70            protocol_version: "1".into(),
71            max_timestamp_skew_secs: 300,
72            endpoint_path: "/qefro".into(),
73        }
74    }
75}
76
77#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
78#[serde(rename_all = "snake_case")]
79pub enum ToolAuthMode {
80    None,
81    #[default]
82    Optional,
83    Required,
84}
85
86/// Identity attributes the Qefro runtime must resolve before `tool.invoke`.
87#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
88pub struct ToolLookup {
89    /// Shorthand for a single required attribute, e.g. `"email"` or `"phone"`.
90    #[serde(default, skip_serializing_if = "Option::is_none")]
91    pub by: Option<String>,
92    /// Explicit list, e.g. `["email"]` or `["phone", "customer_id"]`.
93    #[serde(default, skip_serializing_if = "Vec::is_empty")]
94    pub required: Vec<String>,
95}
96
97/// Normalize `lookup.by` / `lookup.required` into a deduped lowercase attribute list.
98pub fn normalize_lookup(lookup: Option<&ToolLookup>) -> Vec<String> {
99    let Some(lookup) = lookup else {
100        return Vec::new();
101    };
102    let mut seen = std::collections::HashSet::new();
103    let mut out = Vec::new();
104    for item in lookup
105        .required
106        .iter()
107        .cloned()
108        .chain(lookup.by.iter().cloned())
109    {
110        let key = item.trim().to_ascii_lowercase();
111        if key.is_empty() || !seen.insert(key.clone()) {
112            continue;
113        }
114        out.push(key);
115    }
116    out
117}
118
119fn normalized_lookup_field(lookup: Option<&ToolLookup>) -> Option<ToolLookup> {
120    let attrs = normalize_lookup(lookup);
121    if attrs.is_empty() {
122        None
123    } else {
124        Some(ToolLookup {
125            by: None,
126            required: attrs,
127        })
128    }
129}
130
131#[derive(Debug, Clone, Serialize, Deserialize, Default)]
132pub struct ToolMetadata {
133    pub name: String,
134    #[serde(default, skip_serializing_if = "Option::is_none")]
135    pub description: Option<String>,
136    #[serde(default, skip_serializing_if = "Option::is_none")]
137    pub input_schema: Option<Value>,
138    #[serde(default, skip_serializing_if = "Vec::is_empty")]
139    pub authentication_methods: Vec<String>,
140    #[serde(default)]
141    pub auth: ToolAuthMode,
142    #[serde(default, skip_serializing_if = "Vec::is_empty")]
143    pub permissions: Vec<String>,
144    #[serde(default, skip_serializing_if = "Option::is_none")]
145    pub timeout: Option<u64>,
146    #[serde(default, skip_serializing_if = "Option::is_none")]
147    pub default_auth_method: Option<String>,
148    /// What identity the runtime must have before invoking this tool.
149    #[serde(default, skip_serializing_if = "Option::is_none")]
150    pub lookup: Option<ToolLookup>,
151}
152
153#[derive(Debug, Clone, Serialize, Deserialize)]
154pub struct RegisteredTool {
155    pub name: String,
156    #[serde(default, skip_serializing_if = "Option::is_none")]
157    pub description: Option<String>,
158    #[serde(default, skip_serializing_if = "Option::is_none")]
159    pub input_schema: Option<Value>,
160    #[serde(default, skip_serializing_if = "Vec::is_empty")]
161    pub authentication_methods: Vec<String>,
162    #[serde(default)]
163    pub auth: ToolAuthMode,
164    #[serde(default, skip_serializing_if = "Vec::is_empty")]
165    pub permissions: Vec<String>,
166    #[serde(default, skip_serializing_if = "Option::is_none")]
167    pub timeout: Option<u64>,
168    #[serde(default, skip_serializing_if = "Option::is_none")]
169    pub lookup: Option<ToolLookup>,
170}
171
172// ---------------------------------------------------------------------------
173// Business Flows (metadata only — the SDK advertises them, never executes them)
174// ---------------------------------------------------------------------------
175
176fn default_flow_version() -> u32 {
177    1
178}
179
180/// Immutable identity + descriptive metadata for a Business Flow.
181///
182/// `id` is the identity key: renaming `name` never creates a new flow.
183#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
184pub struct BusinessFlowMetadata {
185    pub id: String,
186    #[serde(default, skip_serializing_if = "Option::is_none")]
187    pub name: Option<String>,
188    #[serde(default, skip_serializing_if = "Option::is_none")]
189    pub description: Option<String>,
190    /// Integer flow version, defaults to 1. Bump when the definition changes.
191    #[serde(default = "default_flow_version")]
192    pub version: u32,
193    #[serde(default, skip_serializing_if = "Option::is_none")]
194    pub category: Option<String>,
195    #[serde(default, skip_serializing_if = "Vec::is_empty")]
196    pub tags: Vec<String>,
197    /// Example utterances used by the runtime for AI flow selection.
198    #[serde(default, skip_serializing_if = "Vec::is_empty")]
199    pub intent: Vec<String>,
200    /// Identity/context attributes this flow requires before it can run.
201    #[serde(default, skip_serializing_if = "Vec::is_empty")]
202    pub inputs: Vec<String>,
203    /// Values this flow produces (for analytics and future flow chaining).
204    #[serde(default, skip_serializing_if = "Vec::is_empty")]
205    pub outputs: Vec<String>,
206    /// Entry trigger. Conversation (default) keeps Phase 2 behaviour.
207    #[serde(default, skip_serializing_if = "Option::is_none")]
208    pub trigger: Option<FlowTrigger>,
209}
210
211/// How a Business Flow is entered (Phase 3). Events are triggers into the
212/// Qefro runtime — not a second execution engine.
213#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
214#[serde(tag = "type", rename_all = "snake_case")]
215pub enum FlowTrigger {
216    Conversation,
217    Event {
218        event: String,
219        #[serde(default, skip_serializing_if = "Option::is_none")]
220        when: Option<String>,
221    },
222    Schedule { cron: String },
223    Webhook {
224        #[serde(default, skip_serializing_if = "Option::is_none")]
225        name: Option<String>,
226        #[serde(default, skip_serializing_if = "Option::is_none")]
227        when: Option<String>,
228    },
229}
230
231impl FlowTrigger {
232    pub fn normalize(self) -> Result<Self, FlowError> {
233        match self {
234            FlowTrigger::Conversation => Ok(FlowTrigger::Conversation),
235            FlowTrigger::Event { event, when } => {
236                let event = event.trim().to_string();
237                if event.is_empty() {
238                    return Err(FlowError::InvalidTrigger(
239                        "trigger.type=event requires a non-empty event name".into(),
240                    ));
241                }
242                if !event.contains('.') {
243                    return Err(FlowError::InvalidTrigger(
244                        "trigger.event must be namespaced (e.g. shopify.order.created)".into(),
245                    ));
246                }
247                let when = when
248                    .map(|w| w.trim().to_string())
249                    .filter(|w| !w.is_empty());
250                Ok(FlowTrigger::Event { event, when })
251            }
252            FlowTrigger::Schedule { cron } => {
253                let cron = cron.trim().to_string();
254                if cron.is_empty() {
255                    return Err(FlowError::InvalidTrigger(
256                        "trigger.type=schedule requires a non-empty cron expression".into(),
257                    ));
258                }
259                Ok(FlowTrigger::Schedule { cron })
260            }
261            FlowTrigger::Webhook { name, when } => {
262                let name = name
263                    .map(|n| n.trim().to_string())
264                    .filter(|n| !n.is_empty());
265                let when = when
266                    .map(|w| w.trim().to_string())
267                    .filter(|w| !w.is_empty());
268                Ok(FlowTrigger::Webhook { name, when })
269            }
270        }
271    }
272}
273
274/// Standalone event / webhook / schedule handler advertised via capabilities.list.
275#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
276pub struct EventHandlerDefinition {
277    pub name: String,
278    #[serde(default, skip_serializing_if = "Option::is_none")]
279    pub description: Option<String>,
280    #[serde(default, skip_serializing_if = "Option::is_none")]
281    pub cron: Option<String>,
282}
283
284/// Type-specific settings for a flow step. Serialized as `{ "type": ..., "config": {...} }`
285/// so new settings (retry, timeout, permissions, parallel) extend `config` without
286/// changing the wire schema.
287#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
288#[serde(tag = "type", content = "config", rename_all = "snake_case")]
289pub enum FlowStepKind {
290    Ask {
291        field: String,
292        prompt: String,
293    },
294    Tool {
295        /// Name of an existing Business Tool. Namespaceable later (e.g. `CRM.lookup_customer`).
296        tool_ref: String,
297    },
298    Challenge {
299        #[serde(default, skip_serializing_if = "Option::is_none")]
300        message: Option<String>,
301    },
302    Upload {
303        #[serde(default, skip_serializing_if = "Option::is_none")]
304        field: Option<String>,
305        #[serde(default, skip_serializing_if = "Option::is_none")]
306        prompt: Option<String>,
307        #[serde(default, skip_serializing_if = "Vec::is_empty")]
308        accept: Vec<String>,
309    },
310    Condition {
311        when: String,
312        #[serde(default, skip_serializing_if = "Option::is_none")]
313        then: Option<String>,
314        #[serde(rename = "else", default, skip_serializing_if = "Option::is_none")]
315        else_step: Option<String>,
316    },
317    Delay {
318        duration_seconds: u64,
319    },
320    Approval {
321        #[serde(default, skip_serializing_if = "Option::is_none")]
322        prompt: Option<String>,
323    },
324    Complete {
325        #[serde(default, skip_serializing_if = "Option::is_none")]
326        message: Option<String>,
327    },
328}
329
330/// Wire shape of a flow step: `{ id, type, config }`.
331#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
332pub struct FlowStep {
333    pub id: String,
334    #[serde(flatten)]
335    pub kind: FlowStepKind,
336}
337
338/// A Business Flow as advertised through `capabilities.list`. Never executed by the SDK.
339#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
340pub struct BusinessFlow {
341    pub metadata: BusinessFlowMetadata,
342    pub steps: Vec<FlowStep>,
343}
344
345/// Developer mistakes surfaced as explicit errors — the SDK never panics on a
346/// malformed flow declaration.
347#[derive(Debug, Clone, PartialEq, Eq)]
348pub enum FlowError {
349    EmptyFlowId,
350    DuplicateFlowId(String),
351    EmptyStepId { flow: String },
352    DuplicateStepId { flow: String, step: String },
353    InvalidTrigger(String),
354    EmptyHandlerName(&'static str),
355    DuplicateHandler { kind: &'static str, name: String },
356}
357
358impl std::fmt::Display for FlowError {
359    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
360        match self {
361            FlowError::EmptyFlowId => write!(f, "flow() requires a non-empty metadata.id"),
362            FlowError::DuplicateFlowId(id) => write!(f, "flow \"{id}\" is already registered"),
363            FlowError::EmptyStepId { flow } => {
364                write!(f, "flow \"{flow}\": every step requires a non-empty id")
365            }
366            FlowError::DuplicateStepId { flow, step } => {
367                write!(f, "flow \"{flow}\": duplicate step id \"{step}\"")
368            }
369            FlowError::InvalidTrigger(msg) => write!(f, "{msg}"),
370            FlowError::EmptyHandlerName(kind) => {
371                write!(f, "{kind}() requires a non-empty name")
372            }
373            FlowError::DuplicateHandler { kind, name } => {
374                write!(f, "{kind} \"{name}\" is already registered")
375            }
376        }
377    }
378}
379
380impl std::error::Error for FlowError {}
381
382#[derive(Debug, Clone, Serialize, Deserialize)]
383pub struct AuthenticationContextPayload {
384    #[serde(rename = "type", skip_serializing_if = "Option::is_none")]
385    pub credential_type: Option<String>,
386    #[serde(skip_serializing_if = "Option::is_none")]
387    pub access_token: Option<String>,
388    #[serde(skip_serializing_if = "Option::is_none")]
389    pub credential: Option<String>,
390    #[serde(skip_serializing_if = "Option::is_none")]
391    pub refresh_token: Option<String>,
392    #[serde(skip_serializing_if = "Option::is_none")]
393    pub expires_in: Option<i64>,
394    #[serde(skip_serializing_if = "Option::is_none")]
395    pub customer_id: Option<String>,
396}
397
398#[derive(Debug, Clone, Serialize, Deserialize)]
399pub struct ChallengePayload {
400    #[serde(rename = "type")]
401    pub challenge_type: String,
402    pub message: String,
403    #[serde(skip_serializing_if = "Option::is_none")]
404    pub destination_hint: Option<String>,
405    #[serde(skip_serializing_if = "Option::is_none")]
406    pub login_url: Option<String>,
407}
408
409#[derive(Debug, Clone, Serialize, Deserialize)]
410pub struct QefroRequest {
411    pub protocol_version: String,
412    pub request_id: Uuid,
413    #[serde(rename = "type")]
414    pub request_type: String,
415    pub organization_id: Option<Uuid>,
416    pub conversation_id: Option<Uuid>,
417    pub channel: Option<String>,
418    pub identity: Option<Value>,
419    pub tool: Option<String>,
420    pub parameters: Option<Value>,
421    pub authentication: Option<Value>,
422    pub resume_token: Option<String>,
423    pub challenge_response: Option<String>,
424    /// Customer Hub Person snapshot from Qefro memory (not connector customer).
425    #[serde(default, skip_serializing_if = "Option::is_none")]
426    pub person: Option<Value>,
427    /// Managed storage / Customer Hub gateway for sdk.* bindings.
428    #[serde(default, skip_serializing_if = "Option::is_none")]
429    pub platform: Option<PlatformCapabilities>,
430}
431
432#[derive(Debug, Clone, Serialize, Deserialize)]
433#[serde(tag = "type", rename_all = "snake_case")]
434pub enum QefroResponse {
435    Pong {
436        protocol_version: String,
437        sdk_version: String,
438    },
439    #[serde(rename = "tools.list")]
440    ToolsList {
441        tools: Vec<RegisteredTool>,
442        protocol_version: String,
443        sdk_version: String,
444    },
445    #[serde(rename = "capabilities.list")]
446    CapabilitiesList {
447        tools: Vec<RegisteredTool>,
448        flows: Vec<BusinessFlow>,
449        #[serde(default, skip_serializing_if = "Vec::is_empty")]
450        events: Vec<EventHandlerDefinition>,
451        #[serde(default, skip_serializing_if = "Vec::is_empty")]
452        webhooks: Vec<EventHandlerDefinition>,
453        #[serde(default, skip_serializing_if = "Vec::is_empty")]
454        schedules: Vec<EventHandlerDefinition>,
455        protocol_version: String,
456        sdk_version: String,
457        sdk_name: String,
458    },
459    Result {
460        output: Value,
461        #[serde(skip_serializing_if = "Option::is_none")]
462        authentication_context: Option<AuthenticationContextPayload>,
463    },
464    Challenge {
465        resume_token: String,
466        challenge: ChallengePayload,
467    },
468    Error {
469        code: String,
470        message: String,
471    },
472}
473
474#[derive(Debug, Clone)]
475struct PendingInvocation {
476    tool: String,
477    conversation_id: Uuid,
478    parameters: Value,
479    identity: Option<Value>,
480    channel: Option<String>,
481    platform: Option<PlatformCapabilities>,
482    person: Option<Value>,
483}
484
485#[derive(Debug, Clone)]
486struct StoredAuth {
487    customer: Value,
488    auth: AuthenticationContextPayload,
489    expires_at_epoch_ms: i64,
490}
491
492#[derive(Debug, Clone)]
493pub struct Conversation {
494    pub id: Uuid,
495}
496
497#[derive(Clone)]
498pub struct ToolContext {
499    pub identity: Value,
500    pub parameters: Value,
501    pub conversation: Conversation,
502    pub channel: Option<String>,
503    pub authentication: Option<Value>,
504    pub auth_response: Option<String>,
505    /// Customer resolved for `auth = required` (or via in-handler authorize).
506    pub customer: Option<Value>,
507    customer_api: Option<CustomerApi>,
508    /// Append Customer Hub timeline activities.
509    pub timeline: TimelineContext,
510    /// Attach/detach solution membership on a Hub customer.
511    pub membership: MembershipContext,
512    /// Grant/revoke consent purposes on a Hub customer.
513    pub consent: ConsentContext,
514    /// Platform capabilities from `tool.invoke` (`platform.customer` / storage).
515    pub platform: Option<PlatformCapabilities>,
516}
517
518impl ToolContext {
519    /// In-handler customer helpers (mirrors JS `ctx.customer`).
520    pub fn customer_api(&self) -> Option<&CustomerApi> {
521        self.customer_api.as_ref()
522    }
523
524    /// Raise an auth challenge from inside a tool handler (mirrors JS `AuthBuilder.challenge`).
525    pub fn raise_challenge(challenge: ChallengePayload) -> Result<Value> {
526        Err(ChallengeSignal { challenge }.into())
527    }
528}
529
530/// Signal an auth challenge from a tool handler (caught like JS `ChallengeSignal`).
531#[derive(Debug, Clone)]
532pub struct ChallengeSignal {
533    pub challenge: ChallengePayload,
534}
535
536impl std::fmt::Display for ChallengeSignal {
537    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
538        write!(f, "{}", self.challenge.message)
539    }
540}
541
542impl std::error::Error for ChallengeSignal {}
543
544#[derive(Debug, Clone)]
545pub enum AuthOutcome {
546    Success {
547        customer: Value,
548        auth: AuthenticationContextPayload,
549    },
550    Challenge(ChallengePayload),
551    Denied,
552    NotFound,
553}
554
555/// Helpers matching JS `AuthBuilder`.
556#[derive(Debug, Clone)]
557pub struct AuthBuilder {
558    pub response: Option<String>,
559}
560
561impl AuthBuilder {
562    pub fn new(response: Option<String>) -> Self {
563        Self { response }
564    }
565
566    pub fn success(
567        &self,
568        customer: Value,
569        mut auth: AuthenticationContextPayload,
570    ) -> AuthOutcome {
571        if auth.customer_id.is_none() {
572            auth.customer_id = customer
573                .get("id")
574                .and_then(|v| v.as_str())
575                .map(str::to_string);
576        }
577        AuthOutcome::Success { customer, auth }
578    }
579
580    pub fn denied(&self) -> AuthOutcome {
581        AuthOutcome::Denied
582    }
583
584    pub fn not_found(&self) -> AuthOutcome {
585        AuthOutcome::NotFound
586    }
587
588    pub fn email_otp(&self, email: &str, message: Option<&str>) -> AuthOutcome {
589        AuthOutcome::Challenge(ChallengePayload {
590            challenge_type: "email_otp".into(),
591            message: message
592                .unwrap_or("Enter the OTP sent to your email.")
593                .into(),
594            destination_hint: Some(mask(email)),
595            login_url: None,
596        })
597    }
598
599    pub fn sms_otp(&self, phone: &str, message: Option<&str>) -> AuthOutcome {
600        AuthOutcome::Challenge(ChallengePayload {
601            challenge_type: "sms_otp".into(),
602            message: message
603                .unwrap_or("Enter the OTP sent to your phone.")
604                .into(),
605            destination_hint: Some(mask(phone)),
606            login_url: None,
607        })
608    }
609
610    pub fn login(&self, url: &str, message: Option<&str>) -> AuthOutcome {
611        AuthOutcome::Challenge(ChallengePayload {
612            challenge_type: "login".into(),
613            message: message
614                .unwrap_or("Please continue in your login page.")
615                .into(),
616            destination_hint: None,
617            login_url: Some(url.into()),
618        })
619    }
620
621    pub fn custom(&self, challenge: ChallengePayload) -> AuthOutcome {
622        AuthOutcome::Challenge(challenge)
623    }
624}
625
626fn mask(value: &str) -> String {
627    if value.len() <= 4 {
628        return value.to_string();
629    }
630    format!("{}***{}", &value[..2], &value[value.len() - 2..])
631}
632
633#[derive(Debug, Clone)]
634pub struct CustomerLookupContext {
635    pub identity: Value,
636    pub parameters: Value,
637    pub conversation: Conversation,
638    pub channel: Option<String>,
639}
640
641#[derive(Debug, Clone)]
642pub struct CustomerAuthorizeContext {
643    pub customer: Value,
644    pub method: Option<String>,
645    pub response: Option<String>,
646    pub identity: Value,
647    pub parameters: Value,
648    pub conversation: Conversation,
649    pub channel: Option<String>,
650}
651
652#[async_trait]
653pub trait CustomerProvider: Send + Sync {
654    async fn lookup(&self, ctx: &CustomerLookupContext) -> Result<Option<Value>>;
655    async fn authorize(&self, ctx: &CustomerAuthorizeContext) -> Result<AuthOutcome>;
656}
657
658#[derive(Clone)]
659struct ToolRegistration {
660    metadata: ToolMetadata,
661    handler: ToolHandler,
662}
663
664#[derive(Clone)]
665struct FlowRegistration {
666    metadata: BusinessFlowMetadata,
667    steps: Vec<FlowStep>,
668    /// First builder violation recorded for this flow; if set the flow is
669    /// excluded from `capabilities.list`.
670    error: Option<FlowError>,
671}
672
673/// Fluent builder returned by [`Qefro::flow`]. Step methods append into the SDK's
674/// flow registry as they are declared and never panic; the first step-id
675/// violation is recorded and surfaced by [`FlowBuilder::complete`].
676pub struct FlowBuilder {
677    inner: Arc<Inner>,
678    flow_id: String,
679}
680
681impl FlowBuilder {
682    pub fn ask(self, id: impl Into<String>, field: impl Into<String>, prompt: impl Into<String>) -> Self {
683        self.push(
684            id.into(),
685            FlowStepKind::Ask {
686                field: field.into(),
687                prompt: prompt.into(),
688            },
689        )
690    }
691
692    pub fn tool(self, id: impl Into<String>, tool_ref: impl Into<String>) -> Self {
693        self.push(
694            id.into(),
695            FlowStepKind::Tool {
696                tool_ref: tool_ref.into(),
697            },
698        )
699    }
700
701    pub fn challenge(self, id: impl Into<String>, message: Option<String>) -> Self {
702        self.push(id.into(), FlowStepKind::Challenge { message })
703    }
704
705    pub fn upload(
706        self,
707        id: impl Into<String>,
708        field: Option<String>,
709        prompt: Option<String>,
710        accept: Vec<String>,
711    ) -> Self {
712        self.push(
713            id.into(),
714            FlowStepKind::Upload {
715                field,
716                prompt,
717                accept,
718            },
719        )
720    }
721
722    pub fn condition(
723        self,
724        id: impl Into<String>,
725        when: impl Into<String>,
726        then: Option<String>,
727        else_step: Option<String>,
728    ) -> Self {
729        self.push(
730            id.into(),
731            FlowStepKind::Condition {
732                when: when.into(),
733                then,
734                else_step,
735            },
736        )
737    }
738
739    pub fn delay(self, id: impl Into<String>, duration_seconds: u64) -> Self {
740        self.push(id.into(), FlowStepKind::Delay { duration_seconds })
741    }
742
743    pub fn approval(self, id: impl Into<String>, prompt: Option<String>) -> Self {
744        self.push(id.into(), FlowStepKind::Approval { prompt })
745    }
746
747    /// Append a non-final `complete` step and keep building. Use this for
748    /// branch terminals (e.g. a `condition` else-target) when more steps
749    /// follow; finish the chain with [`FlowBuilder::complete`].
750    pub fn complete_step(self, id: impl Into<String>, message: Option<String>) -> Self {
751        self.push(id.into(), FlowStepKind::Complete { message })
752    }
753
754    /// Append the terminal `complete` step and surface any recorded builder error.
755    #[must_use = "handle the FlowError so malformed flows fail fast at startup"]
756    pub fn complete(self, id: impl Into<String>, message: Option<String>) -> Result<(), FlowError> {
757        let flow_id = self.flow_id.clone();
758        let inner = self.inner.clone();
759        let _ = self.push(id.into(), FlowStepKind::Complete { message });
760        let flows = inner.flows.read().expect("flows");
761        match flows.get(&flow_id).and_then(|r| r.error.clone()) {
762            Some(err) => Err(err),
763            None => Ok(()),
764        }
765    }
766
767    fn push(self, id: String, kind: FlowStepKind) -> Self {
768        let step_id = id.trim().to_string();
769        let mut flows = self.inner.flows.write().expect("flows");
770        if let Some(reg) = flows.get_mut(&self.flow_id) {
771            if reg.error.is_none() {
772                if step_id.is_empty() {
773                    reg.error = Some(FlowError::EmptyStepId {
774                        flow: self.flow_id.clone(),
775                    });
776                } else if reg.steps.iter().any(|s| s.id == step_id) {
777                    reg.error = Some(FlowError::DuplicateStepId {
778                        flow: self.flow_id.clone(),
779                        step: step_id.clone(),
780                    });
781                } else {
782                    reg.steps.push(FlowStep { id: step_id, kind });
783                }
784            }
785        }
786        drop(flows);
787        self
788    }
789}
790
791#[derive(Debug, Clone)]
792pub struct ListenOptions {
793    pub port: u16,
794    pub host: Option<String>,
795    pub path: Option<String>,
796}
797
798pub struct ListenHandle {
799    pub url: String,
800    shutdown: Option<tokio::sync::oneshot::Sender<()>>,
801    join: Option<tokio::task::JoinHandle<()>>,
802}
803
804impl ListenHandle {
805    pub async fn close(mut self) {
806        if let Some(tx) = self.shutdown.take() {
807            let _ = tx.send(());
808        }
809        if let Some(join) = self.join.take() {
810            let _ = join.await;
811        }
812    }
813}
814
815struct Inner {
816    config: QefroConfig,
817    tools: RwLock<HashMap<String, ToolRegistration>>,
818    flows: RwLock<HashMap<String, FlowRegistration>>,
819    events: RwLock<HashMap<String, EventHandlerDefinition>>,
820    webhooks: RwLock<HashMap<String, EventHandlerDefinition>>,
821    schedules: RwLock<HashMap<String, EventHandlerDefinition>>,
822    pending: Mutex<HashMap<String, PendingInvocation>>,
823    auth_by_conversation: Mutex<HashMap<Uuid, StoredAuth>>,
824    customer_provider: RwLock<Option<Arc<dyn CustomerProvider>>>,
825    middlewares: RwLock<Vec<MiddlewareFn>>,
826    before_hooks: RwLock<Vec<BeforeHook>>,
827    after_hooks: RwLock<Vec<AfterHook>>,
828}
829
830/// In-handler customer API (mirrors JS `ctx.customer`).
831///
832/// Hub methods (`resolve` / `create` / `update` / `note` / `tag`) talk to the
833/// platform Customer Hub via `platform.customer`. External `CustomerProvider`
834/// auth (`authorize` / provider `lookup`) is unchanged for connector CRMs.
835#[derive(Clone)]
836pub struct CustomerApi {
837    app: Qefro,
838    identity: Value,
839    parameters: Value,
840    conversation_id: Uuid,
841    channel: Option<String>,
842    auth_response: Option<String>,
843    state: Arc<Mutex<CustomerState>>,
844    platform: Option<PlatformCapabilities>,
845}
846
847impl CustomerApi {
848    async fn set_current(&self, customer: Option<Value>) -> Option<Value> {
849        let mut state = self.state.lock().await;
850        state.current = customer.clone();
851        state.lookup_completed = true;
852        customer
853    }
854
855    async fn require_current_id(&self) -> Result<String> {
856        let state = self.state.lock().await;
857        if let Some(id) = state
858            .current
859            .as_ref()
860            .and_then(|v| v.get("id"))
861            .and_then(|v| v.as_str())
862            .filter(|s| !s.is_empty())
863        {
864            return Ok(id.to_string());
865        }
866        Err(anyhow!("customer_not_found"))
867    }
868
869    /// Resolve-or-create Customer Hub identity (preferred for apps).
870    pub async fn resolve(&self, input: Option<Value>) -> Result<Option<Value>> {
871        let identity = pick_identity(input.as_ref(), &self.identity);
872        let mut body = Value::Object(identity);
873        if let Some(obj) = body.as_object_mut() {
874            if let Some(ch) = self.channel.as_ref() {
875                obj.insert("channel".into(), json!(ch));
876            }
877            obj.insert("conversation_id".into(), json!(self.conversation_id.to_string()));
878        }
879        let out = hub_call(self.platform.as_ref(), "resolve", body).await?;
880        let hub = hub_customer_from_person(out.as_ref());
881        if hub.is_some() {
882            self.set_current(hub.clone()).await;
883        }
884        Ok(hub)
885    }
886
887    /// Lookup only (no create). Hub when args / hub-only; else external provider.
888    pub async fn lookup(&self, input: Option<Value>) -> Result<Option<Value>> {
889        let provider = self
890            .app
891            .inner
892            .customer_provider
893            .read()
894            .expect("customer_provider")
895            .clone();
896
897        if input.is_some() || provider.is_none() {
898            {
899                let state = self.state.lock().await;
900                if state.lookup_completed && input.is_none() && state.current.is_some() {
901                    return Ok(state.current.clone());
902                }
903            }
904            let identity = pick_identity(input.as_ref(), &self.identity);
905            let mut body = Value::Object(identity);
906            if let Some(obj) = body.as_object_mut() {
907                if let Some(ch) = self.channel.as_ref() {
908                    obj.insert("channel".into(), json!(ch));
909                }
910                obj.insert(
911                    "conversation_id".into(),
912                    json!(self.conversation_id.to_string()),
913                );
914            }
915            let out = hub_call(self.platform.as_ref(), "lookup", body).await?;
916            let hub = hub_customer_from_person(out.as_ref());
917            self.set_current(hub.clone()).await;
918            return Ok(hub);
919        }
920
921        {
922            let state = self.state.lock().await;
923            if state.lookup_completed {
924                return Ok(state.current.clone());
925            }
926        }
927
928        let customer = provider
929            .unwrap()
930            .lookup(&CustomerLookupContext {
931                identity: self.identity.clone(),
932                parameters: self.parameters.clone(),
933                conversation: Conversation {
934                    id: self.conversation_id,
935                },
936                channel: self.channel.clone(),
937            })
938            .await?;
939
940        Ok(self.set_current(customer).await)
941    }
942
943    pub async fn lookup_by_phone(&self, phone: Option<&str>) -> Result<Option<Value>> {
944        let source = phone
945            .map(str::to_string)
946            .or_else(|| read_identity_phone(&self.identity));
947
948        let Some(source) = source else {
949            let mut state = self.state.lock().await;
950            state.lookup_completed = true;
951            state.current = None;
952            return Ok(None);
953        };
954
955        let provider = self
956            .app
957            .inner
958            .customer_provider
959            .read()
960            .expect("customer_provider")
961            .clone();
962
963        if provider.is_none() || is_customer_hub_enabled() {
964            return self
965                .lookup(Some(json!({
966                    "phone_number": source,
967                    "whatsapp_number": source,
968                })))
969                .await;
970        }
971
972        let mut identity = self.identity.clone();
973        if let Some(obj) = identity.as_object_mut() {
974            obj.insert("phone".into(), json!(source));
975        }
976
977        let customer = provider
978            .unwrap()
979            .lookup(&CustomerLookupContext {
980                identity,
981                parameters: self.parameters.clone(),
982                conversation: Conversation {
983                    id: self.conversation_id,
984                },
985                channel: self.channel.clone(),
986            })
987            .await?;
988
989        Ok(self.set_current(customer).await)
990    }
991
992    pub async fn create(&self, input: Value) -> Result<Option<Value>> {
993        let identity = pick_identity(Some(&input), &self.identity);
994        let mut body = Value::Object(identity);
995        if let Some(obj) = body.as_object_mut() {
996            if let Some(ch) = self.channel.as_ref() {
997                obj.insert("channel".into(), json!(ch));
998            }
999            obj.insert(
1000                "conversation_id".into(),
1001                json!(self.conversation_id.to_string()),
1002            );
1003        }
1004        let out = hub_call(self.platform.as_ref(), "create", body).await?;
1005        let hub = hub_customer_from_person(out.as_ref());
1006        if hub.is_some() {
1007            self.set_current(hub.clone()).await;
1008        }
1009        Ok(hub)
1010    }
1011
1012    pub async fn update(&self, input: Value) -> Result<Option<Value>> {
1013        let id = input
1014            .get("id")
1015            .and_then(|v| v.as_str())
1016            .map(str::to_string)
1017            .or_else(|| {
1018                // filled from state below
1019                None
1020            });
1021        let id = match id {
1022            Some(id) => id,
1023            None => {
1024                let state = self.state.lock().await;
1025                match state
1026                    .current
1027                    .as_ref()
1028                    .and_then(|v| v.get("id"))
1029                    .and_then(|v| v.as_str())
1030                    .map(str::to_string)
1031                {
1032                    Some(id) => id,
1033                    None if is_customer_hub_optional() => return Ok(None),
1034                    None => return Err(anyhow!("customer_not_found")),
1035                }
1036            }
1037        };
1038        let identity = pick_identity(Some(&input), &self.identity);
1039        let mut body = Value::Object(identity);
1040        if let Some(obj) = body.as_object_mut() {
1041            obj.insert("id".into(), json!(id));
1042        }
1043        let out = hub_call(self.platform.as_ref(), "update", body).await?;
1044        let hub = hub_customer_from_person(out.as_ref());
1045        if hub.is_some() {
1046            self.set_current(hub.clone()).await;
1047        }
1048        Ok(hub)
1049    }
1050
1051    pub async fn note(&self, content: &str, options: Option<Value>) -> Result<()> {
1052        let trimmed = content.trim();
1053        if trimmed.is_empty() {
1054            return Err(anyhow!("customer_note_empty"));
1055        }
1056        let customer_id = match self.require_current_id().await {
1057            Ok(id) => id,
1058            Err(_err) if is_customer_hub_optional() => return Ok(()),
1059            Err(err) => return Err(err),
1060        };
1061        let author_id = options
1062            .as_ref()
1063            .and_then(|v| v.get("author_id"))
1064            .cloned()
1065            .unwrap_or(Value::Null);
1066        hub_call(
1067            self.platform.as_ref(),
1068            "note",
1069            json!({
1070                "customer_id": customer_id,
1071                "content": trimmed,
1072                "author_id": author_id,
1073            }),
1074        )
1075        .await?;
1076        Ok(())
1077    }
1078
1079    pub async fn tag(&self, name: &str, options: Option<Value>) -> Result<()> {
1080        let trimmed = name.trim();
1081        if trimmed.is_empty() {
1082            return Err(anyhow!("customer_tag_empty"));
1083        }
1084        let customer_id = match self.require_current_id().await {
1085            Ok(id) => id,
1086            Err(_err) if is_customer_hub_optional() => return Ok(()),
1087            Err(err) => return Err(err),
1088        };
1089        let color = options
1090            .as_ref()
1091            .and_then(|v| v.get("color"))
1092            .cloned()
1093            .unwrap_or(Value::Null);
1094        hub_call(
1095            self.platform.as_ref(),
1096            "tag",
1097            json!({
1098                "customer_id": customer_id,
1099                "name": trimmed,
1100                "color": color,
1101            }),
1102        )
1103        .await?;
1104        Ok(())
1105    }
1106
1107    pub async fn authorize(&self, method: Option<String>) -> Result<Value> {
1108        let provider = self
1109            .app
1110            .inner
1111            .customer_provider
1112            .read()
1113            .expect("customer_provider")
1114            .clone()
1115            .ok_or_else(|| anyhow!("customer_provider_missing"))?;
1116
1117        {
1118            let auth = self.app.inner.auth_by_conversation.lock().await;
1119            if let Some(existing) = auth.get(&self.conversation_id) {
1120                if existing.expires_at_epoch_ms > Utc::now().timestamp_millis() {
1121                    let mut state = self.state.lock().await;
1122                    state.current = Some(existing.customer.clone());
1123                    state.lookup_completed = true;
1124                    return Ok(existing.customer.clone());
1125                }
1126            }
1127        }
1128
1129        let customer = self
1130            .lookup(None)
1131            .await?
1132            .ok_or_else(|| anyhow!("customer_not_found"))?;
1133
1134        let outcome = provider
1135            .authorize(&CustomerAuthorizeContext {
1136                customer: customer.clone(),
1137                method,
1138                response: self.auth_response.clone(),
1139                identity: self.identity.clone(),
1140                parameters: self.parameters.clone(),
1141                conversation: Conversation {
1142                    id: self.conversation_id,
1143                },
1144                channel: self.channel.clone(),
1145            })
1146            .await?;
1147
1148        self.app
1149            .consume_auth_outcome(
1150                outcome,
1151                self.conversation_id,
1152                Some(self.state.clone()),
1153                None,
1154                None,
1155                None,
1156                None,
1157            )
1158            .await
1159    }
1160
1161    pub async fn get(&self) -> Option<Value> {
1162        self.state.lock().await.current.clone()
1163    }
1164
1165    pub async fn require(&self) -> Result<Value> {
1166        self.get()
1167            .await
1168            .ok_or_else(|| anyhow!("customer_not_found"))
1169    }
1170
1171    /// Convenience: Hub / provider customer `id` when available.
1172    pub async fn id(&self) -> Option<String> {
1173        self.get()
1174            .await
1175            .and_then(|v| v.get("id")?.as_str().map(str::to_string))
1176    }
1177
1178    pub async fn phone_number(&self) -> Option<String> {
1179        self.get().await.and_then(|v| {
1180            v.get("phone_number")?
1181                .as_str()
1182                .map(str::to_string)
1183                .or_else(|| v.get("phone")?.as_str().map(str::to_string))
1184        })
1185    }
1186
1187    pub async fn whatsapp_number(&self) -> Option<String> {
1188        self.get()
1189            .await
1190            .and_then(|v| v.get("whatsapp_number")?.as_str().map(str::to_string))
1191    }
1192
1193    pub async fn display_name(&self) -> Option<String> {
1194        self.get().await.and_then(|v| {
1195            v.get("display_name")?
1196                .as_str()
1197                .map(str::to_string)
1198                .or_else(|| v.get("name")?.as_str().map(str::to_string))
1199        })
1200    }
1201}
1202
1203#[derive(Clone)]
1204pub struct Qefro {
1205    inner: Arc<Inner>,
1206}
1207
1208impl Qefro {
1209    pub fn new(config: QefroConfig) -> Self {
1210        Self {
1211            inner: Arc::new(Inner {
1212                config,
1213                tools: RwLock::new(HashMap::new()),
1214                flows: RwLock::new(HashMap::new()),
1215                events: RwLock::new(HashMap::new()),
1216                webhooks: RwLock::new(HashMap::new()),
1217                schedules: RwLock::new(HashMap::new()),
1218                pending: Mutex::new(HashMap::new()),
1219                auth_by_conversation: Mutex::new(HashMap::new()),
1220                customer_provider: RwLock::new(None),
1221                middlewares: RwLock::new(Vec::new()),
1222                before_hooks: RwLock::new(Vec::new()),
1223                after_hooks: RwLock::new(Vec::new()),
1224            }),
1225        }
1226    }
1227
1228    pub fn customer<P>(&self, provider: P) -> &Self
1229    where
1230        P: CustomerProvider + 'static,
1231    {
1232        *self.inner.customer_provider.write().expect("customer_provider") =
1233            Some(Arc::new(provider));
1234        self
1235    }
1236
1237    pub fn tool<F, Fut>(&self, metadata: ToolMetadata, handler: F) -> &Self
1238    where
1239        F: Fn(ToolContext) -> Fut + Send + Sync + 'static,
1240        Fut: Future<Output = Result<Value>> + Send + 'static,
1241    {
1242        let name = metadata.name.clone();
1243        let lookup = normalized_lookup_field(metadata.lookup.as_ref());
1244        let metadata = ToolMetadata {
1245            lookup,
1246            ..metadata
1247        };
1248        let registration = ToolRegistration {
1249            metadata,
1250            handler: Arc::new(move |ctx| Box::pin(handler(ctx))),
1251        };
1252        self.inner
1253            .tools
1254            .write()
1255            .expect("tools")
1256            .insert(name, registration);
1257        self
1258    }
1259
1260    /// Register a Business Flow. Flows are metadata only: the SDK advertises them
1261    /// through `capabilities.list` and the Qefro Runtime orchestrates execution.
1262    ///
1263    /// Returns [`FlowError`] on a duplicate or empty flow id — the SDK never panics.
1264    pub fn flow(&self, metadata: BusinessFlowMetadata) -> std::result::Result<FlowBuilder, FlowError> {
1265        let id = metadata.id.trim().to_string();
1266        if id.is_empty() {
1267            return Err(FlowError::EmptyFlowId);
1268        }
1269        {
1270            let flows = self.inner.flows.read().expect("flows");
1271            if flows.contains_key(&id) {
1272                return Err(FlowError::DuplicateFlowId(id));
1273            }
1274        }
1275        let version = if metadata.version == 0 { 1 } else { metadata.version };
1276        let trigger = match metadata.trigger.clone() {
1277            Some(t) => Some(t.normalize()?),
1278            None => None,
1279        };
1280        let metadata = BusinessFlowMetadata {
1281            id: id.clone(),
1282            version,
1283            trigger,
1284            ..metadata
1285        };
1286        self.inner.flows.write().expect("flows").insert(
1287            id.clone(),
1288            FlowRegistration {
1289                metadata,
1290                steps: Vec::new(),
1291                error: None,
1292            },
1293        );
1294        Ok(FlowBuilder {
1295            inner: self.inner.clone(),
1296            flow_id: id,
1297        })
1298    }
1299
1300    /// Register a standalone event handler (advertised via capabilities.list).
1301    /// The Qefro runtime owns delivery; connectors only emit into the bus.
1302    pub fn event(&self, def: EventHandlerDefinition) -> std::result::Result<&Self, FlowError> {
1303        self.register_named_handler("event", &self.inner.events, def)
1304    }
1305
1306    /// Register a webhook alias (normalized to an orchestration event at ingest).
1307    pub fn webhook(&self, def: EventHandlerDefinition) -> std::result::Result<&Self, FlowError> {
1308        self.register_named_handler("webhook", &self.inner.webhooks, def)
1309    }
1310
1311    /// Register a cron schedule. The runtime scheduler emits the named event.
1312    pub fn schedule(&self, def: EventHandlerDefinition) -> std::result::Result<&Self, FlowError> {
1313        let cron = def.cron.as_deref().unwrap_or("").trim();
1314        if cron.is_empty() {
1315            return Err(FlowError::InvalidTrigger(
1316                "schedule() requires a non-empty cron expression".into(),
1317            ));
1318        }
1319        self.register_named_handler("schedule", &self.inner.schedules, def)
1320    }
1321
1322    fn register_named_handler(
1323        &self,
1324        kind: &'static str,
1325        lock: &RwLock<HashMap<String, EventHandlerDefinition>>,
1326        def: EventHandlerDefinition,
1327    ) -> std::result::Result<&Self, FlowError> {
1328        let name = def.name.trim().to_string();
1329        if name.is_empty() {
1330            return Err(FlowError::EmptyHandlerName(kind));
1331        }
1332        let mut map = lock.write().expect(kind);
1333        if map.contains_key(&name) {
1334            return Err(FlowError::DuplicateHandler { kind, name });
1335        }
1336        map.insert(
1337            name.clone(),
1338            EventHandlerDefinition {
1339                name,
1340                description: def.description,
1341                cron: def.cron,
1342            },
1343        );
1344        Ok(self)
1345    }
1346
1347    fn list_named_handlers(
1348        lock: &RwLock<HashMap<String, EventHandlerDefinition>>,
1349    ) -> Vec<EventHandlerDefinition> {
1350        lock.read()
1351            .expect("handlers")
1352            .values()
1353            .cloned()
1354            .collect()
1355    }
1356
1357    /// Snapshot the valid registered flows for `capabilities.list`. Flows with a
1358    /// recorded builder error are excluded and logged.
1359    fn list_registered_flows(&self) -> Vec<BusinessFlow> {
1360        let flows = self.inner.flows.read().expect("flows");
1361        flows
1362            .values()
1363            .filter_map(|reg| {
1364                if let Some(err) = &reg.error {
1365                    eprintln!("[qefro] skipping invalid flow \"{}\": {err}", reg.metadata.id);
1366                    None
1367                } else {
1368                    Some(BusinessFlow {
1369                        metadata: reg.metadata.clone(),
1370                        steps: reg.steps.clone(),
1371                    })
1372                }
1373            })
1374            .collect()
1375    }
1376
1377    pub fn before<F, Fut>(&self, hook: F) -> &Self
1378    where
1379        F: Fn(ToolContext) -> Fut + Send + Sync + 'static,
1380        Fut: Future<Output = Result<()>> + Send + 'static,
1381    {
1382        let hook: BeforeHook = Arc::new(move |ctx| Box::pin(hook(ctx)));
1383        self.inner
1384            .before_hooks
1385            .write()
1386            .expect("before_hooks")
1387            .push(hook);
1388        self
1389    }
1390
1391    pub fn after<F, Fut>(&self, hook: F) -> &Self
1392    where
1393        F: Fn(ToolContext, Value) -> Fut + Send + Sync + 'static,
1394        Fut: Future<Output = Result<Value>> + Send + 'static,
1395    {
1396        let hook: AfterHook = Arc::new(move |ctx, out| Box::pin(hook(ctx, out)));
1397        self.inner
1398            .after_hooks
1399            .write()
1400            .expect("after_hooks")
1401            .push(hook);
1402        self
1403    }
1404
1405    /// Onion middleware (mirrors JS `app.use`).
1406    pub fn use_middleware<F>(&self, middleware: F) -> &Self
1407    where
1408        F: Fn(ToolContext, NextFn) -> Pin<Box<dyn Future<Output = Result<Value>> + Send>>
1409            + Send
1410            + Sync
1411            + 'static,
1412    {
1413        let mw: MiddlewareFn = Arc::new(middleware);
1414        self.inner.middlewares.write().expect("middlewares").push(mw);
1415        self
1416    }
1417
1418    pub fn verify_signature(&self, signature: &str, timestamp: i64, body: &str) -> bool {
1419        let now = Utc::now().timestamp();
1420        if (now - timestamp).abs() > self.inner.config.max_timestamp_skew_secs {
1421            return false;
1422        }
1423        let payload = format!("v1:{timestamp}:{body}");
1424        let mut mac = HmacSha256::new_from_slice(self.inner.config.signing_secret.as_bytes())
1425            .expect("HMAC accepts any key length");
1426        mac.update(payload.as_bytes());
1427        let expected = format!("v1={}", hex::encode(mac.finalize().into_bytes()));
1428        let a = expected.as_bytes();
1429        let b = signature.as_bytes();
1430        a.len() == b.len() && bool::from(a.ct_eq(b))
1431    }
1432
1433    /// Handle a verified protocol request (after signature check).
1434    pub async fn handle(&self, request: QefroRequest) -> QefroResponse {
1435        if request.protocol_version != self.inner.config.protocol_version {
1436            return QefroResponse::Error {
1437                code: "protocol_mismatch".into(),
1438                message: "Unsupported protocol version".into(),
1439            };
1440        }
1441
1442        match request.request_type.as_str() {
1443            "ping" => QefroResponse::Pong {
1444                protocol_version: self.inner.config.protocol_version.clone(),
1445                sdk_version: SDK_VERSION.into(),
1446            },
1447            "tools.list" => {
1448                let tools = self.inner.tools.read().expect("tools");
1449                QefroResponse::ToolsList {
1450                    tools: tools
1451                        .values()
1452                        .map(|r| RegisteredTool {
1453                            name: r.metadata.name.clone(),
1454                            description: r.metadata.description.clone(),
1455                            input_schema: r.metadata.input_schema.clone(),
1456                            authentication_methods: r.metadata.authentication_methods.clone(),
1457                            auth: r.metadata.auth,
1458                            permissions: r.metadata.permissions.clone(),
1459                            timeout: r.metadata.timeout,
1460                            lookup: r.metadata.lookup.clone(),
1461                        })
1462                        .collect(),
1463                    protocol_version: self.inner.config.protocol_version.clone(),
1464                    sdk_version: SDK_VERSION.into(),
1465                }
1466            }
1467            "capabilities.list" => {
1468                let tools = {
1469                    let tools = self.inner.tools.read().expect("tools");
1470                    tools
1471                        .values()
1472                        .map(|r| RegisteredTool {
1473                            name: r.metadata.name.clone(),
1474                            description: r.metadata.description.clone(),
1475                            input_schema: r.metadata.input_schema.clone(),
1476                            authentication_methods: r.metadata.authentication_methods.clone(),
1477                            auth: r.metadata.auth,
1478                            permissions: r.metadata.permissions.clone(),
1479                            timeout: r.metadata.timeout,
1480                            lookup: r.metadata.lookup.clone(),
1481                        })
1482                        .collect()
1483                };
1484                QefroResponse::CapabilitiesList {
1485                    tools,
1486                    flows: self.list_registered_flows(),
1487                    events: Self::list_named_handlers(&self.inner.events),
1488                    webhooks: Self::list_named_handlers(&self.inner.webhooks),
1489                    schedules: Self::list_named_handlers(&self.inner.schedules),
1490                    protocol_version: self.inner.config.protocol_version.clone(),
1491                    sdk_version: SDK_VERSION.into(),
1492                    sdk_name: SDK_NAME.into(),
1493                }
1494            }
1495            "tool.invoke" => {
1496                self.invoke(
1497                    request.tool,
1498                    request.parameters.unwrap_or_else(|| json!({})),
1499                    request.conversation_id.unwrap_or_else(Uuid::new_v4),
1500                    request.identity,
1501                    request.channel,
1502                    request.authentication,
1503                    None,
1504                    request.platform,
1505                    request.person,
1506                )
1507                .await
1508            }
1509            "tool.resume" => {
1510                let Some(resume_token) = request.resume_token else {
1511                    return QefroResponse::Error {
1512                        code: "invalid_request".into(),
1513                        message: "resume_token is required".into(),
1514                    };
1515                };
1516                let Some(challenge_response) = request.challenge_response else {
1517                    return QefroResponse::Error {
1518                        code: "invalid_request".into(),
1519                        message: "challenge_response is required".into(),
1520                    };
1521                };
1522                let pending = {
1523                    let mut map = self.inner.pending.lock().await;
1524                    map.remove(&resume_token)
1525                };
1526                let Some(pending) = pending else {
1527                    return QefroResponse::Error {
1528                        code: "not_found".into(),
1529                        message: "resume token not found or expired".into(),
1530                    };
1531                };
1532                self.invoke(
1533                    Some(pending.tool),
1534                    pending.parameters,
1535                    pending.conversation_id,
1536                    pending.identity,
1537                    pending.channel,
1538                    request.authentication,
1539                    Some(challenge_response),
1540                    request.platform.or(pending.platform),
1541                    request.person.or(pending.person),
1542                )
1543                .await
1544            }
1545            _ => QefroResponse::Error {
1546                code: "invalid_request".into(),
1547                message: "Unsupported request type".into(),
1548            },
1549        }
1550    }
1551
1552    /// Verify signature + protocol headers, then handle (mirrors JS `handleRaw`).
1553    pub async fn handle_raw(
1554        &self,
1555        body: &str,
1556        headers: &HeaderMap,
1557    ) -> (StatusCode, QefroResponse) {
1558        let signature = header_str(headers, "x-qefro-signature");
1559        let timestamp = header_str(headers, "x-qefro-timestamp")
1560            .and_then(|t| t.parse::<i64>().ok());
1561
1562        let protocol_header = header_str(headers, "x-qefro-protocol")
1563            .or_else(|| header_str(headers, "x-qefro-protocol-version"));
1564        if let Some(proto) = protocol_header {
1565            if proto != self.inner.config.protocol_version {
1566                return (
1567                    StatusCode::BAD_REQUEST,
1568                    QefroResponse::Error {
1569                        code: "protocol_mismatch".into(),
1570                        message: format!("Unsupported protocol version {proto}"),
1571                    },
1572                );
1573            }
1574        }
1575
1576        match (signature, timestamp) {
1577            (Some(sig), Some(ts)) if self.verify_signature(sig, ts, body) => {}
1578            _ => {
1579                return (
1580                    StatusCode::UNAUTHORIZED,
1581                    QefroResponse::Error {
1582                        code: "invalid_signature".into(),
1583                        message: "Invalid Qefro signature".into(),
1584                    },
1585                );
1586            }
1587        }
1588
1589        let request: QefroRequest = match serde_json::from_str(body) {
1590            Ok(r) => r,
1591            Err(e) => {
1592                return (
1593                    StatusCode::BAD_REQUEST,
1594                    QefroResponse::Error {
1595                        code: "invalid_request".into(),
1596                        message: e.to_string(),
1597                    },
1598                );
1599            }
1600        };
1601
1602        (StatusCode::OK, self.handle(request).await)
1603    }
1604
1605    /// Start an HTTP server (mirrors JS `listen`).
1606    pub async fn listen(&self, options: ListenOptions) -> Result<ListenHandle> {
1607        let host = options
1608            .host
1609            .unwrap_or_else(|| "0.0.0.0".to_string());
1610        let path = options
1611            .path
1612            .unwrap_or_else(|| self.inner.config.endpoint_path.clone());
1613        let path = if path.starts_with('/') {
1614            path
1615        } else {
1616            format!("/{path}")
1617        };
1618
1619        let app_state = self.clone();
1620        let router = Router::new()
1621            .route(&path, post(http_handler))
1622            .with_state(app_state);
1623
1624        let addr = format!("{host}:{}", options.port);
1625        let listener = tokio::net::TcpListener::bind(&addr).await?;
1626        let url = format!("http://{host}:{}{path}", options.port);
1627
1628        let (tx, rx) = tokio::sync::oneshot::channel::<()>();
1629        let join = tokio::spawn(async move {
1630            let _ = axum::serve(listener, router)
1631                .with_graceful_shutdown(async {
1632                    let _ = rx.await;
1633                })
1634                .await;
1635        });
1636
1637        Ok(ListenHandle {
1638            url,
1639            shutdown: Some(tx),
1640            join: Some(join),
1641        })
1642    }
1643
1644    async fn invoke(
1645        &self,
1646        tool: Option<String>,
1647        parameters: Value,
1648        conversation_id: Uuid,
1649        identity: Option<Value>,
1650        channel: Option<String>,
1651        authentication: Option<Value>,
1652        auth_response: Option<String>,
1653        platform: Option<PlatformCapabilities>,
1654        person: Option<Value>,
1655    ) -> QefroResponse {
1656        let Some(tool_name) = tool else {
1657            return QefroResponse::Error {
1658                code: "invalid_request".into(),
1659                message: "tool is required".into(),
1660            };
1661        };
1662
1663        let registration = {
1664            let tools = self.inner.tools.read().expect("tools");
1665            tools.get(&tool_name).cloned()
1666        };
1667        let Some(registration) = registration else {
1668            return QefroResponse::Error {
1669                code: "not_found".into(),
1670                message: format!("Unknown tool: {tool_name}"),
1671            };
1672        };
1673
1674        let identity_value = identity.clone().unwrap_or_else(|| json!({}));
1675        let customer_state = Arc::new(Mutex::new(CustomerState::default()));
1676
1677        {
1678            let auth = self.inner.auth_by_conversation.lock().await;
1679            if let Some(stored) = auth.get(&conversation_id) {
1680                if stored.expires_at_epoch_ms > Utc::now().timestamp_millis() {
1681                    let mut state = customer_state.lock().await;
1682                    state.current = Some(stored.customer.clone());
1683                    state.lookup_completed = true;
1684                }
1685            }
1686        }
1687
1688        // Seed hub customer from Person snapshot when present (native chat path).
1689        if let Some(ref person_val) = person {
1690            if let Some(seeded) = seed_from_person(person_val) {
1691                let mut state = customer_state.lock().await;
1692                state.current = Some(seeded);
1693                state.lookup_completed = true;
1694            }
1695        }
1696
1697        let customer_api = CustomerApi {
1698            app: self.clone(),
1699            identity: identity_value.clone(),
1700            parameters: parameters.clone(),
1701            conversation_id,
1702            channel: channel.clone(),
1703            auth_response: auth_response.clone(),
1704            state: customer_state.clone(),
1705            platform: platform.clone(),
1706        };
1707
1708        let mut current_customer = customer_state.lock().await.current.clone();
1709
1710        if registration.metadata.auth == ToolAuthMode::Required {
1711            match customer_api
1712                .authorize(registration.metadata.default_auth_method.clone())
1713                .await
1714            {
1715                Ok(customer) => current_customer = Some(customer),
1716                Err(e) => {
1717                    return map_invoke_error(
1718                        e,
1719                        self,
1720                        &tool_name,
1721                        &parameters,
1722                        conversation_id,
1723                        identity.clone(),
1724                        channel.clone(),
1725                        platform.clone(),
1726                        person.clone(),
1727                    )
1728                    .await
1729                }
1730            }
1731        }
1732
1733        let solution_id = platform
1734            .as_ref()
1735            .and_then(|p| p.customer.as_ref())
1736            .and_then(|c| c.context.as_ref())
1737            .and_then(|c| c.solution_id.clone())
1738            .or_else(|| {
1739                platform
1740                    .as_ref()
1741                    .and_then(|p| p.storage.as_ref())
1742                    .and_then(|s| s.context.as_ref())
1743                    .map(|c| c.solution_id.clone())
1744            });
1745
1746        let timeline = TimelineContext {
1747            platform: platform.clone(),
1748            state: customer_state.clone(),
1749        };
1750        let membership = MembershipContext {
1751            platform: platform.clone(),
1752            state: customer_state.clone(),
1753            solution_id,
1754        };
1755        let consent = ConsentContext {
1756            platform: platform.clone(),
1757            state: customer_state.clone(),
1758        };
1759
1760        let ctx = ToolContext {
1761            identity: identity_value,
1762            parameters: parameters.clone(),
1763            conversation: Conversation {
1764                id: conversation_id,
1765            },
1766            channel: channel.clone(),
1767            authentication,
1768            auth_response,
1769            customer: current_customer,
1770            customer_api: Some(customer_api),
1771            timeline,
1772            membership,
1773            consent,
1774            platform: platform.clone(),
1775        };
1776
1777        let before_hooks = self.inner.before_hooks.read().expect("before_hooks").clone();
1778        for hook in &before_hooks {
1779            if let Err(e) = hook(ctx.clone()).await {
1780                return map_invoke_error(
1781                    e,
1782                    self,
1783                    &tool_name,
1784                    &parameters,
1785                    conversation_id,
1786                    identity.clone(),
1787                    channel.clone(),
1788                    platform.clone(),
1789                    person.clone(),
1790                )
1791                .await;
1792            }
1793        }
1794
1795        let handler = registration.handler.clone();
1796        let middlewares = self.inner.middlewares.read().expect("middlewares").clone();
1797        let run_result = run_middlewares(middlewares, ctx.clone(), handler).await;
1798
1799        let output = match run_result {
1800            Ok(v) => v,
1801            Err(e) => {
1802                return map_invoke_error(
1803                    e,
1804                    self,
1805                    &tool_name,
1806                    &parameters,
1807                    conversation_id,
1808                    identity,
1809                    channel,
1810                    platform,
1811                    person,
1812                )
1813                .await;
1814            }
1815        };
1816
1817        let after_hooks = self.inner.after_hooks.read().expect("after_hooks").clone();
1818        let mut output = output;
1819        for hook in &after_hooks {
1820            match hook(ctx.clone(), output).await {
1821                Ok(v) => output = v,
1822                Err(e) => {
1823                    return map_invoke_error(
1824                        e,
1825                        self,
1826                        &tool_name,
1827                        &parameters,
1828                        conversation_id,
1829                        identity,
1830                        channel,
1831                        platform,
1832                        person,
1833                    )
1834                    .await;
1835                }
1836            }
1837        }
1838
1839        let auth = {
1840            let map = self.inner.auth_by_conversation.lock().await;
1841            map.get(&conversation_id)
1842                .filter(|v| v.expires_at_epoch_ms > Utc::now().timestamp_millis())
1843                .map(|v| v.auth.clone())
1844        };
1845
1846        QefroResponse::Result {
1847            output,
1848            authentication_context: auth,
1849        }
1850    }
1851
1852    async fn consume_auth_outcome(
1853        &self,
1854        outcome: AuthOutcome,
1855        conversation_id: Uuid,
1856        customer_state: Option<Arc<Mutex<CustomerState>>>,
1857        // When challenge: stash pending invoke
1858        pending_tool: Option<&str>,
1859        pending_parameters: Option<Value>,
1860        pending_identity: Option<Value>,
1861        pending_channel: Option<String>,
1862    ) -> Result<Value> {
1863        match outcome {
1864            AuthOutcome::Success { customer, auth } => {
1865                let ttl = auth.expires_in.unwrap_or(900).max(1);
1866                self.inner.auth_by_conversation.lock().await.insert(
1867                    conversation_id,
1868                    StoredAuth {
1869                        customer: customer.clone(),
1870                        auth,
1871                        expires_at_epoch_ms: Utc::now().timestamp_millis() + ttl * 1000,
1872                    },
1873                );
1874                if let Some(state) = customer_state {
1875                    let mut s = state.lock().await;
1876                    s.current = Some(customer.clone());
1877                    s.lookup_completed = true;
1878                }
1879                Ok(customer)
1880            }
1881            AuthOutcome::Challenge(challenge) => {
1882                if let (Some(tool), Some(parameters)) = (pending_tool, pending_parameters) {
1883                    let resume_token = Uuid::new_v4().to_string();
1884                    self.inner.pending.lock().await.insert(
1885                        resume_token.clone(),
1886                        PendingInvocation {
1887                            tool: tool.to_string(),
1888                            conversation_id,
1889                            parameters,
1890                            identity: pending_identity,
1891                            channel: pending_channel,
1892                            platform: None,
1893                            person: None,
1894                        },
1895                    );
1896                    // Encode resume in error path via ChallengeSignal — callers for required-auth
1897                    // use map_invoke_error. For CustomerApi.authorize we raise ChallengeSignal.
1898                    let _ = resume_token;
1899                }
1900                Err(ChallengeSignal { challenge }.into())
1901            }
1902            AuthOutcome::Denied => Err(anyhow!("denied")),
1903            AuthOutcome::NotFound => Err(anyhow!("customer_not_found")),
1904        }
1905    }
1906
1907    pub async fn require_authentication(
1908        &self,
1909        conversation_id: Uuid,
1910        outcome: AuthOutcome,
1911        tool: &str,
1912        parameters: Value,
1913        identity: Option<Value>,
1914        channel: Option<String>,
1915    ) -> std::result::Result<Value, QefroResponse> {
1916        match outcome {
1917            AuthOutcome::Success { customer, auth } => {
1918                let ttl = auth.expires_in.unwrap_or(900).max(1);
1919                self.inner.auth_by_conversation.lock().await.insert(
1920                    conversation_id,
1921                    StoredAuth {
1922                        customer: customer.clone(),
1923                        auth,
1924                        expires_at_epoch_ms: Utc::now().timestamp_millis() + ttl * 1000,
1925                    },
1926                );
1927                Ok(customer)
1928            }
1929            AuthOutcome::Challenge(challenge) => {
1930                let resume_token = Uuid::new_v4().to_string();
1931                self.inner.pending.lock().await.insert(
1932                    resume_token.clone(),
1933                    PendingInvocation {
1934                        tool: tool.to_string(),
1935                        conversation_id,
1936                        parameters,
1937                        identity,
1938                        channel,
1939                        platform: None,
1940                        person: None,
1941                    },
1942                );
1943                Err(QefroResponse::Challenge {
1944                    resume_token,
1945                    challenge,
1946                })
1947            }
1948            AuthOutcome::Denied => Err(QefroResponse::Error {
1949                code: "denied".into(),
1950                message: "Authentication denied".into(),
1951            }),
1952            AuthOutcome::NotFound => Err(QefroResponse::Error {
1953                code: "customer_not_found".into(),
1954                message: "Customer not found".into(),
1955            }),
1956        }
1957    }
1958}
1959
1960async fn map_invoke_error(
1961    e: anyhow::Error,
1962    app: &Qefro,
1963    tool_name: &str,
1964    parameters: &Value,
1965    conversation_id: Uuid,
1966    identity: Option<Value>,
1967    channel: Option<String>,
1968    platform: Option<PlatformCapabilities>,
1969    person: Option<Value>,
1970) -> QefroResponse {
1971    if let Some(signal) = e.downcast_ref::<ChallengeSignal>() {
1972        let resume_token = Uuid::new_v4().to_string();
1973        app.inner.pending.lock().await.insert(
1974            resume_token.clone(),
1975            PendingInvocation {
1976                tool: tool_name.to_string(),
1977                conversation_id,
1978                parameters: parameters.clone(),
1979                identity,
1980                channel,
1981                platform,
1982                person,
1983            },
1984        );
1985        return QefroResponse::Challenge {
1986            resume_token,
1987            challenge: signal.challenge.clone(),
1988        };
1989    }
1990
1991    let message = e.to_string();
1992    if message == "denied" {
1993        return QefroResponse::Error {
1994            code: "denied".into(),
1995            message: "Authentication denied".into(),
1996        };
1997    }
1998    if message == "customer_not_found" {
1999        return QefroResponse::Error {
2000            code: "customer_not_found".into(),
2001            message: "Customer not found".into(),
2002        };
2003    }
2004    if message == "customer_provider_missing" {
2005        return QefroResponse::Error {
2006            code: "configuration_error".into(),
2007            message: "Tool requires customer provider. Configure app.customer(...) first.".into(),
2008        };
2009    }
2010
2011    QefroResponse::Error {
2012        code: "internal_error".into(),
2013        message,
2014    }
2015}
2016
2017async fn run_middlewares(
2018    middlewares: Vec<MiddlewareFn>,
2019    ctx: ToolContext,
2020    handler: ToolHandler,
2021) -> Result<Value> {
2022    fn dispatch(
2023        i: usize,
2024        middlewares: Arc<Vec<MiddlewareFn>>,
2025        ctx: ToolContext,
2026        handler: ToolHandler,
2027    ) -> Pin<Box<dyn Future<Output = Result<Value>> + Send>> {
2028        Box::pin(async move {
2029            if i == middlewares.len() {
2030                return handler(ctx).await;
2031            }
2032            let mw = middlewares[i].clone();
2033            let mws = middlewares.clone();
2034            let h = handler.clone();
2035            let next: NextFn = Box::new(move |c| dispatch(i + 1, mws, c, h));
2036            mw(ctx, next).await
2037        })
2038    }
2039
2040    dispatch(0, Arc::new(middlewares), ctx, handler).await
2041}
2042
2043fn header_str<'a>(headers: &'a HeaderMap, key: &str) -> Option<&'a str> {
2044    headers.get(key).and_then(|v| v.to_str().ok())
2045}
2046
2047fn protocol_response_headers(app: &Qefro) -> HeaderMap {
2048    use axum::http::HeaderValue;
2049    let mut headers = HeaderMap::new();
2050    let proto = HeaderValue::from_str(&app.inner.config.protocol_version)
2051        .unwrap_or_else(|_| HeaderValue::from_static("1"));
2052    headers.insert("X-Qefro-Protocol", proto.clone());
2053    headers.insert("X-Qefro-Protocol-Version", proto);
2054    headers.insert("X-Qefro-SDK", HeaderValue::from_static(SDK_NAME));
2055    headers.insert(
2056        "X-Qefro-Version",
2057        HeaderValue::from_static(SDK_VERSION),
2058    );
2059    headers
2060}
2061
2062async fn http_handler(
2063    State(app): State<Qefro>,
2064    headers: HeaderMap,
2065    body: Bytes,
2066) -> impl IntoResponse {
2067    let mut response_headers = protocol_response_headers(&app);
2068    let body_str = String::from_utf8_lossy(&body);
2069    let (status, resp) = app.handle_raw(&body_str, &headers).await;
2070    response_headers.insert(
2071        axum::http::header::CONTENT_TYPE,
2072        "application/json".parse().unwrap(),
2073    );
2074    (status, response_headers, Json(resp))
2075}
2076
2077#[cfg(test)]
2078mod tests {
2079    use super::*;
2080
2081    #[test]
2082    fn normalize_lookup_dedupes() {
2083        let lookup = ToolLookup {
2084            by: Some("Email".into()),
2085            required: vec!["phone".into(), "email".into()],
2086        };
2087        assert_eq!(
2088            normalize_lookup(Some(&lookup)),
2089            vec!["phone".to_string(), "email".to_string()]
2090        );
2091    }
2092
2093    #[test]
2094    fn signature_roundtrip() {
2095        let app = Qefro::new(QefroConfig::new("secret"));
2096        let body = r#"{"protocol_version":"1","request_id":"00000000-0000-0000-0000-000000000001","type":"ping"}"#;
2097        let ts = Utc::now().timestamp();
2098        let payload = format!("v1:{ts}:{body}");
2099        let mut mac = HmacSha256::new_from_slice(b"secret").unwrap();
2100        mac.update(payload.as_bytes());
2101        let sig = format!("v1={}", hex::encode(mac.finalize().into_bytes()));
2102        assert!(app.verify_signature(&sig, ts, body));
2103        assert!(!app.verify_signature("v1=deadbeef", ts, body));
2104    }
2105
2106    #[tokio::test]
2107    async fn tools_list_includes_lookup() {
2108        let app = Qefro::new(QefroConfig::new("secret"));
2109        app.tool(
2110            ToolMetadata {
2111                name: "orders".into(),
2112                lookup: Some(ToolLookup {
2113                    by: Some("email".into()),
2114                    required: vec![],
2115                }),
2116                ..Default::default()
2117            },
2118            |_ctx| async move { Ok(json!({})) },
2119        );
2120
2121        let resp = app
2122            .handle(QefroRequest {
2123                protocol_version: "1".into(),
2124                request_id: Uuid::new_v4(),
2125                request_type: "tools.list".into(),
2126                organization_id: None,
2127                conversation_id: None,
2128                channel: None,
2129                identity: None,
2130                tool: None,
2131                parameters: None,
2132                authentication: None,
2133                resume_token: None,
2134                challenge_response: None,
2135                person: None,
2136                platform: None,
2137            })
2138            .await;
2139
2140        match resp {
2141            QefroResponse::ToolsList { tools, .. } => {
2142                assert_eq!(tools.len(), 1);
2143                assert_eq!(
2144                    tools[0].lookup.as_ref().unwrap().required,
2145                    vec!["email".to_string()]
2146                );
2147            }
2148            other => panic!("unexpected {other:?}"),
2149        }
2150    }
2151
2152    fn capabilities_request() -> QefroRequest {
2153        QefroRequest {
2154            protocol_version: "1".into(),
2155            request_id: Uuid::new_v4(),
2156            request_type: "capabilities.list".into(),
2157            organization_id: None,
2158            conversation_id: None,
2159            channel: None,
2160            identity: None,
2161            tool: None,
2162            parameters: None,
2163            authentication: None,
2164            resume_token: None,
2165            challenge_response: None,
2166            person: None,
2167            platform: None,
2168        }
2169    }
2170
2171    fn order_lookup_metadata() -> BusinessFlowMetadata {
2172        BusinessFlowMetadata {
2173            id: "order_lookup".into(),
2174            name: Some("Order Lookup".into()),
2175            description: Some("Lookup customer orders".into()),
2176            category: Some("crm".into()),
2177            tags: vec!["customer".into(), "orders".into()],
2178            intent: vec!["track order".into(), "where is my order".into()],
2179            inputs: vec!["email".into()],
2180            outputs: vec!["customer".into(), "orders".into()],
2181            ..Default::default()
2182        }
2183    }
2184
2185    #[tokio::test]
2186    async fn capabilities_list_advertises_flows() {
2187        let app = Qefro::new(QefroConfig::new("secret"));
2188        app.tool(
2189            ToolMetadata {
2190                name: "lookup_customer".into(),
2191                ..Default::default()
2192            },
2193            |_ctx| async move { Ok(json!({})) },
2194        );
2195        app.flow(order_lookup_metadata())
2196            .expect("flow registers")
2197            .ask("email", "email", "Please enter your email.")
2198            .tool("lookup", "lookup_customer")
2199            .complete("done", None)
2200            .expect("flow builds without error");
2201
2202        let resp = app.handle(capabilities_request()).await;
2203        let value = serde_json::to_value(&resp).expect("serialize");
2204        assert_eq!(value["type"], "capabilities.list");
2205        assert_eq!(value["flows"].as_array().unwrap().len(), 1);
2206        let flow = &value["flows"][0];
2207        // Wrapped { metadata, steps } shape with integer version.
2208        assert_eq!(flow["metadata"]["id"], "order_lookup");
2209        assert_eq!(flow["metadata"]["version"], 1);
2210        assert!(flow["metadata"]["version"].is_number());
2211        // { id, type, config } step model with tool_ref inside config.
2212        assert_eq!(flow["steps"][0]["id"], "email");
2213        assert_eq!(flow["steps"][0]["type"], "ask");
2214        assert_eq!(flow["steps"][0]["config"]["field"], "email");
2215        assert_eq!(flow["steps"][1]["type"], "tool");
2216        assert_eq!(flow["steps"][1]["config"]["tool_ref"], "lookup_customer");
2217        assert_eq!(flow["steps"][2]["type"], "complete");
2218        assert!(flow["steps"][2]["config"].is_object());
2219    }
2220
2221    #[test]
2222    fn duplicate_flow_id_returns_error_no_panic() {
2223        let app = Qefro::new(QefroConfig::new("secret"));
2224        app.flow(order_lookup_metadata()).expect("first registers");
2225        let err = app.flow(order_lookup_metadata()).err().unwrap();
2226        assert_eq!(err, FlowError::DuplicateFlowId("order_lookup".into()));
2227    }
2228
2229    #[test]
2230    fn empty_flow_id_returns_error() {
2231        let app = Qefro::new(QefroConfig::new("secret"));
2232        let err = app
2233            .flow(BusinessFlowMetadata {
2234                id: "   ".into(),
2235                ..Default::default()
2236            })
2237            .err()
2238            .unwrap();
2239        assert_eq!(err, FlowError::EmptyFlowId);
2240    }
2241
2242    #[tokio::test]
2243    async fn capabilities_list_includes_event_triggers_and_handlers() {
2244        let app = Qefro::new(QefroConfig::new("secret"));
2245        app.flow(BusinessFlowMetadata {
2246            id: "abandoned_cart".into(),
2247            name: Some("Abandoned cart".into()),
2248            trigger: Some(FlowTrigger::Event {
2249                event: "shopify.cart.abandoned".into(),
2250                when: None,
2251            }),
2252            ..Default::default()
2253        })
2254        .expect("flow")
2255        .delay("wait", 60)
2256        .complete("done", None)
2257        .expect("build");
2258
2259        app.event(EventHandlerDefinition {
2260            name: "shopify.cart.abandoned".into(),
2261            description: Some("cart abandoned".into()),
2262            cron: None,
2263        })
2264        .expect("event");
2265        app.schedule(EventHandlerDefinition {
2266            name: "nightly_sync".into(),
2267            description: None,
2268            cron: Some("0 2 * * *".into()),
2269        })
2270        .expect("schedule");
2271
2272        let resp = app.handle(capabilities_request()).await;
2273        let value = serde_json::to_value(&resp).expect("serialize");
2274        assert_eq!(value["type"], "capabilities.list");
2275        assert_eq!(
2276            value["flows"][0]["metadata"]["trigger"]["type"],
2277            "event"
2278        );
2279        assert_eq!(
2280            value["flows"][0]["metadata"]["trigger"]["event"],
2281            "shopify.cart.abandoned"
2282        );
2283        assert_eq!(value["events"][0]["name"], "shopify.cart.abandoned");
2284        assert_eq!(value["schedules"][0]["cron"], "0 2 * * *");
2285    }
2286
2287    #[tokio::test]
2288    async fn duplicate_step_id_surfaced_and_flow_excluded() {
2289        let app = Qefro::new(QefroConfig::new("secret"));
2290        let result = app
2291            .flow(order_lookup_metadata())
2292            .expect("flow registers")
2293            .ask("email", "email", "Please enter your email.")
2294            .tool("email", "lookup_customer") // duplicate step id
2295            .complete("done", None);
2296        assert_eq!(
2297            result.unwrap_err(),
2298            FlowError::DuplicateStepId {
2299                flow: "order_lookup".into(),
2300                step: "email".into()
2301            }
2302        );
2303
2304        // Invalid flow is excluded from capabilities.list (never crashes the server).
2305        let resp = app.handle(capabilities_request()).await;
2306        let value = serde_json::to_value(&resp).expect("serialize");
2307        assert_eq!(value["flows"].as_array().unwrap().len(), 0);
2308    }
2309
2310    #[tokio::test]
2311    async fn complete_step_allows_branch_terminal_before_complete() {
2312        let app = Qefro::new(QefroConfig::new("secret"));
2313        app.flow(order_lookup_metadata())
2314            .expect("flow registers")
2315            .ask("ask", "order_id", "Order id?")
2316            .tool("lookup", "order_status_check")
2317            .condition(
2318                "branch",
2319                "order_status_check.found == true",
2320                Some("ok".into()),
2321                Some("missing".into()),
2322            )
2323            .complete_step("missing", Some("Not found.".into()))
2324            .complete("ok", Some("Found.".into()))
2325            .expect("flow builds with a mid-chain complete_step");
2326
2327        let resp = app.handle(capabilities_request()).await;
2328        let value = serde_json::to_value(&resp).expect("serialize");
2329        let steps = value["flows"][0]["steps"].as_array().unwrap();
2330        let kinds: Vec<&str> = steps.iter().map(|s| s["type"].as_str().unwrap()).collect();
2331        assert_eq!(kinds, ["ask", "tool", "condition", "complete", "complete"]);
2332    }
2333
2334    #[tokio::test]
2335    async fn tools_list_unchanged_alongside_flows() {
2336        let app = Qefro::new(QefroConfig::new("secret"));
2337        app.tool(
2338            ToolMetadata {
2339                name: "lookup_customer".into(),
2340                ..Default::default()
2341            },
2342            |_ctx| async move { Ok(json!({})) },
2343        );
2344        app.flow(order_lookup_metadata())
2345            .expect("flow registers")
2346            .tool("lookup", "lookup_customer")
2347            .complete("done", None)
2348            .expect("flow builds");
2349
2350        let resp = app
2351            .handle(QefroRequest {
2352                protocol_version: "1".into(),
2353                request_id: Uuid::new_v4(),
2354                request_type: "tools.list".into(),
2355                organization_id: None,
2356                conversation_id: None,
2357                channel: None,
2358                identity: None,
2359                tool: None,
2360                parameters: None,
2361                authentication: None,
2362                resume_token: None,
2363                challenge_response: None,
2364                person: None,
2365                platform: None,
2366            })
2367            .await;
2368        let value = serde_json::to_value(&resp).expect("serialize");
2369        // Legacy tools.list response carries no `flows` field.
2370        assert_eq!(value["type"], "tools.list");
2371        assert!(value.get("flows").is_none());
2372        assert_eq!(value["tools"].as_array().unwrap().len(), 1);
2373    }
2374
2375    #[test]
2376    fn flow_step_roundtrips_through_wire_shape() {
2377        let step = FlowStep {
2378            id: "lookup".into(),
2379            kind: FlowStepKind::Tool {
2380                tool_ref: "lookup_customer".into(),
2381            },
2382        };
2383        let value = serde_json::to_value(&step).unwrap();
2384        assert_eq!(value["id"], "lookup");
2385        assert_eq!(value["type"], "tool");
2386        assert_eq!(value["config"]["tool_ref"], "lookup_customer");
2387        let back: FlowStep = serde_json::from_value(value).unwrap();
2388        assert_eq!(back, step);
2389    }
2390
2391    #[tokio::test]
2392    async fn customer_hub_resolve_soft_skips_when_disabled() {
2393        let _guard = customer_hub::HUB_ENV_LOCK
2394            .lock()
2395            .unwrap_or_else(|e| e.into_inner());
2396        std::env::set_var("QEFRO_CUSTOMER_HUB_ENABLED", "false");
2397        std::env::set_var("QEFRO_CUSTOMER_HUB_OPTIONAL", "true");
2398        let app = Qefro::new(QefroConfig::new("secret"));
2399        app.tool(
2400            ToolMetadata {
2401                name: "hub_probe".into(),
2402                auth: ToolAuthMode::None,
2403                ..Default::default()
2404            },
2405            |ctx| async move {
2406                let api = ctx.customer_api().unwrap();
2407                let out = api.resolve(Some(json!({"phone_number": "+1"}))).await?;
2408                Ok(json!({ "customer": out }))
2409            },
2410        );
2411        let resp = app
2412            .handle(QefroRequest {
2413                protocol_version: "1".into(),
2414                request_id: Uuid::new_v4(),
2415                request_type: "tool.invoke".into(),
2416                organization_id: None,
2417                conversation_id: None,
2418                channel: None,
2419                identity: None,
2420                tool: Some("hub_probe".into()),
2421                parameters: Some(json!({})),
2422                authentication: None,
2423                resume_token: None,
2424                challenge_response: None,
2425                person: None,
2426                platform: None,
2427            })
2428            .await;
2429        let value = serde_json::to_value(&resp).unwrap();
2430        assert_eq!(value["type"], "result");
2431        assert!(value["output"]["customer"].is_null());
2432        std::env::remove_var("QEFRO_CUSTOMER_HUB_ENABLED");
2433        std::env::remove_var("QEFRO_CUSTOMER_HUB_OPTIONAL");
2434    }
2435
2436    #[tokio::test]
2437    async fn customer_hub_person_seed_exposes_properties() {
2438        let app = Qefro::new(QefroConfig::new("secret"));
2439        app.tool(
2440            ToolMetadata {
2441                name: "who".into(),
2442                auth: ToolAuthMode::None,
2443                ..Default::default()
2444            },
2445            |ctx| async move {
2446                let api = ctx.customer_api().unwrap();
2447                Ok(json!({
2448                    "id": api.id().await,
2449                    "phone_number": api.phone_number().await,
2450                    "display_name": api.display_name().await,
2451                }))
2452            },
2453        );
2454        let resp = app
2455            .handle(QefroRequest {
2456                protocol_version: "1".into(),
2457                request_id: Uuid::new_v4(),
2458                request_type: "tool.invoke".into(),
2459                organization_id: None,
2460                conversation_id: None,
2461                channel: None,
2462                identity: None,
2463                tool: Some("who".into()),
2464                parameters: Some(json!({})),
2465                authentication: None,
2466                resume_token: None,
2467                challenge_response: None,
2468                person: Some(json!({
2469                    "id": "cust-1",
2470                    "phone": "+1999",
2471                    "name": "Sam",
2472                })),
2473                platform: None,
2474            })
2475            .await;
2476        let value = serde_json::to_value(&resp).unwrap();
2477        assert_eq!(value["output"]["id"], "cust-1");
2478        assert_eq!(value["output"]["phone_number"], "+1999");
2479        assert_eq!(value["output"]["display_name"], "Sam");
2480    }
2481
2482    #[tokio::test]
2483    async fn customer_hub_timeline_noop_when_optional_no_customer() {
2484        let _guard = customer_hub::HUB_ENV_LOCK
2485            .lock()
2486            .unwrap_or_else(|e| e.into_inner());
2487        std::env::set_var("QEFRO_CUSTOMER_HUB_ENABLED", "true");
2488        std::env::set_var("QEFRO_CUSTOMER_HUB_OPTIONAL", "true");
2489        let app = Qefro::new(QefroConfig::new("secret"));
2490        app.tool(
2491            ToolMetadata {
2492                name: "hub_side".into(),
2493                auth: ToolAuthMode::None,
2494                ..Default::default()
2495            },
2496            |ctx| async move {
2497                ctx.timeline
2498                    .append(json!({"event_type": "x.y"}))
2499                    .await?;
2500                ctx.membership.attach(None).await?;
2501                ctx.consent
2502                    .grant(json!({"purpose": "marketing"}))
2503                    .await?;
2504                Ok(json!({"ok": true}))
2505            },
2506        );
2507        let resp = app
2508            .handle(QefroRequest {
2509                protocol_version: "1".into(),
2510                request_id: Uuid::new_v4(),
2511                request_type: "tool.invoke".into(),
2512                organization_id: None,
2513                conversation_id: None,
2514                channel: None,
2515                identity: None,
2516                tool: Some("hub_side".into()),
2517                parameters: Some(json!({})),
2518                authentication: None,
2519                resume_token: None,
2520                challenge_response: None,
2521                person: None,
2522                platform: None,
2523            })
2524            .await;
2525        let value = serde_json::to_value(&resp).unwrap();
2526        assert_eq!(value["type"], "result");
2527        assert_eq!(value["output"]["ok"], true);
2528        std::env::remove_var("QEFRO_CUSTOMER_HUB_ENABLED");
2529        std::env::remove_var("QEFRO_CUSTOMER_HUB_OPTIONAL");
2530    }
2531}