langchain-trigger-server 0.1.6__tar.gz → 0.1.8__tar.gz
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.
- {langchain_trigger_server-0.1.6 → langchain_trigger_server-0.1.8}/PKG-INFO +3 -1
- {langchain_trigger_server-0.1.6 → langchain_trigger_server-0.1.8}/langchain_triggers/app.py +51 -20
- {langchain_trigger_server-0.1.6 → langchain_trigger_server-0.1.8}/langchain_triggers/database/interface.py +8 -2
- {langchain_trigger_server-0.1.6 → langchain_trigger_server-0.1.8}/langchain_triggers/database/supabase.py +76 -4
- {langchain_trigger_server-0.1.6 → langchain_trigger_server-0.1.8}/langchain_triggers/decorators.py +2 -2
- {langchain_trigger_server-0.1.6 → langchain_trigger_server-0.1.8}/pyproject.toml +3 -1
- {langchain_trigger_server-0.1.6 → langchain_trigger_server-0.1.8}/.github/workflows/release.yml +0 -0
- {langchain_trigger_server-0.1.6 → langchain_trigger_server-0.1.8}/README.md +0 -0
- {langchain_trigger_server-0.1.6 → langchain_trigger_server-0.1.8}/langchain_triggers/__init__.py +0 -0
- {langchain_trigger_server-0.1.6 → langchain_trigger_server-0.1.8}/langchain_triggers/core.py +0 -0
- {langchain_trigger_server-0.1.6 → langchain_trigger_server-0.1.8}/langchain_triggers/database/__init__.py +0 -0
- {langchain_trigger_server-0.1.6 → langchain_trigger_server-0.1.8}/test_framework.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: langchain-trigger-server
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.8
|
|
4
4
|
Summary: Generic event-driven triggers framework
|
|
5
5
|
Project-URL: Homepage, https://github.com/langchain-ai/open-agent-platform
|
|
6
6
|
Project-URL: Repository, https://github.com/langchain-ai/open-agent-platform
|
|
@@ -16,12 +16,14 @@ Classifier: Programming Language :: Python :: 3.10
|
|
|
16
16
|
Classifier: Programming Language :: Python :: 3.11
|
|
17
17
|
Classifier: Programming Language :: Python :: 3.12
|
|
18
18
|
Requires-Python: >=3.9
|
|
19
|
+
Requires-Dist: cryptography>=3.0.0
|
|
19
20
|
Requires-Dist: fastapi>=0.100.0
|
|
20
21
|
Requires-Dist: httpx>=0.24.0
|
|
21
22
|
Requires-Dist: langgraph-sdk>=0.2.6
|
|
22
23
|
Requires-Dist: pydantic>=2.0.0
|
|
23
24
|
Requires-Dist: python-jose[cryptography]>=3.3.0
|
|
24
25
|
Requires-Dist: python-multipart>=0.0.6
|
|
26
|
+
Requires-Dist: supabase>=2.0.0
|
|
25
27
|
Requires-Dist: uvicorn[standard]>=0.20.0
|
|
26
28
|
Provides-Extra: dev
|
|
27
29
|
Requires-Dist: black>=23.0.0; extra == 'dev'
|
|
@@ -242,6 +242,29 @@ class TriggerServer:
|
|
|
242
242
|
if not trigger:
|
|
243
243
|
raise HTTPException(status_code=400, detail=f"Unknown trigger type: {trigger_id}")
|
|
244
244
|
|
|
245
|
+
# Parse payload into registration model first
|
|
246
|
+
try:
|
|
247
|
+
registration_instance = trigger.registration_model(**payload)
|
|
248
|
+
except Exception as e:
|
|
249
|
+
raise HTTPException(
|
|
250
|
+
status_code=400,
|
|
251
|
+
detail=f"Invalid payload for trigger: {str(e)}"
|
|
252
|
+
)
|
|
253
|
+
|
|
254
|
+
# Check for duplicate registration based on resource data
|
|
255
|
+
resource_dict = registration_instance.model_dump()
|
|
256
|
+
existing_registration = await self.database.find_registration_by_resource(
|
|
257
|
+
template_id=trigger.id,
|
|
258
|
+
resource_data=resource_dict,
|
|
259
|
+
user_id=user_id
|
|
260
|
+
)
|
|
261
|
+
|
|
262
|
+
if existing_registration:
|
|
263
|
+
raise HTTPException(
|
|
264
|
+
status_code=400,
|
|
265
|
+
detail=f"A registration with this configuration already exists for trigger type '{trigger.id}'. Registration ID: {existing_registration.get('id')}"
|
|
266
|
+
)
|
|
267
|
+
|
|
245
268
|
# Inject OAuth tokens if needed for registration
|
|
246
269
|
auth_user = None
|
|
247
270
|
if trigger.oauth_providers:
|
|
@@ -267,19 +290,10 @@ class TriggerServer:
|
|
|
267
290
|
raise HTTPException(status_code=500, detail="OAuth authentication failed")
|
|
268
291
|
|
|
269
292
|
|
|
270
|
-
# Parse payload into registration model first
|
|
271
|
-
try:
|
|
272
|
-
registration_instance = trigger.registration_model(**payload)
|
|
273
|
-
except Exception as e:
|
|
274
|
-
raise HTTPException(
|
|
275
|
-
status_code=400,
|
|
276
|
-
detail=f"Invalid payload for trigger: {str(e)}"
|
|
277
|
-
)
|
|
278
|
-
|
|
279
293
|
# Call the trigger's registration handler with parsed registration model
|
|
280
294
|
result = await trigger.registration_handler(registration_instance, auth_user)
|
|
281
|
-
|
|
282
295
|
resource_dict = registration_instance.model_dump()
|
|
296
|
+
|
|
283
297
|
registration = await self.database.create_trigger_registration(
|
|
284
298
|
user_id=user_id,
|
|
285
299
|
template_id=trigger.id,
|
|
@@ -423,6 +437,25 @@ class TriggerServer:
|
|
|
423
437
|
) -> Dict[str, Any]:
|
|
424
438
|
"""Handle an incoming request with a handler function."""
|
|
425
439
|
try:
|
|
440
|
+
# Step 1: API Key Authentication (required for webhooks)
|
|
441
|
+
# Check for API key in header first, then query params (for Pub/Sub compatibility)
|
|
442
|
+
api_key = request.headers.get("x-api-key") or request.query_params.get("api_key")
|
|
443
|
+
if not api_key:
|
|
444
|
+
logger.warning("Webhook request missing x-api-key header or api_key query parameter")
|
|
445
|
+
raise HTTPException(
|
|
446
|
+
status_code=401,
|
|
447
|
+
detail="Missing x-api-key header or api_key query parameter"
|
|
448
|
+
)
|
|
449
|
+
|
|
450
|
+
# Validate API key and get user_id
|
|
451
|
+
user_id = await self.database.validate_api_key(api_key)
|
|
452
|
+
if not user_id:
|
|
453
|
+
logger.warning("Invalid API key provided to webhook")
|
|
454
|
+
raise HTTPException(
|
|
455
|
+
status_code=401,
|
|
456
|
+
detail="Invalid API key"
|
|
457
|
+
)
|
|
458
|
+
|
|
426
459
|
# Parse request data
|
|
427
460
|
if request.method == "POST":
|
|
428
461
|
if request.headers.get("content-type", "").startswith("application/json"):
|
|
@@ -434,34 +467,32 @@ class TriggerServer:
|
|
|
434
467
|
else:
|
|
435
468
|
payload = dict(request.query_params)
|
|
436
469
|
|
|
437
|
-
# Step
|
|
470
|
+
# Step 2: Registration resolution
|
|
438
471
|
if not trigger.registration_resolver:
|
|
439
472
|
raise HTTPException(
|
|
440
473
|
status_code=500,
|
|
441
474
|
detail=f"Trigger {trigger.id} missing required registration_resolver"
|
|
442
475
|
)
|
|
443
476
|
|
|
444
|
-
# Extract resource identifiers
|
|
445
|
-
resource_data = await trigger.registration_resolver(payload)
|
|
477
|
+
# Extract resource identifiers using resolver (gets both query params and payload)
|
|
478
|
+
resource_data = await trigger.registration_resolver(payload, dict(request.query_params))
|
|
446
479
|
|
|
447
|
-
# Find matching registration
|
|
480
|
+
# Find matching registration for the authenticated user
|
|
448
481
|
# Convert Pydantic model to dict for database lookup
|
|
449
482
|
resource_dict = resource_data.model_dump()
|
|
450
483
|
registration = await self.database.find_registration_by_resource(
|
|
451
484
|
trigger.id,
|
|
452
|
-
resource_dict
|
|
485
|
+
resource_dict,
|
|
486
|
+
user_id
|
|
453
487
|
)
|
|
454
488
|
|
|
455
489
|
if not registration:
|
|
456
|
-
logger.warning(f"No registration found for trigger_id={trigger.id} with resource={
|
|
490
|
+
logger.warning(f"No registration found for user {user_id}, trigger_id={trigger.id} with resource={resource_dict}")
|
|
457
491
|
raise HTTPException(
|
|
458
492
|
status_code=400,
|
|
459
|
-
detail=f"No registration found for {trigger.id} with resource {
|
|
493
|
+
detail=f"No registration found for {trigger.id} with resource {resource_dict}"
|
|
460
494
|
)
|
|
461
495
|
|
|
462
|
-
# Step 2: Get user_id from registration (webhooks don't use API auth)
|
|
463
|
-
user_id = registration["user_id"]
|
|
464
|
-
|
|
465
496
|
# Step 3: Inject OAuth tokens if needed
|
|
466
497
|
auth_user = None
|
|
467
498
|
if trigger.oauth_providers and self.langchain_auth_client:
|
|
@@ -61,9 +61,10 @@ class TriggerDatabaseInterface(ABC):
|
|
|
61
61
|
async def find_registration_by_resource(
|
|
62
62
|
self,
|
|
63
63
|
template_id: str,
|
|
64
|
-
resource_data: Dict[str, Any]
|
|
64
|
+
resource_data: Dict[str, Any],
|
|
65
|
+
user_id: str
|
|
65
66
|
) -> Optional[Dict[str, Any]]:
|
|
66
|
-
"""Find trigger registration by matching resource data."""
|
|
67
|
+
"""Find trigger registration by matching resource data for a specific user."""
|
|
67
68
|
pass
|
|
68
69
|
|
|
69
70
|
@abstractmethod
|
|
@@ -127,4 +128,9 @@ class TriggerDatabaseInterface(ABC):
|
|
|
127
128
|
@abstractmethod
|
|
128
129
|
async def get_user_by_email(self, email: str) -> Optional[str]:
|
|
129
130
|
"""Get user ID by email from trigger registrations."""
|
|
131
|
+
pass
|
|
132
|
+
|
|
133
|
+
@abstractmethod
|
|
134
|
+
async def validate_api_key(self, api_key: str) -> Optional[str]:
|
|
135
|
+
"""Validate API key and return user_id if valid, None if invalid."""
|
|
130
136
|
pass
|
|
@@ -4,6 +4,10 @@ import os
|
|
|
4
4
|
import logging
|
|
5
5
|
from typing import List, Optional, Dict, Any
|
|
6
6
|
from supabase import create_client, Client
|
|
7
|
+
import base64
|
|
8
|
+
import hashlib
|
|
9
|
+
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
|
|
10
|
+
from cryptography.hazmat.backends import default_backend
|
|
7
11
|
|
|
8
12
|
from .interface import TriggerDatabaseInterface
|
|
9
13
|
|
|
@@ -21,8 +25,47 @@ class SupabaseTriggerDatabase(TriggerDatabaseInterface):
|
|
|
21
25
|
raise ValueError("SUPABASE_URL and SUPABASE_KEY environment variables are required")
|
|
22
26
|
|
|
23
27
|
self.client = create_client(self.supabase_url, self.supabase_key)
|
|
28
|
+
|
|
29
|
+
# Get encryption key for API key decryption - required
|
|
30
|
+
self.encryption_key = os.getenv("SECRETS_ENCRYPTION_KEY")
|
|
31
|
+
if not self.encryption_key:
|
|
32
|
+
raise ValueError("SECRETS_ENCRYPTION_KEY environment variable is required")
|
|
33
|
+
|
|
24
34
|
logger.info("Initialized SupabaseTriggerDatabase")
|
|
25
35
|
|
|
36
|
+
def _decrypt_secret(self, encrypted_secret: str) -> str:
|
|
37
|
+
"""Decrypt an encrypted secret using AES-256-GCM to match OAP Node.js implementation."""
|
|
38
|
+
try:
|
|
39
|
+
# Decode the base64 encoded encrypted data
|
|
40
|
+
combined = base64.b64decode(encrypted_secret)
|
|
41
|
+
|
|
42
|
+
# Constants from Node.js implementation
|
|
43
|
+
IV_LENGTH = 12 # 96 bits
|
|
44
|
+
TAG_LENGTH = 16 # 128 bits
|
|
45
|
+
|
|
46
|
+
# Minimum length check
|
|
47
|
+
if len(combined) < IV_LENGTH + TAG_LENGTH + 1:
|
|
48
|
+
raise ValueError("Invalid encrypted secret format: too short or malformed")
|
|
49
|
+
|
|
50
|
+
# Extract IV, encrypted data, and auth tag
|
|
51
|
+
iv = combined[:IV_LENGTH]
|
|
52
|
+
tag = combined[-TAG_LENGTH:]
|
|
53
|
+
encrypted_data = combined[IV_LENGTH:-TAG_LENGTH]
|
|
54
|
+
|
|
55
|
+
# Derive key using SHA-256 hash (same as Node.js deriveKey function)
|
|
56
|
+
key = hashlib.sha256(self.encryption_key.encode()).digest()
|
|
57
|
+
|
|
58
|
+
# Create AES-GCM cipher
|
|
59
|
+
cipher = Cipher(algorithms.AES(key), modes.GCM(iv, tag), backend=default_backend())
|
|
60
|
+
decryptor = cipher.decryptor()
|
|
61
|
+
|
|
62
|
+
# Decrypt the data
|
|
63
|
+
decrypted_data = decryptor.update(encrypted_data) + decryptor.finalize()
|
|
64
|
+
|
|
65
|
+
return decrypted_data.decode('utf-8')
|
|
66
|
+
except Exception as e:
|
|
67
|
+
logger.error(f"Error decrypting secret: {e}")
|
|
68
|
+
raise ValueError("Failed to decrypt API key")
|
|
26
69
|
|
|
27
70
|
# ========== Trigger Templates ==========
|
|
28
71
|
|
|
@@ -182,14 +225,15 @@ class SupabaseTriggerDatabase(TriggerDatabaseInterface):
|
|
|
182
225
|
async def find_registration_by_resource(
|
|
183
226
|
self,
|
|
184
227
|
template_id: str,
|
|
185
|
-
resource_data: Dict[str, Any]
|
|
228
|
+
resource_data: Dict[str, Any],
|
|
229
|
+
user_id: str
|
|
186
230
|
) -> Optional[Dict[str, Any]]:
|
|
187
|
-
"""Find trigger registration by matching resource data."""
|
|
231
|
+
"""Find trigger registration by matching resource data for a specific user."""
|
|
188
232
|
try:
|
|
189
|
-
# Build query to match against trigger_registrations with template_id
|
|
233
|
+
# Build query to match against trigger_registrations with template_id and user_id filters
|
|
190
234
|
query = self.client.table("trigger_registrations").select(
|
|
191
235
|
"*, trigger_templates(id, name, description)"
|
|
192
|
-
).eq("trigger_templates.id", template_id)
|
|
236
|
+
).eq("trigger_templates.id", template_id).eq("user_id", user_id)
|
|
193
237
|
|
|
194
238
|
# Add resource field matches
|
|
195
239
|
for field, value in resource_data.items():
|
|
@@ -300,4 +344,32 @@ class SupabaseTriggerDatabase(TriggerDatabaseInterface):
|
|
|
300
344
|
|
|
301
345
|
except Exception as e:
|
|
302
346
|
logger.error(f"Error getting user by email: {e}")
|
|
347
|
+
return None
|
|
348
|
+
|
|
349
|
+
async def validate_api_key(self, api_key: str) -> Optional[str]:
|
|
350
|
+
"""Validate API key and return user_id if valid, None if invalid."""
|
|
351
|
+
try:
|
|
352
|
+
# Query all user API keys to find a match
|
|
353
|
+
response = self.client.table("user_api_keys").select("user_id, key_hash").execute()
|
|
354
|
+
|
|
355
|
+
if not response.data:
|
|
356
|
+
return None
|
|
357
|
+
|
|
358
|
+
# Check each encrypted key to see if it matches the provided key
|
|
359
|
+
for row in response.data:
|
|
360
|
+
try:
|
|
361
|
+
decrypted_key = self._decrypt_secret(row["key_hash"])
|
|
362
|
+
if decrypted_key == api_key:
|
|
363
|
+
logger.info(f"Valid API key authenticated for user: {row['user_id']}")
|
|
364
|
+
return row["user_id"]
|
|
365
|
+
except Exception as e:
|
|
366
|
+
# Skip keys that fail to decrypt
|
|
367
|
+
logger.debug(f"Failed to decrypt API key: {e}")
|
|
368
|
+
continue
|
|
369
|
+
|
|
370
|
+
logger.warning(f"Invalid API key provided")
|
|
371
|
+
return None
|
|
372
|
+
|
|
373
|
+
except Exception as e:
|
|
374
|
+
logger.error(f"Error validating API key: {e}")
|
|
303
375
|
return None
|
{langchain_trigger_server-0.1.6 → langchain_trigger_server-0.1.8}/langchain_triggers/decorators.py
RENAMED
|
@@ -42,8 +42,8 @@ class TriggerTemplate:
|
|
|
42
42
|
# Expected: async def handler(payload: Dict[str, Any], auth_user: UserAuthInfo, metadata: MetadataManager) -> TriggerHandlerResult
|
|
43
43
|
self._validate_handler("trigger_handler", self.trigger_handler, [Dict[str, Any], UserAuthInfo, MetadataManager], TriggerHandlerResult)
|
|
44
44
|
|
|
45
|
-
# Expected: async def resolver(payload: Dict[str, Any]) -> RegistrationModel
|
|
46
|
-
self._validate_handler("registration_resolver", self.registration_resolver, [Dict[str, Any]], self.registration_model)
|
|
45
|
+
# Expected: async def resolver(payload: Dict[str, Any], query_params: Dict[str, str]) -> RegistrationModel
|
|
46
|
+
self._validate_handler("registration_resolver", self.registration_resolver, [Dict[str, Any], Dict[str, str]], self.registration_model)
|
|
47
47
|
|
|
48
48
|
def _validate_handler(self, handler_name: str, handler_func, expected_types: List[Type], expected_return_type: Type = None):
|
|
49
49
|
"""Common validation logic for all handler functions."""
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "langchain-trigger-server"
|
|
7
|
-
version = "0.1.
|
|
7
|
+
version = "0.1.8"
|
|
8
8
|
description = "Generic event-driven triggers framework"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.9"
|
|
@@ -31,6 +31,8 @@ dependencies = [
|
|
|
31
31
|
"python-multipart>=0.0.6",
|
|
32
32
|
"python-jose[cryptography]>=3.3.0",
|
|
33
33
|
"langgraph-sdk>=0.2.6",
|
|
34
|
+
"supabase>=2.0.0",
|
|
35
|
+
"cryptography>=3.0.0",
|
|
34
36
|
]
|
|
35
37
|
|
|
36
38
|
[project.optional-dependencies]
|
{langchain_trigger_server-0.1.6 → langchain_trigger_server-0.1.8}/.github/workflows/release.yml
RENAMED
|
File without changes
|
|
File without changes
|
{langchain_trigger_server-0.1.6 → langchain_trigger_server-0.1.8}/langchain_triggers/__init__.py
RENAMED
|
File without changes
|
{langchain_trigger_server-0.1.6 → langchain_trigger_server-0.1.8}/langchain_triggers/core.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|