xences-ai 0.1.0__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.
- xences_ai-0.1.0/PKG-INFO +149 -0
- xences_ai-0.1.0/README.md +130 -0
- xences_ai-0.1.0/pyproject.toml +28 -0
- xences_ai-0.1.0/setup.cfg +4 -0
- xences_ai-0.1.0/src/xences_ai/__init__.py +5 -0
- xences_ai-0.1.0/src/xences_ai/client.py +483 -0
- xences_ai-0.1.0/src/xences_ai.egg-info/PKG-INFO +149 -0
- xences_ai-0.1.0/src/xences_ai.egg-info/SOURCES.txt +10 -0
- xences_ai-0.1.0/src/xences_ai.egg-info/dependency_links.txt +1 -0
- xences_ai-0.1.0/src/xences_ai.egg-info/requires.txt +4 -0
- xences_ai-0.1.0/src/xences_ai.egg-info/top_level.txt +1 -0
- xences_ai-0.1.0/tests/test_client.py +374 -0
xences_ai-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: xences-ai
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Python SDK for the Xences AI execution runtime
|
|
5
|
+
License: MIT
|
|
6
|
+
Classifier: Development Status :: 3 - Alpha
|
|
7
|
+
Classifier: Intended Audience :: Developers
|
|
8
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
9
|
+
Classifier: Programming Language :: Python :: 3
|
|
10
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
11
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
13
|
+
Classifier: Typing :: Typed
|
|
14
|
+
Requires-Python: >=3.10
|
|
15
|
+
Description-Content-Type: text/markdown
|
|
16
|
+
Requires-Dist: cryptography>=42.0
|
|
17
|
+
Provides-Extra: dev
|
|
18
|
+
Requires-Dist: pytest>=8.0; extra == "dev"
|
|
19
|
+
|
|
20
|
+
# xences-ai
|
|
21
|
+
|
|
22
|
+
Python SDK for the Xences AI execution runtime.
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
pip install xences-ai
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
The client signs authentication requests with your application's RSA private key. Keep the private key in a server-side secret store — never expose it to clients or ship it in a browser or mobile application.
|
|
29
|
+
|
|
30
|
+
## Connect and send a goal
|
|
31
|
+
|
|
32
|
+
```python
|
|
33
|
+
import os
|
|
34
|
+
from xences_ai import Xences, Principal
|
|
35
|
+
|
|
36
|
+
xences = Xences(
|
|
37
|
+
app_id=os.environ["XENCES_APP_ID"],
|
|
38
|
+
private_key=os.environ["XENCES_PRIVATE_KEY"],
|
|
39
|
+
base_url="https://xences.app",
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
result = xences.goals.create(
|
|
43
|
+
request="Reschedule my appointment to Friday",
|
|
44
|
+
principal=Principal(external_id=user.id), # "human" is the default type
|
|
45
|
+
)
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
For async applications use `await xences.goals.acreate(...)`.
|
|
49
|
+
|
|
50
|
+
## Register operations
|
|
51
|
+
|
|
52
|
+
Expose handlers for operations the platform can invoke on your application, then register them once at startup:
|
|
53
|
+
|
|
54
|
+
```python
|
|
55
|
+
xences.expose(
|
|
56
|
+
"orders.refund",
|
|
57
|
+
method="POST",
|
|
58
|
+
path="/orders/:id/refund",
|
|
59
|
+
input_schema={
|
|
60
|
+
"type": "object",
|
|
61
|
+
"required": ["orderId"],
|
|
62
|
+
"properties": {"orderId": {"type": "string"}},
|
|
63
|
+
},
|
|
64
|
+
)(lambda input, context: orders.refund(input["orderId"]))
|
|
65
|
+
|
|
66
|
+
xences.register()
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
The decorator form is also supported:
|
|
70
|
+
|
|
71
|
+
```python
|
|
72
|
+
@xences.expose("orders.refund", method="POST", path="/orders/:id/refund")
|
|
73
|
+
def refund_order(input, context):
|
|
74
|
+
return orders.refund(input["orderId"])
|
|
75
|
+
|
|
76
|
+
xences.register()
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
## Handle inbound invocations
|
|
80
|
+
|
|
81
|
+
After registering operations, mount a route so the platform can call back into your app. Pass `xences_public_key` to enable signature verification on inbound requests:
|
|
82
|
+
|
|
83
|
+
```python
|
|
84
|
+
xences = Xences(
|
|
85
|
+
app_id=os.environ["XENCES_APP_ID"],
|
|
86
|
+
private_key=os.environ["XENCES_PRIVATE_KEY"],
|
|
87
|
+
xences_public_key=os.environ["XENCES_PUBLIC_KEY"], # verifies inbound calls
|
|
88
|
+
base_url="https://xences.app",
|
|
89
|
+
)
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
The default invoke path (`/.well-known/xences/invoke`) is sent to the platform when you call `register()`. Mount it in your web framework:
|
|
93
|
+
|
|
94
|
+
### Flask
|
|
95
|
+
|
|
96
|
+
```python
|
|
97
|
+
from flask import Flask, request
|
|
98
|
+
from xences_ai import XencesError
|
|
99
|
+
|
|
100
|
+
app = Flask(__name__)
|
|
101
|
+
|
|
102
|
+
@app.post("/.well-known/xences/invoke")
|
|
103
|
+
def invoke():
|
|
104
|
+
try:
|
|
105
|
+
return xences.handle_request(
|
|
106
|
+
body=request.get_json(),
|
|
107
|
+
headers=dict(request.headers),
|
|
108
|
+
)
|
|
109
|
+
except XencesError as e:
|
|
110
|
+
return {"success": False, "error": str(e)}, e.status or 500
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
### FastAPI
|
|
114
|
+
|
|
115
|
+
```python
|
|
116
|
+
from fastapi import FastAPI, Request
|
|
117
|
+
from fastapi.responses import JSONResponse
|
|
118
|
+
from xences_ai import XencesError
|
|
119
|
+
|
|
120
|
+
app = FastAPI()
|
|
121
|
+
|
|
122
|
+
@app.post("/.well-known/xences/invoke")
|
|
123
|
+
async def invoke(request: Request):
|
|
124
|
+
try:
|
|
125
|
+
return await xences.ahandle_request(
|
|
126
|
+
body=await request.json(),
|
|
127
|
+
headers=dict(request.headers),
|
|
128
|
+
)
|
|
129
|
+
except XencesError as e:
|
|
130
|
+
return JSONResponse(
|
|
131
|
+
{"success": False, "error": str(e)},
|
|
132
|
+
status_code=e.status or 500,
|
|
133
|
+
)
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
`handle_request` and `ahandle_request` verify the platform's app identity, timestamp freshness, replay protection, and RSA signature before dispatching. They raise `XencesError` with a `status` code on any failure.
|
|
137
|
+
|
|
138
|
+
## Key rotation
|
|
139
|
+
|
|
140
|
+
Rotation is opt-in. Set `allow_key_rotation=True` and call `rotate_key()` only after registering the new public key with the platform operator:
|
|
141
|
+
|
|
142
|
+
```python
|
|
143
|
+
xences = Xences(
|
|
144
|
+
...,
|
|
145
|
+
allow_key_rotation=True,
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
xences.rotate_key(new_public_key_pem)
|
|
149
|
+
```
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
# xences-ai
|
|
2
|
+
|
|
3
|
+
Python SDK for the Xences AI execution runtime.
|
|
4
|
+
|
|
5
|
+
```bash
|
|
6
|
+
pip install xences-ai
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
The client signs authentication requests with your application's RSA private key. Keep the private key in a server-side secret store — never expose it to clients or ship it in a browser or mobile application.
|
|
10
|
+
|
|
11
|
+
## Connect and send a goal
|
|
12
|
+
|
|
13
|
+
```python
|
|
14
|
+
import os
|
|
15
|
+
from xences_ai import Xences, Principal
|
|
16
|
+
|
|
17
|
+
xences = Xences(
|
|
18
|
+
app_id=os.environ["XENCES_APP_ID"],
|
|
19
|
+
private_key=os.environ["XENCES_PRIVATE_KEY"],
|
|
20
|
+
base_url="https://xences.app",
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
result = xences.goals.create(
|
|
24
|
+
request="Reschedule my appointment to Friday",
|
|
25
|
+
principal=Principal(external_id=user.id), # "human" is the default type
|
|
26
|
+
)
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
For async applications use `await xences.goals.acreate(...)`.
|
|
30
|
+
|
|
31
|
+
## Register operations
|
|
32
|
+
|
|
33
|
+
Expose handlers for operations the platform can invoke on your application, then register them once at startup:
|
|
34
|
+
|
|
35
|
+
```python
|
|
36
|
+
xences.expose(
|
|
37
|
+
"orders.refund",
|
|
38
|
+
method="POST",
|
|
39
|
+
path="/orders/:id/refund",
|
|
40
|
+
input_schema={
|
|
41
|
+
"type": "object",
|
|
42
|
+
"required": ["orderId"],
|
|
43
|
+
"properties": {"orderId": {"type": "string"}},
|
|
44
|
+
},
|
|
45
|
+
)(lambda input, context: orders.refund(input["orderId"]))
|
|
46
|
+
|
|
47
|
+
xences.register()
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
The decorator form is also supported:
|
|
51
|
+
|
|
52
|
+
```python
|
|
53
|
+
@xences.expose("orders.refund", method="POST", path="/orders/:id/refund")
|
|
54
|
+
def refund_order(input, context):
|
|
55
|
+
return orders.refund(input["orderId"])
|
|
56
|
+
|
|
57
|
+
xences.register()
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
## Handle inbound invocations
|
|
61
|
+
|
|
62
|
+
After registering operations, mount a route so the platform can call back into your app. Pass `xences_public_key` to enable signature verification on inbound requests:
|
|
63
|
+
|
|
64
|
+
```python
|
|
65
|
+
xences = Xences(
|
|
66
|
+
app_id=os.environ["XENCES_APP_ID"],
|
|
67
|
+
private_key=os.environ["XENCES_PRIVATE_KEY"],
|
|
68
|
+
xences_public_key=os.environ["XENCES_PUBLIC_KEY"], # verifies inbound calls
|
|
69
|
+
base_url="https://xences.app",
|
|
70
|
+
)
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
The default invoke path (`/.well-known/xences/invoke`) is sent to the platform when you call `register()`. Mount it in your web framework:
|
|
74
|
+
|
|
75
|
+
### Flask
|
|
76
|
+
|
|
77
|
+
```python
|
|
78
|
+
from flask import Flask, request
|
|
79
|
+
from xences_ai import XencesError
|
|
80
|
+
|
|
81
|
+
app = Flask(__name__)
|
|
82
|
+
|
|
83
|
+
@app.post("/.well-known/xences/invoke")
|
|
84
|
+
def invoke():
|
|
85
|
+
try:
|
|
86
|
+
return xences.handle_request(
|
|
87
|
+
body=request.get_json(),
|
|
88
|
+
headers=dict(request.headers),
|
|
89
|
+
)
|
|
90
|
+
except XencesError as e:
|
|
91
|
+
return {"success": False, "error": str(e)}, e.status or 500
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
### FastAPI
|
|
95
|
+
|
|
96
|
+
```python
|
|
97
|
+
from fastapi import FastAPI, Request
|
|
98
|
+
from fastapi.responses import JSONResponse
|
|
99
|
+
from xences_ai import XencesError
|
|
100
|
+
|
|
101
|
+
app = FastAPI()
|
|
102
|
+
|
|
103
|
+
@app.post("/.well-known/xences/invoke")
|
|
104
|
+
async def invoke(request: Request):
|
|
105
|
+
try:
|
|
106
|
+
return await xences.ahandle_request(
|
|
107
|
+
body=await request.json(),
|
|
108
|
+
headers=dict(request.headers),
|
|
109
|
+
)
|
|
110
|
+
except XencesError as e:
|
|
111
|
+
return JSONResponse(
|
|
112
|
+
{"success": False, "error": str(e)},
|
|
113
|
+
status_code=e.status or 500,
|
|
114
|
+
)
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
`handle_request` and `ahandle_request` verify the platform's app identity, timestamp freshness, replay protection, and RSA signature before dispatching. They raise `XencesError` with a `status` code on any failure.
|
|
118
|
+
|
|
119
|
+
## Key rotation
|
|
120
|
+
|
|
121
|
+
Rotation is opt-in. Set `allow_key_rotation=True` and call `rotate_key()` only after registering the new public key with the platform operator:
|
|
122
|
+
|
|
123
|
+
```python
|
|
124
|
+
xences = Xences(
|
|
125
|
+
...,
|
|
126
|
+
allow_key_rotation=True,
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
xences.rotate_key(new_public_key_pem)
|
|
130
|
+
```
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "xences-ai"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Python SDK for the Xences AI execution runtime"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = { text = "MIT" }
|
|
11
|
+
requires-python = ">=3.10"
|
|
12
|
+
dependencies = ["cryptography>=42.0"]
|
|
13
|
+
classifiers = [
|
|
14
|
+
"Development Status :: 3 - Alpha",
|
|
15
|
+
"Intended Audience :: Developers",
|
|
16
|
+
"License :: OSI Approved :: MIT License",
|
|
17
|
+
"Programming Language :: Python :: 3",
|
|
18
|
+
"Programming Language :: Python :: 3.10",
|
|
19
|
+
"Programming Language :: Python :: 3.11",
|
|
20
|
+
"Programming Language :: Python :: 3.12",
|
|
21
|
+
"Typing :: Typed",
|
|
22
|
+
]
|
|
23
|
+
|
|
24
|
+
[project.optional-dependencies]
|
|
25
|
+
dev = ["pytest>=8.0"]
|
|
26
|
+
|
|
27
|
+
[tool.setuptools.packages.find]
|
|
28
|
+
where = ["src"]
|
|
@@ -0,0 +1,483 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import base64
|
|
5
|
+
import json
|
|
6
|
+
import time
|
|
7
|
+
import uuid
|
|
8
|
+
from typing import Any, Callable, Dict, Optional
|
|
9
|
+
from urllib.error import HTTPError
|
|
10
|
+
from urllib.request import Request, urlopen
|
|
11
|
+
|
|
12
|
+
from cryptography.exceptions import InvalidSignature
|
|
13
|
+
from cryptography.hazmat.primitives import hashes, serialization
|
|
14
|
+
from cryptography.hazmat.primitives.asymmetric import padding
|
|
15
|
+
from cryptography.hazmat.primitives.asymmetric.rsa import RSAPrivateKey
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class XencesError(RuntimeError):
|
|
19
|
+
"""Raised when the Xences platform returns an error or an invocation fails validation."""
|
|
20
|
+
|
|
21
|
+
def __init__(self, message: str, status: int = 0, body: Any = None) -> None:
|
|
22
|
+
super().__init__(message)
|
|
23
|
+
self.status = status
|
|
24
|
+
self.body = body
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class Principal:
|
|
28
|
+
"""Identifies the actor submitting a goal."""
|
|
29
|
+
|
|
30
|
+
def __init__(self, external_id: str, type: str = "human") -> None:
|
|
31
|
+
self.external_id = external_id
|
|
32
|
+
self.type = type
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class _GoalsNamespace:
|
|
36
|
+
"""Namespace for goal operations — ``xences.goals.create(...)``."""
|
|
37
|
+
|
|
38
|
+
__slots__ = ("_sdk",)
|
|
39
|
+
|
|
40
|
+
def __init__(self, sdk: "Xences") -> None:
|
|
41
|
+
self._sdk = sdk
|
|
42
|
+
|
|
43
|
+
def create(
|
|
44
|
+
self,
|
|
45
|
+
*,
|
|
46
|
+
request: Optional[str] = None,
|
|
47
|
+
effect: Optional[str] = None,
|
|
48
|
+
input: Optional[Dict[str, Any]] = None,
|
|
49
|
+
principal: Optional[Principal] = None,
|
|
50
|
+
) -> Any:
|
|
51
|
+
"""Submit a goal to the Xences orchestrator."""
|
|
52
|
+
message = request or effect or ""
|
|
53
|
+
metadata: Dict[str, Any] = dict(input or {})
|
|
54
|
+
if effect:
|
|
55
|
+
metadata["effect"] = effect
|
|
56
|
+
p = principal or Principal(external_id="sdk-app")
|
|
57
|
+
return self._sdk._request(
|
|
58
|
+
"/api/ai/orchestrator/surfaces/sdk/respond",
|
|
59
|
+
{"message": message, "channel": "api", "metadata": metadata},
|
|
60
|
+
p,
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
async def acreate(
|
|
64
|
+
self,
|
|
65
|
+
*,
|
|
66
|
+
request: Optional[str] = None,
|
|
67
|
+
effect: Optional[str] = None,
|
|
68
|
+
input: Optional[Dict[str, Any]] = None,
|
|
69
|
+
principal: Optional[Principal] = None,
|
|
70
|
+
) -> Any:
|
|
71
|
+
"""Async version of ``create()``."""
|
|
72
|
+
return await asyncio.to_thread(
|
|
73
|
+
self.create, request=request, effect=effect, input=input, principal=principal
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class Xences:
|
|
78
|
+
"""Server-side Xences SDK client.
|
|
79
|
+
|
|
80
|
+
Args:
|
|
81
|
+
app_id: Application ID issued by the platform operator.
|
|
82
|
+
private_key: PEM-encoded RSA private key used to sign SDK auth requests.
|
|
83
|
+
Keep this in a server-side secret store — never expose it to clients.
|
|
84
|
+
base_url: Base URL of the Xences platform (e.g. ``"https://xences.app"``).
|
|
85
|
+
xences_public_key: PEM-encoded RSA public key for verifying inbound platform
|
|
86
|
+
invocations. Required when using ``handle_request`` / ``ahandle_request``.
|
|
87
|
+
allow_key_rotation: Set ``True`` to enable ``rotate_key()``.
|
|
88
|
+
|
|
89
|
+
Example::
|
|
90
|
+
|
|
91
|
+
xences = Xences(
|
|
92
|
+
app_id=os.environ["XENCES_APP_ID"],
|
|
93
|
+
private_key=os.environ["XENCES_PRIVATE_KEY"],
|
|
94
|
+
base_url="https://xences.app",
|
|
95
|
+
)
|
|
96
|
+
"""
|
|
97
|
+
|
|
98
|
+
def __init__(
|
|
99
|
+
self,
|
|
100
|
+
app_id: str,
|
|
101
|
+
private_key: str,
|
|
102
|
+
base_url: str,
|
|
103
|
+
xences_public_key: Optional[str] = None,
|
|
104
|
+
allow_key_rotation: bool = False,
|
|
105
|
+
) -> None:
|
|
106
|
+
if not app_id or not private_key or not base_url:
|
|
107
|
+
raise ValueError("Xences requires app_id, private_key, and base_url.")
|
|
108
|
+
|
|
109
|
+
try:
|
|
110
|
+
key = serialization.load_pem_private_key(private_key.encode(), password=None)
|
|
111
|
+
except Exception as exc:
|
|
112
|
+
raise ValueError("Xences private_key must be a valid PEM private key.") from exc
|
|
113
|
+
if not isinstance(key, RSAPrivateKey):
|
|
114
|
+
raise ValueError("Xences private_key must be an RSA private key.")
|
|
115
|
+
|
|
116
|
+
self.app_id = app_id
|
|
117
|
+
self._private_key_pem = private_key
|
|
118
|
+
self.base_url = base_url.rstrip("/")
|
|
119
|
+
self._xences_public_key = xences_public_key
|
|
120
|
+
self._allow_key_rotation = allow_key_rotation
|
|
121
|
+
|
|
122
|
+
# Token cache
|
|
123
|
+
self._token: Optional[str] = None
|
|
124
|
+
self._token_expires_at: float = 0.0
|
|
125
|
+
|
|
126
|
+
# Operation registry
|
|
127
|
+
self._operations: Dict[str, Callable[..., Any]] = {}
|
|
128
|
+
self._manifests: Dict[str, Dict[str, Any]] = {}
|
|
129
|
+
self._invocation_nonces: Dict[str, float] = {}
|
|
130
|
+
|
|
131
|
+
self.goals = _GoalsNamespace(self)
|
|
132
|
+
|
|
133
|
+
# ------------------------------------------------------------------
|
|
134
|
+
# Authentication
|
|
135
|
+
# ------------------------------------------------------------------
|
|
136
|
+
|
|
137
|
+
def _authenticate(self) -> str:
|
|
138
|
+
now = int(time.time())
|
|
139
|
+
nonce = str(uuid.uuid4())
|
|
140
|
+
message = f"{self.app_id}.{now}.{nonce}".encode()
|
|
141
|
+
key = serialization.load_pem_private_key(self._private_key_pem.encode(), password=None)
|
|
142
|
+
sig = key.sign(message, padding.PKCS1v15(), hashes.SHA256())
|
|
143
|
+
req = Request(
|
|
144
|
+
f"{self.base_url}/api/auth/sdk/token",
|
|
145
|
+
data=json.dumps(
|
|
146
|
+
{
|
|
147
|
+
"appId": self.app_id,
|
|
148
|
+
"timestamp": now,
|
|
149
|
+
"nonce": nonce,
|
|
150
|
+
"signature": base64.b64encode(sig).decode(),
|
|
151
|
+
}
|
|
152
|
+
).encode(),
|
|
153
|
+
headers={"Accept": "application/json", "Content-Type": "application/json"},
|
|
154
|
+
method="POST",
|
|
155
|
+
)
|
|
156
|
+
try:
|
|
157
|
+
with urlopen(req, timeout=15) as resp:
|
|
158
|
+
body: Dict[str, Any] = json.loads(resp.read().decode())
|
|
159
|
+
except HTTPError as exc:
|
|
160
|
+
raw = exc.read() if exc.fp else b"{}"
|
|
161
|
+
body = json.loads(raw.decode())
|
|
162
|
+
raise XencesError(
|
|
163
|
+
body.get("error", {}).get("message", "SDK authentication failed"),
|
|
164
|
+
status=exc.code,
|
|
165
|
+
body=body,
|
|
166
|
+
) from exc
|
|
167
|
+
token = body.get("token")
|
|
168
|
+
if not token:
|
|
169
|
+
raise XencesError(
|
|
170
|
+
body.get("error", {}).get("message", "SDK authentication failed"),
|
|
171
|
+
status=200,
|
|
172
|
+
body=body,
|
|
173
|
+
)
|
|
174
|
+
expires_in = max(60, int(body.get("expiresIn", 300)) - 30)
|
|
175
|
+
self._token = str(token)
|
|
176
|
+
self._token_expires_at = time.time() + expires_in
|
|
177
|
+
return self._token
|
|
178
|
+
|
|
179
|
+
def _access_token(self) -> str:
|
|
180
|
+
if self._token and time.time() < self._token_expires_at:
|
|
181
|
+
return self._token
|
|
182
|
+
return self._authenticate()
|
|
183
|
+
|
|
184
|
+
# ------------------------------------------------------------------
|
|
185
|
+
# HTTP transport
|
|
186
|
+
# ------------------------------------------------------------------
|
|
187
|
+
|
|
188
|
+
def _request(
|
|
189
|
+
self,
|
|
190
|
+
path: str,
|
|
191
|
+
payload: Dict[str, Any],
|
|
192
|
+
principal: Optional[Principal] = None,
|
|
193
|
+
) -> Any:
|
|
194
|
+
token = self._access_token()
|
|
195
|
+
headers: Dict[str, str] = {
|
|
196
|
+
"Accept": "application/json",
|
|
197
|
+
"Content-Type": "application/json",
|
|
198
|
+
"Authorization": f"Bearer {token}",
|
|
199
|
+
}
|
|
200
|
+
if principal:
|
|
201
|
+
headers["x-xences-principal-id"] = principal.external_id
|
|
202
|
+
headers["x-xences-principal-type"] = principal.type
|
|
203
|
+
req = Request(
|
|
204
|
+
f"{self.base_url}{path}",
|
|
205
|
+
data=json.dumps(payload).encode(),
|
|
206
|
+
headers=headers,
|
|
207
|
+
method="POST",
|
|
208
|
+
)
|
|
209
|
+
try:
|
|
210
|
+
with urlopen(req, timeout=15) as resp:
|
|
211
|
+
return json.loads(resp.read().decode())
|
|
212
|
+
except HTTPError as exc:
|
|
213
|
+
raw = exc.read() if exc.fp else b"{}"
|
|
214
|
+
body = json.loads(raw.decode())
|
|
215
|
+
raise XencesError(
|
|
216
|
+
body.get("error", {}).get("message", body.get("message", "Xences request failed")),
|
|
217
|
+
status=exc.code,
|
|
218
|
+
body=body,
|
|
219
|
+
) from exc
|
|
220
|
+
|
|
221
|
+
# ------------------------------------------------------------------
|
|
222
|
+
# Goals (legacy convenience methods — prefer xences.goals.create())
|
|
223
|
+
# ------------------------------------------------------------------
|
|
224
|
+
|
|
225
|
+
def create_goal(
|
|
226
|
+
self,
|
|
227
|
+
request: str,
|
|
228
|
+
principal: Optional[Principal] = None,
|
|
229
|
+
**input_data: Any,
|
|
230
|
+
) -> Any:
|
|
231
|
+
"""Submit a goal. Prefer ``xences.goals.create()`` for new code."""
|
|
232
|
+
return self.goals.create(request=request, input=input_data or None, principal=principal)
|
|
233
|
+
|
|
234
|
+
async def acreate_goal(
|
|
235
|
+
self,
|
|
236
|
+
request: str,
|
|
237
|
+
principal: Optional[Principal] = None,
|
|
238
|
+
**input_data: Any,
|
|
239
|
+
) -> Any:
|
|
240
|
+
"""Async version of ``create_goal()``."""
|
|
241
|
+
return await asyncio.to_thread(self.create_goal, request, principal, **input_data)
|
|
242
|
+
|
|
243
|
+
# ------------------------------------------------------------------
|
|
244
|
+
# Operations — expose / register
|
|
245
|
+
# ------------------------------------------------------------------
|
|
246
|
+
|
|
247
|
+
def expose(
|
|
248
|
+
self,
|
|
249
|
+
operation_id: str,
|
|
250
|
+
*,
|
|
251
|
+
method: str = "POST",
|
|
252
|
+
path: Optional[str] = None,
|
|
253
|
+
description: str = "",
|
|
254
|
+
input_schema: Optional[Dict[str, Any]] = None,
|
|
255
|
+
output_schema: Optional[Dict[str, Any]] = None,
|
|
256
|
+
endpoint: str = "/.well-known/xences/invoke",
|
|
257
|
+
) -> Callable[[Callable[..., Any]], Callable[..., Any]]:
|
|
258
|
+
"""Register an operation handler. Returns a decorator.
|
|
259
|
+
|
|
260
|
+
Functional usage::
|
|
261
|
+
|
|
262
|
+
xences.expose("orders.refund", method="POST", path="/orders/:id/refund")(
|
|
263
|
+
lambda input, ctx: orders.refund(input["orderId"])
|
|
264
|
+
)
|
|
265
|
+
|
|
266
|
+
Decorator usage::
|
|
267
|
+
|
|
268
|
+
@xences.expose("orders.refund", method="POST", path="/orders/:id/refund")
|
|
269
|
+
def refund_order(input, context):
|
|
270
|
+
return orders.refund(input["orderId"])
|
|
271
|
+
"""
|
|
272
|
+
manifest: Dict[str, Any] = {
|
|
273
|
+
"operationId": operation_id,
|
|
274
|
+
"method": method,
|
|
275
|
+
"path": path or f"/{operation_id}",
|
|
276
|
+
"endpoint": endpoint,
|
|
277
|
+
}
|
|
278
|
+
if description:
|
|
279
|
+
manifest["description"] = description
|
|
280
|
+
if input_schema is not None:
|
|
281
|
+
manifest["inputSchema"] = input_schema
|
|
282
|
+
if output_schema is not None:
|
|
283
|
+
manifest["outputSchema"] = output_schema
|
|
284
|
+
self._manifests[operation_id] = manifest
|
|
285
|
+
|
|
286
|
+
def decorator(handler: Callable[..., Any]) -> Callable[..., Any]:
|
|
287
|
+
self._operations[operation_id] = handler
|
|
288
|
+
return handler
|
|
289
|
+
|
|
290
|
+
return decorator
|
|
291
|
+
|
|
292
|
+
def register(self) -> Any:
|
|
293
|
+
"""Register all exposed operations with the platform.
|
|
294
|
+
|
|
295
|
+
Call once at application startup after all ``expose()`` calls.
|
|
296
|
+
"""
|
|
297
|
+
operations = list(self._manifests.values())
|
|
298
|
+
if not operations:
|
|
299
|
+
return {"success": True, "data": []}
|
|
300
|
+
return self._request("/api/auth/sdk/operations", {"operations": operations})
|
|
301
|
+
|
|
302
|
+
async def aregister(self) -> Any:
|
|
303
|
+
"""Async version of ``register()``."""
|
|
304
|
+
return await asyncio.to_thread(self.register)
|
|
305
|
+
|
|
306
|
+
def register_operations(self, operations: list[Dict[str, Any]]) -> Any:
|
|
307
|
+
"""Register a custom list of operations directly.
|
|
308
|
+
|
|
309
|
+
Prefer ``expose()`` + ``register()`` for new code.
|
|
310
|
+
"""
|
|
311
|
+
return self._request("/api/auth/sdk/operations", {"operations": operations})
|
|
312
|
+
|
|
313
|
+
async def aregister_operations(self, operations: list[Dict[str, Any]]) -> Any:
|
|
314
|
+
"""Async version of ``register_operations()``."""
|
|
315
|
+
return await asyncio.to_thread(self.register_operations, operations)
|
|
316
|
+
|
|
317
|
+
# ------------------------------------------------------------------
|
|
318
|
+
# Inbound invocation handler
|
|
319
|
+
# ------------------------------------------------------------------
|
|
320
|
+
|
|
321
|
+
def _verify_invocation(
|
|
322
|
+
self,
|
|
323
|
+
*,
|
|
324
|
+
operation_id: str,
|
|
325
|
+
input_data: Dict[str, Any],
|
|
326
|
+
headers: Dict[str, str],
|
|
327
|
+
) -> Callable[..., Any]:
|
|
328
|
+
"""Verify identity, timestamp freshness, replay, and signature.
|
|
329
|
+
|
|
330
|
+
Returns the registered operation handler, or raises ``XencesError``.
|
|
331
|
+
"""
|
|
332
|
+
h = {k.lower(): v for k, v in headers.items()}
|
|
333
|
+
app_id = h.get("x-xences-app-id", "").strip()
|
|
334
|
+
timestamp_str = h.get("x-xences-timestamp", "").strip()
|
|
335
|
+
nonce = h.get("x-xences-nonce", "").strip()
|
|
336
|
+
signature = h.get("x-xences-signature", "").strip()
|
|
337
|
+
|
|
338
|
+
if not app_id or app_id != self.app_id:
|
|
339
|
+
raise XencesError("Unexpected Xences app identity.", status=401)
|
|
340
|
+
|
|
341
|
+
try:
|
|
342
|
+
ts = int(timestamp_str)
|
|
343
|
+
except (ValueError, TypeError) as exc:
|
|
344
|
+
raise XencesError("Invalid or missing timestamp.", status=401) from exc
|
|
345
|
+
|
|
346
|
+
now = int(time.time())
|
|
347
|
+
if abs(now - ts) > 60:
|
|
348
|
+
raise XencesError("Expired Xences invocation.", status=401)
|
|
349
|
+
if nonce in self._invocation_nonces:
|
|
350
|
+
raise XencesError("Replayed Xences invocation.", status=401)
|
|
351
|
+
|
|
352
|
+
if self._xences_public_key and signature:
|
|
353
|
+
# Canonical form matches Node.js JSON.stringify(input)
|
|
354
|
+
canonical = json.dumps(input_data, separators=(",", ":"))
|
|
355
|
+
message = f"{operation_id}.{timestamp_str}.{nonce}.{canonical}".encode()
|
|
356
|
+
pub_key = serialization.load_pem_public_key(self._xences_public_key.encode())
|
|
357
|
+
try:
|
|
358
|
+
pub_key.verify(
|
|
359
|
+
base64.b64decode(signature),
|
|
360
|
+
message,
|
|
361
|
+
padding.PKCS1v15(),
|
|
362
|
+
hashes.SHA256(),
|
|
363
|
+
)
|
|
364
|
+
except InvalidSignature as exc:
|
|
365
|
+
raise XencesError("Invalid Xences invocation signature.", status=401) from exc
|
|
366
|
+
|
|
367
|
+
# Record nonce; purge expired entries
|
|
368
|
+
self._invocation_nonces[nonce] = float(now + 60)
|
|
369
|
+
self._invocation_nonces = {k: v for k, v in self._invocation_nonces.items() if v > now}
|
|
370
|
+
|
|
371
|
+
handler = self._operations.get(operation_id)
|
|
372
|
+
if not handler:
|
|
373
|
+
raise XencesError(f"Operation not registered: {operation_id}", status=404)
|
|
374
|
+
return handler
|
|
375
|
+
|
|
376
|
+
def handle_request(
|
|
377
|
+
self,
|
|
378
|
+
*,
|
|
379
|
+
body: Dict[str, Any],
|
|
380
|
+
headers: Dict[str, str],
|
|
381
|
+
) -> Dict[str, Any]:
|
|
382
|
+
"""Verify and dispatch an inbound platform invocation (sync).
|
|
383
|
+
|
|
384
|
+
Raises ``XencesError`` on authentication or validation failure.
|
|
385
|
+
|
|
386
|
+
Flask example::
|
|
387
|
+
|
|
388
|
+
@app.post("/.well-known/xences/invoke")
|
|
389
|
+
def invoke():
|
|
390
|
+
try:
|
|
391
|
+
return xences.handle_request(
|
|
392
|
+
body=request.get_json(),
|
|
393
|
+
headers=dict(request.headers),
|
|
394
|
+
)
|
|
395
|
+
except XencesError as e:
|
|
396
|
+
return {"success": False, "error": str(e)}, e.status or 500
|
|
397
|
+
"""
|
|
398
|
+
operation_id = str(body.get("operationId", "")).strip()
|
|
399
|
+
input_data: Dict[str, Any] = body.get("input") or {}
|
|
400
|
+
context: Dict[str, Any] = {
|
|
401
|
+
"goalId": str(body.get("goalId") or ""),
|
|
402
|
+
"actionId": str(body.get("actionId") or ""),
|
|
403
|
+
"principal": body.get("principal"),
|
|
404
|
+
"traceId": str(body.get("traceId") or ""),
|
|
405
|
+
}
|
|
406
|
+
handler = self._verify_invocation(
|
|
407
|
+
operation_id=operation_id, input_data=input_data, headers=headers
|
|
408
|
+
)
|
|
409
|
+
result = handler(input_data, context)
|
|
410
|
+
return {"success": True, "operationId": operation_id, "result": result}
|
|
411
|
+
|
|
412
|
+
async def ahandle_request(
|
|
413
|
+
self,
|
|
414
|
+
*,
|
|
415
|
+
body: Dict[str, Any],
|
|
416
|
+
headers: Dict[str, str],
|
|
417
|
+
) -> Dict[str, Any]:
|
|
418
|
+
"""Verify and dispatch an inbound platform invocation (async).
|
|
419
|
+
|
|
420
|
+
Awaits the handler result if it is a coroutine.
|
|
421
|
+
|
|
422
|
+
FastAPI example::
|
|
423
|
+
|
|
424
|
+
@app.post("/.well-known/xences/invoke")
|
|
425
|
+
async def invoke(request: Request):
|
|
426
|
+
try:
|
|
427
|
+
return await xences.ahandle_request(
|
|
428
|
+
body=await request.json(),
|
|
429
|
+
headers=dict(request.headers),
|
|
430
|
+
)
|
|
431
|
+
except XencesError as e:
|
|
432
|
+
return JSONResponse(
|
|
433
|
+
{"success": False, "error": str(e)},
|
|
434
|
+
status_code=e.status or 500,
|
|
435
|
+
)
|
|
436
|
+
"""
|
|
437
|
+
operation_id = str(body.get("operationId", "")).strip()
|
|
438
|
+
input_data: Dict[str, Any] = body.get("input") or {}
|
|
439
|
+
context: Dict[str, Any] = {
|
|
440
|
+
"goalId": str(body.get("goalId") or ""),
|
|
441
|
+
"actionId": str(body.get("actionId") or ""),
|
|
442
|
+
"principal": body.get("principal"),
|
|
443
|
+
"traceId": str(body.get("traceId") or ""),
|
|
444
|
+
}
|
|
445
|
+
handler = self._verify_invocation(
|
|
446
|
+
operation_id=operation_id, input_data=input_data, headers=headers
|
|
447
|
+
)
|
|
448
|
+
result = handler(input_data, context)
|
|
449
|
+
if asyncio.iscoroutine(result):
|
|
450
|
+
result = await result
|
|
451
|
+
return {"success": True, "operationId": operation_id, "result": result}
|
|
452
|
+
|
|
453
|
+
def handle_invocation(
|
|
454
|
+
self,
|
|
455
|
+
operation_id: str,
|
|
456
|
+
input_data: Dict[str, Any],
|
|
457
|
+
context: Dict[str, Any],
|
|
458
|
+
) -> Any:
|
|
459
|
+
"""Dispatch to a registered operation directly, without HTTP validation.
|
|
460
|
+
|
|
461
|
+
.. deprecated::
|
|
462
|
+
Prefer ``handle_request()`` which includes full signature verification.
|
|
463
|
+
"""
|
|
464
|
+
handler = self._operations.get(operation_id)
|
|
465
|
+
if not handler:
|
|
466
|
+
raise XencesError(f"Operation not registered: {operation_id}", status=404)
|
|
467
|
+
return handler(input_data, context)
|
|
468
|
+
|
|
469
|
+
# ------------------------------------------------------------------
|
|
470
|
+
# Key rotation
|
|
471
|
+
# ------------------------------------------------------------------
|
|
472
|
+
|
|
473
|
+
def rotate_key(self, public_key: str) -> Any:
|
|
474
|
+
"""Rotate the application public key registered with the platform.
|
|
475
|
+
|
|
476
|
+
Call this *after* registering the new public key with the platform operator.
|
|
477
|
+
Only available when ``allow_key_rotation=True`` was passed to the constructor.
|
|
478
|
+
"""
|
|
479
|
+
if not self._allow_key_rotation:
|
|
480
|
+
raise RuntimeError(
|
|
481
|
+
"Client key rotation is disabled. Set allow_key_rotation=True to opt in."
|
|
482
|
+
)
|
|
483
|
+
return self._request("/api/auth/sdk/rotate-key", {"publicKey": public_key})
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: xences-ai
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Python SDK for the Xences AI execution runtime
|
|
5
|
+
License: MIT
|
|
6
|
+
Classifier: Development Status :: 3 - Alpha
|
|
7
|
+
Classifier: Intended Audience :: Developers
|
|
8
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
9
|
+
Classifier: Programming Language :: Python :: 3
|
|
10
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
11
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
13
|
+
Classifier: Typing :: Typed
|
|
14
|
+
Requires-Python: >=3.10
|
|
15
|
+
Description-Content-Type: text/markdown
|
|
16
|
+
Requires-Dist: cryptography>=42.0
|
|
17
|
+
Provides-Extra: dev
|
|
18
|
+
Requires-Dist: pytest>=8.0; extra == "dev"
|
|
19
|
+
|
|
20
|
+
# xences-ai
|
|
21
|
+
|
|
22
|
+
Python SDK for the Xences AI execution runtime.
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
pip install xences-ai
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
The client signs authentication requests with your application's RSA private key. Keep the private key in a server-side secret store — never expose it to clients or ship it in a browser or mobile application.
|
|
29
|
+
|
|
30
|
+
## Connect and send a goal
|
|
31
|
+
|
|
32
|
+
```python
|
|
33
|
+
import os
|
|
34
|
+
from xences_ai import Xences, Principal
|
|
35
|
+
|
|
36
|
+
xences = Xences(
|
|
37
|
+
app_id=os.environ["XENCES_APP_ID"],
|
|
38
|
+
private_key=os.environ["XENCES_PRIVATE_KEY"],
|
|
39
|
+
base_url="https://xences.app",
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
result = xences.goals.create(
|
|
43
|
+
request="Reschedule my appointment to Friday",
|
|
44
|
+
principal=Principal(external_id=user.id), # "human" is the default type
|
|
45
|
+
)
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
For async applications use `await xences.goals.acreate(...)`.
|
|
49
|
+
|
|
50
|
+
## Register operations
|
|
51
|
+
|
|
52
|
+
Expose handlers for operations the platform can invoke on your application, then register them once at startup:
|
|
53
|
+
|
|
54
|
+
```python
|
|
55
|
+
xences.expose(
|
|
56
|
+
"orders.refund",
|
|
57
|
+
method="POST",
|
|
58
|
+
path="/orders/:id/refund",
|
|
59
|
+
input_schema={
|
|
60
|
+
"type": "object",
|
|
61
|
+
"required": ["orderId"],
|
|
62
|
+
"properties": {"orderId": {"type": "string"}},
|
|
63
|
+
},
|
|
64
|
+
)(lambda input, context: orders.refund(input["orderId"]))
|
|
65
|
+
|
|
66
|
+
xences.register()
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
The decorator form is also supported:
|
|
70
|
+
|
|
71
|
+
```python
|
|
72
|
+
@xences.expose("orders.refund", method="POST", path="/orders/:id/refund")
|
|
73
|
+
def refund_order(input, context):
|
|
74
|
+
return orders.refund(input["orderId"])
|
|
75
|
+
|
|
76
|
+
xences.register()
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
## Handle inbound invocations
|
|
80
|
+
|
|
81
|
+
After registering operations, mount a route so the platform can call back into your app. Pass `xences_public_key` to enable signature verification on inbound requests:
|
|
82
|
+
|
|
83
|
+
```python
|
|
84
|
+
xences = Xences(
|
|
85
|
+
app_id=os.environ["XENCES_APP_ID"],
|
|
86
|
+
private_key=os.environ["XENCES_PRIVATE_KEY"],
|
|
87
|
+
xences_public_key=os.environ["XENCES_PUBLIC_KEY"], # verifies inbound calls
|
|
88
|
+
base_url="https://xences.app",
|
|
89
|
+
)
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
The default invoke path (`/.well-known/xences/invoke`) is sent to the platform when you call `register()`. Mount it in your web framework:
|
|
93
|
+
|
|
94
|
+
### Flask
|
|
95
|
+
|
|
96
|
+
```python
|
|
97
|
+
from flask import Flask, request
|
|
98
|
+
from xences_ai import XencesError
|
|
99
|
+
|
|
100
|
+
app = Flask(__name__)
|
|
101
|
+
|
|
102
|
+
@app.post("/.well-known/xences/invoke")
|
|
103
|
+
def invoke():
|
|
104
|
+
try:
|
|
105
|
+
return xences.handle_request(
|
|
106
|
+
body=request.get_json(),
|
|
107
|
+
headers=dict(request.headers),
|
|
108
|
+
)
|
|
109
|
+
except XencesError as e:
|
|
110
|
+
return {"success": False, "error": str(e)}, e.status or 500
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
### FastAPI
|
|
114
|
+
|
|
115
|
+
```python
|
|
116
|
+
from fastapi import FastAPI, Request
|
|
117
|
+
from fastapi.responses import JSONResponse
|
|
118
|
+
from xences_ai import XencesError
|
|
119
|
+
|
|
120
|
+
app = FastAPI()
|
|
121
|
+
|
|
122
|
+
@app.post("/.well-known/xences/invoke")
|
|
123
|
+
async def invoke(request: Request):
|
|
124
|
+
try:
|
|
125
|
+
return await xences.ahandle_request(
|
|
126
|
+
body=await request.json(),
|
|
127
|
+
headers=dict(request.headers),
|
|
128
|
+
)
|
|
129
|
+
except XencesError as e:
|
|
130
|
+
return JSONResponse(
|
|
131
|
+
{"success": False, "error": str(e)},
|
|
132
|
+
status_code=e.status or 500,
|
|
133
|
+
)
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
`handle_request` and `ahandle_request` verify the platform's app identity, timestamp freshness, replay protection, and RSA signature before dispatching. They raise `XencesError` with a `status` code on any failure.
|
|
137
|
+
|
|
138
|
+
## Key rotation
|
|
139
|
+
|
|
140
|
+
Rotation is opt-in. Set `allow_key_rotation=True` and call `rotate_key()` only after registering the new public key with the platform operator:
|
|
141
|
+
|
|
142
|
+
```python
|
|
143
|
+
xences = Xences(
|
|
144
|
+
...,
|
|
145
|
+
allow_key_rotation=True,
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
xences.rotate_key(new_public_key_pem)
|
|
149
|
+
```
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
README.md
|
|
2
|
+
pyproject.toml
|
|
3
|
+
src/xences_ai/__init__.py
|
|
4
|
+
src/xences_ai/client.py
|
|
5
|
+
src/xences_ai.egg-info/PKG-INFO
|
|
6
|
+
src/xences_ai.egg-info/SOURCES.txt
|
|
7
|
+
src/xences_ai.egg-info/dependency_links.txt
|
|
8
|
+
src/xences_ai.egg-info/requires.txt
|
|
9
|
+
src/xences_ai.egg-info/top_level.txt
|
|
10
|
+
tests/test_client.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
xences_ai
|
|
@@ -0,0 +1,374 @@
|
|
|
1
|
+
"""Tests for xences_sdk.client — mirrors the Node.js SDK test coverage."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import base64
|
|
7
|
+
import json
|
|
8
|
+
import time
|
|
9
|
+
import unittest
|
|
10
|
+
import uuid
|
|
11
|
+
from unittest.mock import MagicMock, patch
|
|
12
|
+
|
|
13
|
+
from cryptography.hazmat.primitives import hashes, serialization
|
|
14
|
+
from cryptography.hazmat.primitives.asymmetric import padding, rsa
|
|
15
|
+
|
|
16
|
+
from xences_ai import Principal, Xences, XencesError
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
# ---------------------------------------------------------------------------
|
|
20
|
+
# Shared key material — generated once for the whole module
|
|
21
|
+
# ---------------------------------------------------------------------------
|
|
22
|
+
|
|
23
|
+
def _generate_rsa_pair():
|
|
24
|
+
private_key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
|
|
25
|
+
private_pem = private_key.private_bytes(
|
|
26
|
+
serialization.Encoding.PEM,
|
|
27
|
+
serialization.PrivateFormat.PKCS8,
|
|
28
|
+
serialization.NoEncryption(),
|
|
29
|
+
).decode()
|
|
30
|
+
public_pem = private_key.public_key().public_bytes(
|
|
31
|
+
serialization.Encoding.PEM,
|
|
32
|
+
serialization.PublicFormat.SubjectPublicKeyInfo,
|
|
33
|
+
).decode()
|
|
34
|
+
return private_pem, public_pem, private_key
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
PRIVATE_PEM, PUBLIC_PEM, _PRIVATE_KEY_OBJ = _generate_rsa_pair()
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _make_invocation_headers(
|
|
41
|
+
operation_id: str,
|
|
42
|
+
input_data: dict,
|
|
43
|
+
*,
|
|
44
|
+
app_id: str = "app_test",
|
|
45
|
+
private_key=None,
|
|
46
|
+
) -> dict:
|
|
47
|
+
"""Build the signed headers the platform would send for a callback invocation."""
|
|
48
|
+
private_key = private_key or _PRIVATE_KEY_OBJ
|
|
49
|
+
ts = str(int(time.time()))
|
|
50
|
+
nonce = str(uuid.uuid4())
|
|
51
|
+
canonical = json.dumps(input_data, separators=(",", ":"))
|
|
52
|
+
message = f"{operation_id}.{ts}.{nonce}.{canonical}".encode()
|
|
53
|
+
sig = private_key.sign(message, padding.PKCS1v15(), hashes.SHA256())
|
|
54
|
+
return {
|
|
55
|
+
"x-xences-app-id": app_id,
|
|
56
|
+
"x-xences-timestamp": ts,
|
|
57
|
+
"x-xences-nonce": nonce,
|
|
58
|
+
"x-xences-signature": base64.b64encode(sig).decode(),
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _make_sdk(**kwargs) -> Xences:
|
|
63
|
+
defaults = dict(app_id="app_test", private_key=PRIVATE_PEM, base_url="https://xences.test")
|
|
64
|
+
defaults.update(kwargs)
|
|
65
|
+
return Xences(**defaults)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
# ---------------------------------------------------------------------------
|
|
69
|
+
# Constructor validation
|
|
70
|
+
# ---------------------------------------------------------------------------
|
|
71
|
+
|
|
72
|
+
class TestConstructor(unittest.TestCase):
|
|
73
|
+
def test_rejects_non_pem_private_key(self):
|
|
74
|
+
with self.assertRaises(ValueError):
|
|
75
|
+
_make_sdk(private_key="not-a-key")
|
|
76
|
+
|
|
77
|
+
def test_rejects_public_key_as_private(self):
|
|
78
|
+
# A public key is valid PEM but not an RSA private key
|
|
79
|
+
with self.assertRaises(ValueError):
|
|
80
|
+
_make_sdk(private_key=PUBLIC_PEM)
|
|
81
|
+
|
|
82
|
+
def test_rejects_empty_app_id(self):
|
|
83
|
+
with self.assertRaises(ValueError):
|
|
84
|
+
_make_sdk(app_id="")
|
|
85
|
+
|
|
86
|
+
def test_accepts_valid_rsa_private_key(self):
|
|
87
|
+
sdk = _make_sdk()
|
|
88
|
+
self.assertEqual(sdk.app_id, "app_test")
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
# ---------------------------------------------------------------------------
|
|
92
|
+
# Token caching
|
|
93
|
+
# ---------------------------------------------------------------------------
|
|
94
|
+
|
|
95
|
+
class TestTokenCaching(unittest.TestCase):
|
|
96
|
+
def test_valid_cached_token_is_reused(self):
|
|
97
|
+
sdk = _make_sdk()
|
|
98
|
+
sdk._token = "cached_token"
|
|
99
|
+
sdk._token_expires_at = time.time() + 300
|
|
100
|
+
|
|
101
|
+
with patch.object(sdk, "_authenticate") as mock_auth:
|
|
102
|
+
token1 = sdk._access_token()
|
|
103
|
+
token2 = sdk._access_token()
|
|
104
|
+
|
|
105
|
+
mock_auth.assert_not_called()
|
|
106
|
+
self.assertEqual(token1, "cached_token")
|
|
107
|
+
self.assertEqual(token2, "cached_token")
|
|
108
|
+
|
|
109
|
+
def test_expired_token_triggers_reauthentication(self):
|
|
110
|
+
sdk = _make_sdk()
|
|
111
|
+
sdk._token = "old_token"
|
|
112
|
+
sdk._token_expires_at = time.time() - 1 # already expired
|
|
113
|
+
|
|
114
|
+
with patch.object(sdk, "_authenticate", return_value="new_token") as mock_auth:
|
|
115
|
+
token = sdk._access_token()
|
|
116
|
+
|
|
117
|
+
mock_auth.assert_called_once()
|
|
118
|
+
self.assertEqual(token, "new_token")
|
|
119
|
+
|
|
120
|
+
def test_multiple_access_token_calls_use_single_authenticate(self):
|
|
121
|
+
"""Successive _access_token() calls should only trigger _authenticate() once."""
|
|
122
|
+
sdk = _make_sdk()
|
|
123
|
+
auth_calls = 0
|
|
124
|
+
|
|
125
|
+
def fake_authenticate():
|
|
126
|
+
nonlocal auth_calls
|
|
127
|
+
auth_calls += 1
|
|
128
|
+
sdk._token = "tok"
|
|
129
|
+
sdk._token_expires_at = time.time() + 300
|
|
130
|
+
return "tok"
|
|
131
|
+
|
|
132
|
+
with patch.object(sdk, "_authenticate", side_effect=fake_authenticate):
|
|
133
|
+
sdk._access_token() # triggers auth, caches token
|
|
134
|
+
sdk._access_token() # reuses cached token
|
|
135
|
+
sdk._access_token() # reuses cached token
|
|
136
|
+
|
|
137
|
+
self.assertEqual(auth_calls, 1)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
# ---------------------------------------------------------------------------
|
|
141
|
+
# Goals namespace
|
|
142
|
+
# ---------------------------------------------------------------------------
|
|
143
|
+
|
|
144
|
+
class TestGoalsNamespace(unittest.TestCase):
|
|
145
|
+
def test_goals_create_defaults_to_human_principal_type(self):
|
|
146
|
+
sdk = _make_sdk()
|
|
147
|
+
captured = {}
|
|
148
|
+
|
|
149
|
+
def fake_request(path, payload, principal=None):
|
|
150
|
+
captured["principal"] = principal
|
|
151
|
+
return {"success": True}
|
|
152
|
+
|
|
153
|
+
with patch.object(sdk, "_request", side_effect=fake_request):
|
|
154
|
+
sdk.goals.create(request="Do something", principal=Principal(external_id="u1"))
|
|
155
|
+
|
|
156
|
+
self.assertEqual(captured["principal"].type, "human")
|
|
157
|
+
|
|
158
|
+
def test_goals_create_sends_effect_in_metadata(self):
|
|
159
|
+
sdk = _make_sdk()
|
|
160
|
+
captured = {}
|
|
161
|
+
|
|
162
|
+
def fake_request(path, payload, principal=None):
|
|
163
|
+
captured["payload"] = payload
|
|
164
|
+
return {"success": True}
|
|
165
|
+
|
|
166
|
+
with patch.object(sdk, "_request", side_effect=fake_request):
|
|
167
|
+
sdk.goals.create(effect="send.email")
|
|
168
|
+
|
|
169
|
+
self.assertIn("effect", captured["payload"]["metadata"])
|
|
170
|
+
self.assertEqual(captured["payload"]["metadata"]["effect"], "send.email")
|
|
171
|
+
|
|
172
|
+
def test_goals_create_falls_back_to_sdk_app_principal(self):
|
|
173
|
+
sdk = _make_sdk()
|
|
174
|
+
captured = {}
|
|
175
|
+
|
|
176
|
+
def fake_request(path, payload, principal=None):
|
|
177
|
+
captured["principal"] = principal
|
|
178
|
+
return {}
|
|
179
|
+
|
|
180
|
+
with patch.object(sdk, "_request", side_effect=fake_request):
|
|
181
|
+
sdk.goals.create(request="Do something")
|
|
182
|
+
|
|
183
|
+
self.assertEqual(captured["principal"].external_id, "sdk-app")
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
# ---------------------------------------------------------------------------
|
|
187
|
+
# expose() and register()
|
|
188
|
+
# ---------------------------------------------------------------------------
|
|
189
|
+
|
|
190
|
+
class TestExposeAndRegister(unittest.TestCase):
|
|
191
|
+
def test_decorator_form_registers_handler(self):
|
|
192
|
+
sdk = _make_sdk()
|
|
193
|
+
|
|
194
|
+
@sdk.expose("ping", method="POST", path="/ping")
|
|
195
|
+
def ping(input, context):
|
|
196
|
+
return "pong"
|
|
197
|
+
|
|
198
|
+
self.assertIn("ping", sdk._operations)
|
|
199
|
+
self.assertEqual(sdk._operations["ping"]({}, {}), "pong")
|
|
200
|
+
|
|
201
|
+
def test_functional_form_registers_handler(self):
|
|
202
|
+
sdk = _make_sdk()
|
|
203
|
+
sdk.expose("ping", method="POST", path="/ping")(lambda i, c: "pong")
|
|
204
|
+
self.assertIn("ping", sdk._operations)
|
|
205
|
+
self.assertEqual(sdk._operations["ping"]({}, {}), "pong")
|
|
206
|
+
|
|
207
|
+
def test_expose_stores_manifest(self):
|
|
208
|
+
sdk = _make_sdk()
|
|
209
|
+
sdk.expose("orders.refund", method="POST", path="/orders/:id/refund")( lambda i, c: None)
|
|
210
|
+
manifest = sdk._manifests["orders.refund"]
|
|
211
|
+
self.assertEqual(manifest["operationId"], "orders.refund")
|
|
212
|
+
self.assertEqual(manifest["method"], "POST")
|
|
213
|
+
self.assertEqual(manifest["path"], "/orders/:id/refund")
|
|
214
|
+
|
|
215
|
+
def test_register_returns_empty_success_when_no_operations(self):
|
|
216
|
+
sdk = _make_sdk()
|
|
217
|
+
result = sdk.register()
|
|
218
|
+
self.assertTrue(result["success"])
|
|
219
|
+
self.assertEqual(result.get("data", []), [])
|
|
220
|
+
|
|
221
|
+
def test_register_calls_platform_with_manifests(self):
|
|
222
|
+
sdk = _make_sdk()
|
|
223
|
+
sdk.expose("ping", method="POST", path="/ping")(lambda i, c: None)
|
|
224
|
+
captured = {}
|
|
225
|
+
|
|
226
|
+
def fake_request(path, payload, principal=None):
|
|
227
|
+
captured["path"] = path
|
|
228
|
+
captured["payload"] = payload
|
|
229
|
+
return {"success": True}
|
|
230
|
+
|
|
231
|
+
with patch.object(sdk, "_request", side_effect=fake_request):
|
|
232
|
+
sdk.register()
|
|
233
|
+
|
|
234
|
+
self.assertEqual(captured["path"], "/api/auth/sdk/operations")
|
|
235
|
+
ops = captured["payload"]["operations"]
|
|
236
|
+
self.assertEqual(len(ops), 1)
|
|
237
|
+
self.assertEqual(ops[0]["operationId"], "ping")
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
# ---------------------------------------------------------------------------
|
|
241
|
+
# handle_request — sync inbound invocation handler
|
|
242
|
+
# ---------------------------------------------------------------------------
|
|
243
|
+
|
|
244
|
+
class TestHandleRequest(unittest.TestCase):
|
|
245
|
+
def _make_verified_sdk(self) -> Xences:
|
|
246
|
+
return _make_sdk(xences_public_key=PUBLIC_PEM)
|
|
247
|
+
|
|
248
|
+
def test_dispatches_registered_operation(self):
|
|
249
|
+
sdk = self._make_verified_sdk()
|
|
250
|
+
sdk.expose("health.check", method="POST", path="/health")(lambda i, c: {"ok": True})
|
|
251
|
+
headers = _make_invocation_headers("health.check", {})
|
|
252
|
+
result = sdk.handle_request(body={"operationId": "health.check", "input": {}}, headers=headers)
|
|
253
|
+
self.assertTrue(result["success"])
|
|
254
|
+
self.assertEqual(result["result"], {"ok": True})
|
|
255
|
+
self.assertEqual(result["operationId"], "health.check")
|
|
256
|
+
|
|
257
|
+
def test_rejects_wrong_app_id(self):
|
|
258
|
+
sdk = self._make_verified_sdk()
|
|
259
|
+
sdk.expose("orders.refund", method="POST", path="/orders/:id/refund")(lambda i, c: None)
|
|
260
|
+
headers = _make_invocation_headers("orders.refund", {"orderId": "42"}, app_id="app_other")
|
|
261
|
+
with self.assertRaises(XencesError) as ctx:
|
|
262
|
+
sdk.handle_request(
|
|
263
|
+
body={"operationId": "orders.refund", "input": {"orderId": "42"}},
|
|
264
|
+
headers=headers,
|
|
265
|
+
)
|
|
266
|
+
self.assertEqual(ctx.exception.status, 401)
|
|
267
|
+
|
|
268
|
+
def test_rejects_replayed_nonce(self):
|
|
269
|
+
sdk = self._make_verified_sdk()
|
|
270
|
+
sdk.expose("health.check", method="POST", path="/health")(lambda i, c: {"ok": True})
|
|
271
|
+
headers = _make_invocation_headers("health.check", {})
|
|
272
|
+
body = {"operationId": "health.check", "input": {}}
|
|
273
|
+
sdk.handle_request(body=body, headers=headers) # first call — OK
|
|
274
|
+
with self.assertRaises(XencesError) as ctx:
|
|
275
|
+
sdk.handle_request(body=body, headers=headers) # replay — must fail
|
|
276
|
+
self.assertEqual(ctx.exception.status, 401)
|
|
277
|
+
|
|
278
|
+
def test_rejects_unregistered_operation(self):
|
|
279
|
+
sdk = self._make_verified_sdk()
|
|
280
|
+
headers = _make_invocation_headers("unknown.op", {})
|
|
281
|
+
with self.assertRaises(XencesError) as ctx:
|
|
282
|
+
sdk.handle_request(body={"operationId": "unknown.op", "input": {}}, headers=headers)
|
|
283
|
+
self.assertEqual(ctx.exception.status, 404)
|
|
284
|
+
|
|
285
|
+
def test_headers_are_case_insensitive(self):
|
|
286
|
+
sdk = self._make_verified_sdk()
|
|
287
|
+
sdk.expose("ping", method="POST", path="/ping")(lambda i, c: "pong")
|
|
288
|
+
h = _make_invocation_headers("ping", {})
|
|
289
|
+
# Upper-case all header names
|
|
290
|
+
upper_h = {k.upper(): v for k, v in h.items()}
|
|
291
|
+
result = sdk.handle_request(body={"operationId": "ping", "input": {}}, headers=upper_h)
|
|
292
|
+
self.assertTrue(result["success"])
|
|
293
|
+
|
|
294
|
+
def test_passes_context_to_handler(self):
|
|
295
|
+
sdk = self._make_verified_sdk()
|
|
296
|
+
received_context: dict = {}
|
|
297
|
+
|
|
298
|
+
def handler(inp, ctx):
|
|
299
|
+
received_context.update(ctx)
|
|
300
|
+
return None
|
|
301
|
+
|
|
302
|
+
sdk.expose("ctx.check", method="POST", path="/ctx")(handler)
|
|
303
|
+
headers = _make_invocation_headers("ctx.check", {})
|
|
304
|
+
sdk.handle_request(
|
|
305
|
+
body={
|
|
306
|
+
"operationId": "ctx.check",
|
|
307
|
+
"input": {},
|
|
308
|
+
"goalId": "goal-1",
|
|
309
|
+
"traceId": "trace-99",
|
|
310
|
+
},
|
|
311
|
+
headers=headers,
|
|
312
|
+
)
|
|
313
|
+
self.assertEqual(received_context["goalId"], "goal-1")
|
|
314
|
+
self.assertEqual(received_context["traceId"], "trace-99")
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
# ---------------------------------------------------------------------------
|
|
318
|
+
# ahandle_request — async inbound invocation handler
|
|
319
|
+
# ---------------------------------------------------------------------------
|
|
320
|
+
|
|
321
|
+
class TestAsyncHandleRequest(unittest.IsolatedAsyncioTestCase):
|
|
322
|
+
async def test_ahandle_request_dispatches_sync_handler(self):
|
|
323
|
+
sdk = _make_sdk(xences_public_key=PUBLIC_PEM)
|
|
324
|
+
sdk.expose("ping", method="POST", path="/ping")(lambda i, c: "pong")
|
|
325
|
+
headers = _make_invocation_headers("ping", {})
|
|
326
|
+
result = await sdk.ahandle_request(body={"operationId": "ping", "input": {}}, headers=headers)
|
|
327
|
+
self.assertTrue(result["success"])
|
|
328
|
+
self.assertEqual(result["result"], "pong")
|
|
329
|
+
|
|
330
|
+
async def test_ahandle_request_awaits_async_handler(self):
|
|
331
|
+
sdk = _make_sdk(xences_public_key=PUBLIC_PEM)
|
|
332
|
+
|
|
333
|
+
@sdk.expose("async.op", method="POST", path="/async")
|
|
334
|
+
async def async_handler(inp, ctx):
|
|
335
|
+
await asyncio.sleep(0)
|
|
336
|
+
return {"async": True}
|
|
337
|
+
|
|
338
|
+
headers = _make_invocation_headers("async.op", {})
|
|
339
|
+
result = await sdk.ahandle_request(
|
|
340
|
+
body={"operationId": "async.op", "input": {}}, headers=headers
|
|
341
|
+
)
|
|
342
|
+
self.assertTrue(result["success"])
|
|
343
|
+
self.assertEqual(result["result"], {"async": True})
|
|
344
|
+
|
|
345
|
+
async def test_ahandle_request_rejects_wrong_app_id(self):
|
|
346
|
+
sdk = _make_sdk(xences_public_key=PUBLIC_PEM)
|
|
347
|
+
sdk.expose("ping", method="POST", path="/ping")(lambda i, c: "pong")
|
|
348
|
+
headers = _make_invocation_headers("ping", {}, app_id="wrong_app")
|
|
349
|
+
with self.assertRaises(XencesError) as ctx:
|
|
350
|
+
await sdk.ahandle_request(body={"operationId": "ping", "input": {}}, headers=headers)
|
|
351
|
+
self.assertEqual(ctx.exception.status, 401)
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
# ---------------------------------------------------------------------------
|
|
355
|
+
# Key rotation
|
|
356
|
+
# ---------------------------------------------------------------------------
|
|
357
|
+
|
|
358
|
+
class TestKeyRotation(unittest.TestCase):
|
|
359
|
+
def test_rotate_key_raises_when_not_enabled(self):
|
|
360
|
+
sdk = _make_sdk() # allow_key_rotation defaults to False
|
|
361
|
+
with self.assertRaises(RuntimeError):
|
|
362
|
+
sdk.rotate_key("new-public-key-pem")
|
|
363
|
+
|
|
364
|
+
def test_rotate_key_calls_platform_when_enabled(self):
|
|
365
|
+
sdk = _make_sdk(allow_key_rotation=True)
|
|
366
|
+
with patch.object(sdk, "_request", return_value={"success": True}) as mock_req:
|
|
367
|
+
sdk.rotate_key("new-public-key-pem")
|
|
368
|
+
mock_req.assert_called_once_with(
|
|
369
|
+
"/api/auth/sdk/rotate-key", {"publicKey": "new-public-key-pem"}
|
|
370
|
+
)
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
if __name__ == "__main__":
|
|
374
|
+
unittest.main()
|