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.
@@ -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,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,5 @@
1
+ """Xences AI Python SDK."""
2
+
3
+ from .client import Xences, XencesError, Principal
4
+
5
+ __all__ = ["Xences", "XencesError", "Principal"]
@@ -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,4 @@
1
+ cryptography>=42.0
2
+
3
+ [dev]
4
+ pytest>=8.0
@@ -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()