langchain-trigger-server 0.1.6__tar.gz → 0.1.7__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.

Potentially problematic release.


This version of langchain-trigger-server might be problematic. Click here for more details.

@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: langchain-trigger-server
3
- Version: 0.1.6
3
+ Version: 0.1.7
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,24 @@ 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
+ api_key = request.headers.get("x-api-key")
442
+ if not api_key:
443
+ logger.warning("Webhook request missing x-api-key header")
444
+ raise HTTPException(
445
+ status_code=401,
446
+ detail="Missing x-api-key header"
447
+ )
448
+
449
+ # Validate API key and get user_id
450
+ user_id = await self.database.validate_api_key(api_key)
451
+ if not user_id:
452
+ logger.warning("Invalid API key provided to webhook")
453
+ raise HTTPException(
454
+ status_code=401,
455
+ detail="Invalid API key"
456
+ )
457
+
426
458
  # Parse request data
427
459
  if request.method == "POST":
428
460
  if request.headers.get("content-type", "").startswith("application/json"):
@@ -434,7 +466,7 @@ class TriggerServer:
434
466
  else:
435
467
  payload = dict(request.query_params)
436
468
 
437
- # Step 1: Registration resolution
469
+ # Step 2: Registration resolution
438
470
  if not trigger.registration_resolver:
439
471
  raise HTTPException(
440
472
  status_code=500,
@@ -444,24 +476,22 @@ class TriggerServer:
444
476
  # Extract resource identifiers from webhook payload
445
477
  resource_data = await trigger.registration_resolver(payload)
446
478
 
447
- # Find matching registration
479
+ # Find matching registration for the authenticated user
448
480
  # Convert Pydantic model to dict for database lookup
449
481
  resource_dict = resource_data.model_dump()
450
482
  registration = await self.database.find_registration_by_resource(
451
483
  trigger.id,
452
- resource_dict
484
+ resource_dict,
485
+ user_id
453
486
  )
454
487
 
455
488
  if not registration:
456
- logger.warning(f"No registration found for trigger_id={trigger.id} with resource={resource_data}, returning 400")
489
+ logger.warning(f"No registration found for user {user_id}, trigger_id={trigger.id} with resource={resource_data}")
457
490
  raise HTTPException(
458
491
  status_code=400,
459
492
  detail=f"No registration found for {trigger.id} with resource {resource_data}"
460
493
  )
461
494
 
462
- # Step 2: Get user_id from registration (webhooks don't use API auth)
463
- user_id = registration["user_id"]
464
-
465
495
  # Step 3: Inject OAuth tokens if needed
466
496
  auth_user = None
467
497
  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 filter
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
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "langchain-trigger-server"
7
- version = "0.1.6"
7
+ version = "0.1.7"
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]