1use std::collections::HashMap;
2use std::sync::Arc;
3
4use anyhow::Result;
5use async_trait::async_trait;
6use chrono::Utc;
7use hmac::{Hmac, Mac};
8use serde::{Deserialize, Serialize};
9use serde_json::{json, Value};
10use sha2::Sha256;
11use uuid::Uuid;
12
13type HmacSha256 = Hmac<Sha256>;
14
15pub const SDK_NAME: &str = "qefro-backend-sdk";
17pub const SDK_VERSION: &str = env!("CARGO_PKG_VERSION");
19
20type ToolHandler = Arc<dyn Fn(ToolContext) -> ToolFuture + Send + Sync>;
21type ToolFuture = std::pin::Pin<Box<dyn std::future::Future<Output = Result<Value>> + Send>>;
22
23#[derive(Debug, Clone)]
24pub struct QefroConfig {
25 pub signing_secret: String,
26 pub protocol_version: String,
27 pub max_timestamp_skew_secs: i64,
28}
29
30impl QefroConfig {
31 pub fn new(signing_secret: impl Into<String>) -> Self {
32 Self {
33 signing_secret: signing_secret.into(),
34 protocol_version: "1".into(),
35 max_timestamp_skew_secs: 300,
36 }
37 }
38}
39
40#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
41#[serde(rename_all = "snake_case")]
42pub enum ToolAuthMode {
43 None,
44 Optional,
45 Required,
46}
47
48impl Default for ToolAuthMode {
49 fn default() -> Self {
50 Self::Optional
51 }
52}
53
54#[derive(Debug, Clone, Serialize, Deserialize, Default)]
55pub struct ToolMetadata {
56 pub name: String,
57 #[serde(default)]
58 pub description: Option<String>,
59 #[serde(default)]
60 pub input_schema: Option<Value>,
61 #[serde(default)]
62 pub authentication_methods: Vec<String>,
63 #[serde(default)]
64 pub auth: ToolAuthMode,
65 #[serde(default)]
66 pub permissions: Vec<String>,
67 #[serde(default)]
68 pub timeout: Option<u64>,
69 #[serde(default)]
70 pub default_auth_method: Option<String>,
71}
72
73#[derive(Debug, Clone, Serialize, Deserialize)]
74pub struct RegisteredTool {
75 pub name: String,
76 #[serde(default)]
77 pub description: Option<String>,
78 #[serde(default)]
79 pub input_schema: Option<Value>,
80 #[serde(default)]
81 pub authentication_methods: Vec<String>,
82 #[serde(default)]
83 pub auth: ToolAuthMode,
84 #[serde(default)]
85 pub permissions: Vec<String>,
86 #[serde(default)]
87 pub timeout: Option<u64>,
88}
89
90#[derive(Debug, Clone, Serialize, Deserialize)]
91pub struct AuthenticationContextPayload {
92 #[serde(rename = "type")]
93 pub credential_type: Option<String>,
94 pub access_token: Option<String>,
95 pub credential: Option<String>,
96 pub refresh_token: Option<String>,
97 pub expires_in: Option<i64>,
98 pub customer_id: Option<String>,
99}
100
101#[derive(Debug, Clone, Serialize, Deserialize)]
102pub struct ChallengePayload {
103 #[serde(rename = "type")]
104 pub challenge_type: String,
105 pub message: String,
106 pub destination_hint: Option<String>,
107 pub login_url: Option<String>,
108}
109
110#[derive(Debug, Clone, Serialize, Deserialize)]
111pub struct QefroRequest {
112 pub protocol_version: String,
113 pub request_id: Uuid,
114 #[serde(rename = "type")]
115 pub request_type: String,
116 pub organization_id: Option<Uuid>,
117 pub conversation_id: Option<Uuid>,
118 pub channel: Option<String>,
119 pub identity: Option<Value>,
120 pub tool: Option<String>,
121 pub parameters: Option<Value>,
122 pub authentication: Option<Value>,
123 pub resume_token: Option<String>,
124 pub challenge_response: Option<String>,
125}
126
127#[derive(Debug, Clone, Serialize, Deserialize)]
128#[serde(tag = "type", rename_all = "snake_case")]
129pub enum QefroResponse {
130 Pong {
131 protocol_version: String,
132 sdk_version: String,
133 },
134 #[serde(rename = "tools.list")]
135 ToolsList {
136 tools: Vec<RegisteredTool>,
137 protocol_version: String,
138 sdk_version: String,
139 },
140 Result {
141 output: Value,
142 authentication_context: Option<AuthenticationContextPayload>,
143 },
144 Challenge {
145 resume_token: String,
146 challenge: ChallengePayload,
147 },
148 Error {
149 code: String,
150 message: String,
151 },
152}
153
154#[derive(Debug, Clone)]
155struct PendingInvocation {
156 tool: String,
157 conversation_id: Uuid,
158 parameters: Value,
159 identity: Option<Value>,
160 channel: Option<String>,
161}
162
163#[derive(Debug, Clone)]
164struct StoredAuth {
165 customer: Value,
166 auth: AuthenticationContextPayload,
167 expires_at_epoch_ms: i64,
168}
169
170#[derive(Clone)]
171pub struct ToolContext {
172 pub identity: Value,
173 pub parameters: Value,
174 pub conversation_id: Uuid,
175 pub channel: Option<String>,
176 pub authentication: Option<Value>,
177 pub auth_response: Option<String>,
178 pub customer: Option<Value>,
179}
180
181#[derive(Debug, Clone)]
182pub enum AuthOutcome {
183 Success {
184 customer: Value,
185 auth: AuthenticationContextPayload,
186 },
187 Challenge(ChallengePayload),
188 Denied,
189 NotFound,
190}
191
192#[derive(Debug, Clone)]
193pub struct CustomerLookupContext {
194 pub identity: Value,
195 pub parameters: Value,
196 pub conversation_id: Uuid,
197 pub channel: Option<String>,
198}
199
200#[derive(Debug, Clone)]
201pub struct CustomerAuthorizeContext {
202 pub customer: Value,
203 pub method: Option<String>,
204 pub response: Option<String>,
205 pub identity: Value,
206 pub parameters: Value,
207 pub conversation_id: Uuid,
208 pub channel: Option<String>,
209}
210
211#[async_trait]
212pub trait CustomerProvider: Send + Sync {
213 async fn lookup(&self, ctx: &CustomerLookupContext) -> Result<Option<Value>>;
214 async fn authorize(&self, ctx: &CustomerAuthorizeContext) -> Result<AuthOutcome>;
215}
216
217#[derive(Clone)]
218struct ToolRegistration {
219 metadata: ToolMetadata,
220 handler: ToolHandler,
221}
222
223#[derive(Debug, Clone)]
224pub struct ListenOptions {
225 pub port: u16,
226 pub host: Option<String>,
227 pub path: Option<String>,
228}
229
230#[derive(Debug, Clone)]
231pub struct ListenHandle {
232 pub url: String,
233}
234
235pub struct Qefro {
236 config: QefroConfig,
237 tools: HashMap<String, ToolRegistration>,
238 pending: HashMap<String, PendingInvocation>,
239 auth_by_conversation: HashMap<Uuid, StoredAuth>,
240 customer_provider: Option<Arc<dyn CustomerProvider>>,
241}
242
243impl Qefro {
244 pub fn new(config: QefroConfig) -> Self {
245 Self {
246 config,
247 tools: HashMap::new(),
248 pending: HashMap::new(),
249 auth_by_conversation: HashMap::new(),
250 customer_provider: None,
251 }
252 }
253
254 pub fn customer<P>(&mut self, provider: P) -> &mut Self
255 where
256 P: CustomerProvider + 'static,
257 {
258 self.customer_provider = Some(Arc::new(provider));
259 self
260 }
261
262 pub fn tool<F, Fut>(&mut self, metadata: ToolMetadata, handler: F)
263 where
264 F: Fn(ToolContext) -> Fut + Send + Sync + 'static,
265 Fut: std::future::Future<Output = Result<Value>> + Send + 'static,
266 {
267 let name = metadata.name.clone();
268 self.tools.insert(
269 name,
270 ToolRegistration {
271 metadata,
272 handler: Arc::new(move |ctx| Box::pin(handler(ctx))),
273 },
274 );
275 }
276
277 pub async fn listen(&self, options: ListenOptions) -> Result<ListenHandle> {
278 let host = options.host.unwrap_or_else(|| "0.0.0.0".to_string());
279 let path = options.path.unwrap_or_else(|| "/qefro".to_string());
280 Ok(ListenHandle {
281 url: format!("http://{host}:{}{path}", options.port),
282 })
283 }
284
285 pub fn verify_signature(&self, signature: &str, timestamp: i64, body: &str) -> bool {
286 let now = Utc::now().timestamp();
287 if (now - timestamp).abs() > self.config.max_timestamp_skew_secs {
288 return false;
289 }
290 let payload = format!("v1:{timestamp}:{body}");
291 let mut mac = HmacSha256::new_from_slice(self.config.signing_secret.as_bytes())
292 .expect("HMAC accepts any key length");
293 mac.update(payload.as_bytes());
294 let expected = format!("v1={}", hex::encode(mac.finalize().into_bytes()));
295 expected == signature
296 }
297
298 pub async fn handle(&mut self, request: QefroRequest) -> QefroResponse {
299 if request.protocol_version != self.config.protocol_version {
300 return QefroResponse::Error {
301 code: "protocol_mismatch".into(),
302 message: "Unsupported protocol version".into(),
303 };
304 }
305
306 match request.request_type.as_str() {
307 "ping" => QefroResponse::Pong {
308 protocol_version: self.config.protocol_version.clone(),
309 sdk_version: SDK_VERSION.into(),
310 },
311 "tools.list" => QefroResponse::ToolsList {
312 tools: self
313 .tools
314 .values()
315 .map(|r| RegisteredTool {
316 name: r.metadata.name.clone(),
317 description: r.metadata.description.clone(),
318 input_schema: r.metadata.input_schema.clone(),
319 authentication_methods: r.metadata.authentication_methods.clone(),
320 auth: r.metadata.auth,
321 permissions: r.metadata.permissions.clone(),
322 timeout: r.metadata.timeout,
323 })
324 .collect(),
325 protocol_version: self.config.protocol_version.clone(),
326 sdk_version: SDK_VERSION.into(),
327 },
328 "tool.invoke" => {
329 self
330 .invoke(
331 request.tool,
332 request.parameters.unwrap_or_else(|| json!({})),
333 request.conversation_id.unwrap_or_else(Uuid::new_v4),
334 request.identity,
335 request.channel,
336 request.authentication,
337 None,
338 )
339 .await
340 }
341 "tool.resume" => {
342 let Some(resume_token) = request.resume_token else {
343 return QefroResponse::Error {
344 code: "invalid_request".into(),
345 message: "resume_token is required".into(),
346 };
347 };
348 let Some(challenge_response) = request.challenge_response else {
349 return QefroResponse::Error {
350 code: "invalid_request".into(),
351 message: "challenge_response is required".into(),
352 };
353 };
354 let Some(pending) = self.pending.remove(&resume_token) else {
355 return QefroResponse::Error {
356 code: "not_found".into(),
357 message: "resume token not found".into(),
358 };
359 };
360 self
361 .invoke(
362 Some(pending.tool),
363 pending.parameters,
364 pending.conversation_id,
365 pending.identity,
366 pending.channel,
367 request.authentication,
368 Some(challenge_response),
369 )
370 .await
371 }
372 _ => QefroResponse::Error {
373 code: "invalid_request".into(),
374 message: "Unsupported request type".into(),
375 },
376 }
377 }
378
379 async fn invoke(
380 &mut self,
381 tool: Option<String>,
382 parameters: Value,
383 conversation_id: Uuid,
384 identity: Option<Value>,
385 channel: Option<String>,
386 authentication: Option<Value>,
387 auth_response: Option<String>,
388 ) -> QefroResponse {
389 let Some(tool_name) = tool else {
390 return QefroResponse::Error {
391 code: "invalid_request".into(),
392 message: "tool is required".into(),
393 };
394 };
395
396 let Some(registration) = self.tools.get(&tool_name).cloned() else {
397 return QefroResponse::Error {
398 code: "not_found".into(),
399 message: format!("Unknown tool: {tool_name}"),
400 };
401 };
402
403 let mut current_customer = self
404 .auth_by_conversation
405 .get(&conversation_id)
406 .filter(|a| a.expires_at_epoch_ms > Utc::now().timestamp_millis())
407 .map(|a| a.customer.clone());
408
409 if registration.metadata.auth == ToolAuthMode::Required {
410 match self
411 .ensure_authorized_customer(
412 &tool_name,
413 ®istration.metadata,
414 ¶meters,
415 conversation_id,
416 identity.clone(),
417 channel.clone(),
418 auth_response.clone(),
419 )
420 .await
421 {
422 Ok(customer) => {
423 current_customer = Some(customer);
424 }
425 Err(resp) => return resp,
426 }
427 }
428
429 let ctx = ToolContext {
430 identity: identity.clone().unwrap_or_else(|| json!({})),
431 parameters: parameters.clone(),
432 conversation_id,
433 channel,
434 authentication,
435 auth_response,
436 customer: current_customer,
437 };
438
439 match (registration.handler)(ctx).await {
440 Ok(output) => {
441 let auth = self
442 .auth_by_conversation
443 .get(&conversation_id)
444 .filter(|v| v.expires_at_epoch_ms > Utc::now().timestamp_millis())
445 .map(|v| v.auth.clone());
446 QefroResponse::Result {
447 output,
448 authentication_context: auth,
449 }
450 }
451 Err(e) => QefroResponse::Error {
452 code: "internal_error".into(),
453 message: e.to_string(),
454 },
455 }
456 }
457
458 async fn ensure_authorized_customer(
459 &mut self,
460 tool: &str,
461 metadata: &ToolMetadata,
462 parameters: &Value,
463 conversation_id: Uuid,
464 identity: Option<Value>,
465 channel: Option<String>,
466 auth_response: Option<String>,
467 ) -> std::result::Result<Value, QefroResponse> {
468 if let Some(existing) = self
469 .auth_by_conversation
470 .get(&conversation_id)
471 .filter(|a| a.expires_at_epoch_ms > Utc::now().timestamp_millis())
472 {
473 return Ok(existing.customer.clone());
474 }
475
476 let Some(provider) = self.customer_provider.as_ref() else {
477 return Err(QefroResponse::Error {
478 code: "configuration_error".into(),
479 message: "Tool requires customer provider. Configure app.customer(...)".into(),
480 });
481 };
482
483 let lookup = CustomerLookupContext {
484 identity: identity.clone().unwrap_or_else(|| json!({})),
485 parameters: parameters.clone(),
486 conversation_id,
487 channel: channel.clone(),
488 };
489
490 let Some(customer) = provider.lookup(&lookup).await.map_err(|e| QefroResponse::Error {
491 code: "internal_error".into(),
492 message: e.to_string(),
493 })? else {
494 return Err(QefroResponse::Error {
495 code: "customer_not_found".into(),
496 message: "Customer not found".into(),
497 });
498 };
499
500 let authorize = CustomerAuthorizeContext {
501 customer,
502 method: metadata.default_auth_method.clone(),
503 response: auth_response,
504 identity: identity.unwrap_or_else(|| json!({})),
505 parameters: parameters.clone(),
506 conversation_id,
507 channel,
508 };
509
510 let outcome = provider
511 .authorize(&authorize)
512 .await
513 .map_err(|e| QefroResponse::Error {
514 code: "internal_error".into(),
515 message: e.to_string(),
516 })?;
517
518 self.require_authentication(
519 conversation_id,
520 outcome,
521 tool,
522 parameters.clone(),
523 Some(authorize.identity),
524 authorize.channel,
525 )
526 }
527
528 pub fn require_authentication(
529 &mut self,
530 conversation_id: Uuid,
531 outcome: AuthOutcome,
532 tool: &str,
533 parameters: Value,
534 identity: Option<Value>,
535 channel: Option<String>,
536 ) -> std::result::Result<Value, QefroResponse> {
537 match outcome {
538 AuthOutcome::Success { customer, auth } => {
539 let ttl = auth.expires_in.unwrap_or(900).max(1);
540 self.auth_by_conversation.insert(
541 conversation_id,
542 StoredAuth {
543 customer: customer.clone(),
544 auth,
545 expires_at_epoch_ms: Utc::now().timestamp_millis() + ttl * 1000,
546 },
547 );
548 Ok(customer)
549 }
550 AuthOutcome::Challenge(challenge) => {
551 let resume_token = Uuid::new_v4().to_string();
552 self.pending.insert(
553 resume_token.clone(),
554 PendingInvocation {
555 tool: tool.to_string(),
556 conversation_id,
557 parameters,
558 identity,
559 channel,
560 },
561 );
562 Err(QefroResponse::Challenge {
563 resume_token,
564 challenge,
565 })
566 }
567 AuthOutcome::Denied => Err(QefroResponse::Error {
568 code: "denied".into(),
569 message: "Authentication denied".into(),
570 }),
571 AuthOutcome::NotFound => Err(QefroResponse::Error {
572 code: "customer_not_found".into(),
573 message: "Customer not found".into(),
574 }),
575 }
576 }
577}