langchain-trigger-server 0.3.22__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,676 @@
1
+ """FastAPI application for trigger server."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ import os
7
+ from collections.abc import Callable
8
+ from contextlib import asynccontextmanager
9
+ from datetime import UTC, datetime, timedelta
10
+ from typing import Any
11
+
12
+ import jwt
13
+ from fastapi import Depends, FastAPI, HTTPException, Request
14
+ from langchain_auth.client import Client
15
+ from langgraph_sdk import get_client
16
+ from starlette.middleware.base import BaseHTTPMiddleware
17
+ from starlette.responses import Response
18
+ from starlette.types import Lifespan
19
+
20
+ from .core import TriggerType
21
+ from .cron_manager import CronTriggerManager
22
+ from .database import TriggerDatabaseInterface
23
+ from .decorators import TriggerTemplate
24
+
25
+ logger = logging.getLogger(__name__)
26
+
27
+
28
+ def get_x_service_jwt_token(
29
+ payload: dict[str, Any] | None = None, expiration_seconds: int = 60 * 60
30
+ ) -> str:
31
+ exp_datetime = datetime.now(tz=UTC) + timedelta(seconds=expiration_seconds)
32
+ exp = int(exp_datetime.timestamp())
33
+
34
+ payload = payload or {}
35
+ payload = {
36
+ "sub": "unspecified",
37
+ "exp": exp,
38
+ **payload,
39
+ }
40
+
41
+ secret = os.environ["X_SERVICE_AUTH_JWT_SECRET"]
42
+
43
+ return jwt.encode(
44
+ payload,
45
+ secret,
46
+ algorithm="HS256",
47
+ )
48
+
49
+
50
+ class AuthenticationMiddleware(BaseHTTPMiddleware):
51
+ """Middleware to handle authentication for API endpoints."""
52
+
53
+ def __init__(self, app, auth_handler: Callable):
54
+ super().__init__(app)
55
+ self.auth_handler = auth_handler
56
+
57
+ async def dispatch(self, request: Request, call_next):
58
+ # Skip auth for webhooks, health/root endpoints, and OPTIONS requests
59
+ if (
60
+ request.url.path.startswith("/v1/triggers/webhooks/")
61
+ or request.url.path in ["/", "/health"]
62
+ or request.method == "OPTIONS"
63
+ ):
64
+ return await call_next(request)
65
+
66
+ try:
67
+ identity = await self.auth_handler({}, dict(request.headers))
68
+ if (
69
+ not identity
70
+ or not identity.get("identity")
71
+ or not identity.get("tenant_id")
72
+ ):
73
+ logger.error(
74
+ f"Authentication failed: missing required fields (identity={bool(identity.get('identity') if identity else None)}, tenant_id={bool(identity.get('tenant_id') if identity else None)})"
75
+ )
76
+ return Response(
77
+ content='{"detail": "Authentication required - identity and tenant_id must be provided"}',
78
+ status_code=401,
79
+ media_type="application/json",
80
+ )
81
+ request.state.current_user = identity
82
+
83
+ except Exception as e:
84
+ logger.error(f"Authentication middleware error: {e}")
85
+ return Response(
86
+ content='{"detail": "Authentication failed"}',
87
+ status_code=401,
88
+ media_type="application/json",
89
+ )
90
+
91
+ return await call_next(request)
92
+
93
+
94
+ def get_current_user(request: Request) -> dict[str, Any]:
95
+ """FastAPI dependency to get the current authenticated user."""
96
+ if not hasattr(request.state, "current_user"):
97
+ raise HTTPException(status_code=401, detail="Authentication required")
98
+ return request.state.current_user
99
+
100
+
101
+ class TriggerServer:
102
+ """FastAPI application for trigger webhooks."""
103
+
104
+ def __init__(
105
+ self,
106
+ auth_handler: Callable,
107
+ database: TriggerDatabaseInterface,
108
+ lifespan: Lifespan | None = None,
109
+ ):
110
+ # Configure uvicorn logging to use consistent formatting
111
+ self._configure_uvicorn_logging()
112
+
113
+ self.database = database
114
+ self.auth_handler = auth_handler
115
+
116
+ # LangGraph configuration
117
+ self.langgraph_api_url = os.getenv("LANGGRAPH_API_URL")
118
+ self.trigger_server_auth_api_url = os.getenv("TRIGGER_SERVER_HOST_API_URL")
119
+
120
+ if not self.langgraph_api_url:
121
+ raise ValueError("LANGGRAPH_API_URL environment variable is required")
122
+
123
+ self.langgraph_api_url = self.langgraph_api_url.rstrip("/")
124
+
125
+ # Initialize LangGraph SDK client
126
+ self.langgraph_client = get_client(url=self.langgraph_api_url, api_key=None)
127
+ logger.info(
128
+ f"✓ LangGraph client initialized with URL: {self.langgraph_api_url}"
129
+ )
130
+
131
+ # Initialize LangChain auth client
132
+ langchain_api_key = os.getenv("LANGCHAIN_API_KEY")
133
+ if langchain_api_key:
134
+ self.langchain_auth_client = Client(
135
+ api_key=langchain_api_key, api_url=self.trigger_server_auth_api_url
136
+ )
137
+ logger.info("✓ LangChain auth client initialized")
138
+ else:
139
+ self.langchain_auth_client = None
140
+ logger.warning(
141
+ "LANGCHAIN_API_KEY not found - OAuth token injection disabled"
142
+ )
143
+
144
+ self.triggers: list[TriggerTemplate] = []
145
+
146
+ # Initialize CronTriggerManager
147
+ self.cron_manager = CronTriggerManager(self)
148
+
149
+ # Create merged lifespan that includes cron manager lifecycle
150
+ merged_lifespan = self._create_lifespan(lifespan)
151
+
152
+ self.app = FastAPI(
153
+ title="Triggers Server",
154
+ description="Event-driven triggers framework",
155
+ version="0.1.0",
156
+ lifespan=merged_lifespan,
157
+ )
158
+
159
+ # Setup authentication middleware
160
+ self.app.add_middleware(AuthenticationMiddleware, auth_handler=auth_handler)
161
+
162
+ # Setup routes
163
+ self._setup_routes()
164
+
165
+ def _configure_uvicorn_logging(self) -> None:
166
+ """Configure uvicorn loggers to use consistent formatting for production deployments."""
167
+ formatter = logging.Formatter("%(levelname)s: %(name)s - %(message)s")
168
+
169
+ # Configure uvicorn access logger
170
+ uvicorn_access_logger = logging.getLogger("uvicorn.access")
171
+ uvicorn_access_logger.handlers.clear()
172
+ access_handler = logging.StreamHandler()
173
+ access_handler.setFormatter(formatter)
174
+ uvicorn_access_logger.addHandler(access_handler)
175
+
176
+ # Configure uvicorn error logger
177
+ uvicorn_error_logger = logging.getLogger("uvicorn.error")
178
+ uvicorn_error_logger.handlers.clear()
179
+ error_handler = logging.StreamHandler()
180
+ error_handler.setFormatter(formatter)
181
+ uvicorn_error_logger.addHandler(error_handler)
182
+
183
+ # Configure uvicorn main logger
184
+ uvicorn_logger = logging.getLogger("uvicorn")
185
+ uvicorn_logger.handlers.clear()
186
+ main_handler = logging.StreamHandler()
187
+ main_handler.setFormatter(formatter)
188
+ uvicorn_logger.addHandler(main_handler)
189
+
190
+ def _create_lifespan(self, existing_lifespan: Lifespan | None) -> Lifespan:
191
+ """Create a lifespan context manager that handles cron manager lifecycle."""
192
+ cron_manager = self.cron_manager
193
+
194
+ @asynccontextmanager
195
+ async def lifespan(app: FastAPI):
196
+ await cron_manager.start()
197
+ if existing_lifespan:
198
+ logger.info("Starting existing lifespan")
199
+ async with existing_lifespan(app):
200
+ yield
201
+ else:
202
+ yield
203
+ await cron_manager.shutdown()
204
+
205
+ return lifespan
206
+
207
+ def add_trigger(self, trigger: TriggerTemplate) -> None:
208
+ """Add a trigger template to the app."""
209
+ # Check for duplicate IDs
210
+ if any(t.id == trigger.id for t in self.triggers):
211
+ raise ValueError(f"Trigger with id '{trigger.id}' already exists")
212
+
213
+ self.triggers.append(trigger)
214
+
215
+ if trigger.trigger_handler:
216
+
217
+ async def handler_endpoint(request: Request) -> dict[str, Any]:
218
+ return await self._handle_request(trigger, request)
219
+
220
+ handler_path = f"/v1/triggers/webhooks/{trigger.id}"
221
+ self.app.post(handler_path)(handler_endpoint)
222
+ logger.info(f"Added handler route: POST {handler_path}")
223
+
224
+ logger.info(
225
+ f"Registered trigger template in memory: {trigger.display_name} ({trigger.id})"
226
+ )
227
+
228
+ def add_triggers(self, triggers: list[TriggerTemplate]) -> None:
229
+ """Add multiple triggers."""
230
+ for trigger in triggers:
231
+ self.add_trigger(trigger)
232
+
233
+ def _setup_routes(self) -> None:
234
+ """Setup built-in API routes."""
235
+
236
+ @self.app.get("/")
237
+ async def root() -> dict[str, str]:
238
+ return {"message": "Triggers Server", "version": "0.1.0"}
239
+
240
+ @self.app.get("/health")
241
+ async def health() -> dict[str, str]:
242
+ return {"status": "healthy"}
243
+
244
+ @self.app.get("/v1/triggers")
245
+ async def api_list_triggers() -> dict[str, Any]:
246
+ """List available trigger templates from in-memory registry."""
247
+ trigger_list = []
248
+ for trigger in self.triggers:
249
+ trigger_list.append(
250
+ {
251
+ "id": trigger.id,
252
+ "displayName": trigger.display_name,
253
+ "description": trigger.description,
254
+ "path": "/v1/triggers/registrations",
255
+ "method": "POST",
256
+ "payloadSchema": trigger.registration_model.model_json_schema(),
257
+ "integration": trigger.integration,
258
+ "authProvider": trigger.auth_provider,
259
+ "scopes": trigger.scopes,
260
+ }
261
+ )
262
+
263
+ return {"success": True, "data": trigger_list}
264
+
265
+ @self.app.get("/v1/triggers/registrations")
266
+ async def api_list_registrations(
267
+ current_user: dict[str, Any] = Depends(get_current_user),
268
+ ) -> dict[str, Any]:
269
+ """List user's trigger registrations (user and tenant-scoped)."""
270
+ try:
271
+ user_id = current_user["identity"]
272
+ tenant_id = current_user["tenant_id"]
273
+
274
+ # Get user's trigger registrations with linked agents in a single query
275
+ user_registrations = (
276
+ await self.database.get_user_trigger_registrations_with_agents(
277
+ user_id, tenant_id
278
+ )
279
+ )
280
+
281
+ # Format response to match expected structure
282
+ registrations = []
283
+ for reg in user_registrations:
284
+ registrations.append(
285
+ {
286
+ "id": reg["id"],
287
+ "user_id": reg["user_id"],
288
+ "template_id": reg.get("template_id"),
289
+ "resource": reg["resource"],
290
+ "linked_agent_ids": reg.get("linked_agent_ids", []),
291
+ "created_at": reg["created_at"],
292
+ }
293
+ )
294
+
295
+ return {"success": True, "data": registrations}
296
+
297
+ except HTTPException:
298
+ raise
299
+ except Exception as e:
300
+ logger.error(f"Error listing registrations: {e}")
301
+ raise HTTPException(status_code=500, detail=str(e))
302
+
303
+ @self.app.post("/v1/triggers/registrations")
304
+ async def api_create_registration(
305
+ request: Request, current_user: dict[str, Any] = Depends(get_current_user)
306
+ ) -> dict[str, Any]:
307
+ """Create a new trigger registration."""
308
+ try:
309
+ payload = await request.json()
310
+ logger.info(f"Registration payload received: {payload}")
311
+
312
+ user_id = current_user["identity"]
313
+ tenant_id = current_user["tenant_id"]
314
+ trigger_id = payload.get("type")
315
+ if not trigger_id:
316
+ raise HTTPException(
317
+ status_code=400, detail="Missing required field: type"
318
+ )
319
+
320
+ trigger = next((t for t in self.triggers if t.id == trigger_id), None)
321
+ if not trigger:
322
+ raise HTTPException(
323
+ status_code=400, detail=f"Unknown trigger type: {trigger_id}"
324
+ )
325
+
326
+ # Parse payload into registration model first
327
+ try:
328
+ registration_instance = trigger.registration_model(**payload)
329
+ except Exception as e:
330
+ raise HTTPException(
331
+ status_code=400, detail=f"Invalid payload for trigger: {str(e)}"
332
+ )
333
+
334
+ # Check for duplicate registration based on resource data within this user's tenant scope
335
+ resource_dict = registration_instance.model_dump()
336
+ existing_registration = (
337
+ await self.database.find_user_registration_by_resource(
338
+ user_id=user_id,
339
+ tenant_id=tenant_id,
340
+ template_id=trigger.id,
341
+ resource_data=resource_dict,
342
+ )
343
+ )
344
+
345
+ if existing_registration:
346
+ raise HTTPException(
347
+ status_code=400,
348
+ detail=f"You already have a registration with this configuration for trigger type '{trigger.id}'. Registration ID: {existing_registration.get('id')}",
349
+ )
350
+ result = await trigger.registration_handler(
351
+ request, user_id, registration_instance
352
+ )
353
+
354
+ # Check if handler requested to skip registration (e.g., for OAuth or URL verification)
355
+ if not result.create_registration:
356
+ logger.info(
357
+ "Registration handler requested to skip database creation"
358
+ )
359
+ import json
360
+
361
+ from fastapi import Response
362
+
363
+ return Response(
364
+ content=json.dumps(result.response_body),
365
+ status_code=result.status_code,
366
+ media_type="application/json",
367
+ )
368
+
369
+ resource_dict = registration_instance.model_dump()
370
+ resource_dict.update(result.resource)
371
+
372
+ registration = await self.database.create_trigger_registration(
373
+ user_id=user_id,
374
+ tenant_id=tenant_id,
375
+ template_id=trigger.id,
376
+ resource=resource_dict,
377
+ metadata=result.metadata,
378
+ )
379
+
380
+ if not registration:
381
+ raise HTTPException(
382
+ status_code=500, detail="Failed to create trigger registration"
383
+ )
384
+
385
+ # Reload cron manager to pick up any new cron registrations
386
+ await self.cron_manager.reload_from_database()
387
+
388
+ # Return registration result
389
+ return {
390
+ "success": True,
391
+ "data": registration,
392
+ "metadata": result.metadata,
393
+ }
394
+
395
+ except HTTPException:
396
+ raise
397
+ except Exception as e:
398
+ logger.exception(f"Error creating trigger registration: {e}")
399
+ raise HTTPException(status_code=500, detail=str(e))
400
+
401
+ @self.app.delete("/v1/triggers/registrations/{registration_id}")
402
+ async def api_delete_registration(
403
+ registration_id: str,
404
+ current_user: dict[str, Any] = Depends(get_current_user),
405
+ ) -> dict[str, Any]:
406
+ """Delete a trigger registration."""
407
+ try:
408
+ user_id = current_user["identity"]
409
+ tenant_id = current_user["tenant_id"]
410
+ success = await self.database.delete_trigger_registration(
411
+ registration_id, user_id, tenant_id
412
+ )
413
+ if not success:
414
+ raise HTTPException(
415
+ status_code=500, detail="Failed to delete trigger registration"
416
+ )
417
+ return {"success": True}
418
+
419
+ except HTTPException:
420
+ raise
421
+ except Exception as e:
422
+ logger.error(f"Error deleting trigger registration: {e}")
423
+ raise HTTPException(status_code=500, detail=str(e))
424
+
425
+ @self.app.get("/v1/triggers/registrations/{registration_id}/agents")
426
+ async def api_list_registration_agents(
427
+ registration_id: str,
428
+ current_user: dict[str, Any] = Depends(get_current_user),
429
+ ) -> dict[str, Any]:
430
+ """List agents linked to this registration."""
431
+ try:
432
+ user_id = current_user["identity"]
433
+ tenant_id = current_user["tenant_id"]
434
+
435
+ # Get the specific trigger registration
436
+ trigger = await self.database.get_trigger_registration(
437
+ registration_id, user_id, tenant_id
438
+ )
439
+ if not trigger:
440
+ raise HTTPException(
441
+ status_code=404,
442
+ detail="Trigger registration not found or access denied",
443
+ )
444
+
445
+ # Return the linked agent IDs
446
+ return {
447
+ "success": True,
448
+ "data": trigger.get("linked_assistant_ids", []),
449
+ }
450
+
451
+ except HTTPException:
452
+ raise
453
+ except Exception as e:
454
+ logger.error(f"Error getting registration agents: {e}")
455
+ raise HTTPException(status_code=500, detail=str(e))
456
+
457
+ @self.app.post("/v1/triggers/registrations/{registration_id}/agents/{agent_id}")
458
+ async def api_add_agent_to_trigger(
459
+ registration_id: str,
460
+ agent_id: str,
461
+ request: Request,
462
+ current_user: dict[str, Any] = Depends(get_current_user),
463
+ ) -> dict[str, Any]:
464
+ """Add an agent to a trigger registration."""
465
+ try:
466
+ # Parse request body for field selection
467
+ try:
468
+ body = await request.json()
469
+ field_selection = body.get("field_selection")
470
+ except:
471
+ field_selection = None
472
+
473
+ user_id = current_user["identity"]
474
+ tenant_id = current_user["tenant_id"]
475
+
476
+ # Verify the trigger registration exists and belongs to the user
477
+ registration = await self.database.get_trigger_registration(
478
+ registration_id, user_id, tenant_id
479
+ )
480
+ if not registration:
481
+ raise HTTPException(
482
+ status_code=404,
483
+ detail="Trigger registration not found or access denied",
484
+ )
485
+
486
+ # Link the agent to the trigger
487
+ success = await self.database.link_agent_to_trigger(
488
+ agent_id=agent_id,
489
+ registration_id=registration_id,
490
+ created_by=user_id,
491
+ field_selection=field_selection,
492
+ )
493
+
494
+ if not success:
495
+ raise HTTPException(
496
+ status_code=500, detail="Failed to link agent to trigger"
497
+ )
498
+
499
+ return {
500
+ "success": True,
501
+ "message": f"Successfully linked agent {agent_id} to trigger {registration_id}",
502
+ "data": {"registration_id": registration_id, "agent_id": agent_id},
503
+ }
504
+
505
+ except HTTPException:
506
+ raise
507
+ except Exception as e:
508
+ logger.error(f"Error linking agent to trigger: {e}")
509
+ raise HTTPException(status_code=500, detail=str(e))
510
+
511
+ @self.app.delete(
512
+ "/v1/triggers/registrations/{registration_id}/agents/{agent_id}"
513
+ )
514
+ async def api_remove_agent_from_trigger(
515
+ registration_id: str,
516
+ agent_id: str,
517
+ current_user: dict[str, Any] = Depends(get_current_user),
518
+ ) -> dict[str, Any]:
519
+ """Remove an agent from a trigger registration."""
520
+ try:
521
+ user_id = current_user["identity"]
522
+ tenant_id = current_user["tenant_id"]
523
+
524
+ # Verify the trigger registration exists and belongs to the user
525
+ registration = await self.database.get_trigger_registration(
526
+ registration_id, user_id, tenant_id
527
+ )
528
+ if not registration:
529
+ raise HTTPException(
530
+ status_code=404,
531
+ detail="Trigger registration not found or access denied",
532
+ )
533
+
534
+ # Unlink the agent from the trigger
535
+ success = await self.database.unlink_agent_from_trigger(
536
+ agent_id=agent_id, registration_id=registration_id
537
+ )
538
+
539
+ if not success:
540
+ raise HTTPException(
541
+ status_code=500, detail="Failed to unlink agent from trigger"
542
+ )
543
+
544
+ return {
545
+ "success": True,
546
+ "message": f"Successfully unlinked agent {agent_id} from trigger {registration_id}",
547
+ "data": {"registration_id": registration_id, "agent_id": agent_id},
548
+ }
549
+
550
+ except HTTPException:
551
+ raise
552
+ except Exception as e:
553
+ logger.error(f"Error unlinking agent from trigger: {e}")
554
+ raise HTTPException(status_code=500, detail=str(e))
555
+
556
+ @self.app.get("/v1/triggers/agents/{agent_id}/registrations")
557
+ async def api_get_agent_registrations(
558
+ agent_id: str,
559
+ current_user: dict[str, Any] = Depends(get_current_user),
560
+ ) -> dict[str, Any]:
561
+ """Get all trigger registrations linked to a specific agent."""
562
+ try:
563
+ tenant_id = current_user["tenant_id"]
564
+
565
+ registrations = await self.database.get_registrations_for_agent(
566
+ agent_id, tenant_id
567
+ )
568
+
569
+ return {"data": registrations}
570
+
571
+ except HTTPException:
572
+ raise
573
+ except Exception as e:
574
+ logger.error(f"Error getting registrations for agent: {e}")
575
+ raise HTTPException(status_code=500, detail=str(e))
576
+
577
+ @self.app.post("/v1/triggers/registrations/{registration_id}/execute")
578
+ async def api_execute_trigger_now(
579
+ registration_id: str,
580
+ current_user: dict[str, Any] = Depends(get_current_user),
581
+ ) -> dict[str, Any]:
582
+ """Manually execute a cron trigger registration immediately."""
583
+ try:
584
+ user_id = current_user["identity"]
585
+ tenant_id = current_user["tenant_id"]
586
+
587
+ # Verify the trigger registration exists and belongs to the user
588
+ registration = await self.database.get_trigger_registration(
589
+ registration_id, user_id, tenant_id
590
+ )
591
+ if not registration:
592
+ raise HTTPException(
593
+ status_code=404,
594
+ detail="Trigger registration not found or access denied",
595
+ )
596
+
597
+ # Get the template to check if it's a polling trigger
598
+ template_id = registration.get("template_id")
599
+ tmpl = (
600
+ next((t for t in self.triggers if t.id == template_id), None)
601
+ if template_id
602
+ else None
603
+ )
604
+ if not template_id or not tmpl:
605
+ error_reason = (
606
+ "missing_template_id"
607
+ if not template_id
608
+ else "template_not_found"
609
+ )
610
+ logger.error(
611
+ "manual_execute_error registration_id=%s template_id=%s error=%s",
612
+ registration_id,
613
+ template_id,
614
+ error_reason,
615
+ stack_info=True,
616
+ )
617
+ raise HTTPException(status_code=500, detail="Internal server error")
618
+ if (
619
+ getattr(tmpl.trigger_type, "value", tmpl.trigger_type)
620
+ != TriggerType.POLLING.value
621
+ ):
622
+ raise HTTPException(
623
+ status_code=400,
624
+ detail="Manual execution is only supported for polling triggers",
625
+ )
626
+
627
+ # Execute the cron trigger using the cron manager
628
+ agents_invoked = await self.cron_manager.execute_cron_job(registration)
629
+
630
+ return {
631
+ "success": True,
632
+ "message": f"Manually executed cron trigger {registration_id}",
633
+ "agents_invoked": agents_invoked,
634
+ }
635
+
636
+ except HTTPException:
637
+ raise
638
+ except Exception as e:
639
+ logger.error(f"Error executing trigger: {e}")
640
+ raise HTTPException(status_code=500, detail=str(e))
641
+
642
+ async def _handle_request(
643
+ self, trigger: TriggerTemplate, request: Request
644
+ ) -> dict[str, Any]:
645
+ """Handle an incoming request with a handler function."""
646
+ try:
647
+ logger.info(
648
+ "incoming_trigger_request method=%s path=%s trigger_id=%s content_type=%s",
649
+ request.method,
650
+ request.url.path,
651
+ getattr(trigger, "id", "<unknown>"),
652
+ request.headers.get("content-type", ""),
653
+ )
654
+
655
+ logger.info(
656
+ "invoking_trigger_handler trigger_id=%s",
657
+ getattr(trigger, "id", "<unknown>"),
658
+ )
659
+ response = await trigger.trigger_handler(request, self.database)
660
+ logger.info(
661
+ "trigger_handler_completed trigger_id=%s",
662
+ getattr(trigger, "id", "<unknown>"),
663
+ )
664
+ return response
665
+
666
+ except HTTPException:
667
+ raise
668
+ except Exception as e:
669
+ logger.error(f"Error in trigger handler: {e}", exc_info=True)
670
+ raise HTTPException(
671
+ status_code=500, detail=f"Trigger processing failed: {str(e)}"
672
+ )
673
+
674
+ def get_app(self) -> FastAPI:
675
+ """Get the FastAPI app instance."""
676
+ return self.app
@@ -0,0 +1,3 @@
1
+ """Authentication utilities for trigger webhooks."""
2
+
3
+ __all__ = []