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