nxtid-sdk 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.
- nxtid_sdk-0.1.0/PKG-INFO +6 -0
- nxtid_sdk-0.1.0/README.md +319 -0
- nxtid_sdk-0.1.0/nxtid_sdk/__init__.py +5 -0
- nxtid_sdk-0.1.0/nxtid_sdk/client.py +355 -0
- nxtid_sdk-0.1.0/nxtid_sdk/entra.py +91 -0
- nxtid_sdk-0.1.0/nxtid_sdk/exceptions.py +24 -0
- nxtid_sdk-0.1.0/nxtid_sdk.egg-info/PKG-INFO +6 -0
- nxtid_sdk-0.1.0/nxtid_sdk.egg-info/SOURCES.txt +11 -0
- nxtid_sdk-0.1.0/nxtid_sdk.egg-info/dependency_links.txt +1 -0
- nxtid_sdk-0.1.0/nxtid_sdk.egg-info/requires.txt +1 -0
- nxtid_sdk-0.1.0/nxtid_sdk.egg-info/top_level.txt +1 -0
- nxtid_sdk-0.1.0/pyproject.toml +16 -0
- nxtid_sdk-0.1.0/setup.cfg +4 -0
nxtid_sdk-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,319 @@
|
|
|
1
|
+
# nxtID Python SDK
|
|
2
|
+
|
|
3
|
+
Python SDK for integrating **MetaKeep wallet**, **AuthID biometric IDV**, and **nxtlinq AIT on-chain permissions** into AI agents.
|
|
4
|
+
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
## Installation
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
pip install nxtid-sdk
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
---
|
|
14
|
+
|
|
15
|
+
## Quick Start
|
|
16
|
+
|
|
17
|
+
```python
|
|
18
|
+
from nxtid_sdk import NxtidSDK
|
|
19
|
+
|
|
20
|
+
sdk = NxtidSDK(
|
|
21
|
+
api_key="your-api-key",
|
|
22
|
+
api_secret="your-api-secret",
|
|
23
|
+
env="production", # "production" (default) or "staging"
|
|
24
|
+
service_id="your-service-id", # required for log_chat
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
# 1. Register your agent's tools on startup
|
|
28
|
+
await sdk.register_tools([
|
|
29
|
+
{"name": "get_account_balance", "description": "Read-only finance access"},
|
|
30
|
+
{"name": "transfer_funds", "description": "Move money between accounts"},
|
|
31
|
+
])
|
|
32
|
+
|
|
33
|
+
# 2. Connect wallet by email
|
|
34
|
+
wallet = await sdk.connect_wallet(email="user@example.com")
|
|
35
|
+
# → WalletResult(wallet_address="0x...", email="user@example.com")
|
|
36
|
+
|
|
37
|
+
# 3. Start AuthID identity verification
|
|
38
|
+
verify = await sdk.start_verification(wallet.wallet_address)
|
|
39
|
+
# → VerifyStartResult(flow="login"|"onboarding"|"choose_document", widget_url="...", ...)
|
|
40
|
+
|
|
41
|
+
# 4. After user completes the widget
|
|
42
|
+
result = await sdk.complete_verification(
|
|
43
|
+
wallet.wallet_address,
|
|
44
|
+
operation_id=verify.operation_id, # for onboarding flow
|
|
45
|
+
transaction_id=verify.transaction_id, # for login flow
|
|
46
|
+
)
|
|
47
|
+
# → VerifyCompleteResult(is_verified=True)
|
|
48
|
+
|
|
49
|
+
# 5. Check admin-granted permissions
|
|
50
|
+
perms = await sdk.get_permissions(wallet.wallet_address)
|
|
51
|
+
if perms.has_permission("transfer_funds"):
|
|
52
|
+
...
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
---
|
|
56
|
+
|
|
57
|
+
## Environments
|
|
58
|
+
|
|
59
|
+
| env | AIT Service | Dashboard |
|
|
60
|
+
|---------------|----------------------------------------|-------------------------------------|
|
|
61
|
+
| `production` | `https://ait-service.nxtlinq.ai` | `https://dashboard.nxtlinq.ai` |
|
|
62
|
+
| `staging` | `https://staging-ait-service.nxtlinq.ai` | `https://dashboard-staging.nxtlinq.ai` |
|
|
63
|
+
|
|
64
|
+
---
|
|
65
|
+
|
|
66
|
+
## API Reference
|
|
67
|
+
|
|
68
|
+
### `NxtidSDK`
|
|
69
|
+
|
|
70
|
+
```python
|
|
71
|
+
NxtidSDK(
|
|
72
|
+
api_key: str,
|
|
73
|
+
api_secret: str,
|
|
74
|
+
env: str = "production",
|
|
75
|
+
service_id: Optional[str] = None,
|
|
76
|
+
)
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
Credentials (`api_key`, `api_secret`, `service_id`) are obtained from nxtID Dashboard → **Services → Register Third-party Agent**.
|
|
80
|
+
|
|
81
|
+
---
|
|
82
|
+
|
|
83
|
+
#### `connect_wallet(email) → WalletResult`
|
|
84
|
+
|
|
85
|
+
Gets or creates a MetaKeep non-custodial wallet for the given email address.
|
|
86
|
+
|
|
87
|
+
```python
|
|
88
|
+
wallet = await sdk.connect_wallet(email="user@example.com")
|
|
89
|
+
wallet.wallet_address # "0xABCDEF..."
|
|
90
|
+
wallet.email # "user@example.com"
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
---
|
|
94
|
+
|
|
95
|
+
#### `start_verification(wallet_address, id_type=None) → VerifyStartResult`
|
|
96
|
+
|
|
97
|
+
Starts an AuthID identity verification session.
|
|
98
|
+
|
|
99
|
+
| `flow` | Condition |
|
|
100
|
+
|---------------------|------------------------------------------------|
|
|
101
|
+
| `"login"` | Returning user (face scan only) |
|
|
102
|
+
| `"onboarding"` | New user with `id_type` provided |
|
|
103
|
+
| `"choose_document"` | New user without `id_type` (user selects type) |
|
|
104
|
+
|
|
105
|
+
`id_type` is the AuthID document type code (e.g. `"40"` for passport).
|
|
106
|
+
|
|
107
|
+
```python
|
|
108
|
+
result = await sdk.start_verification(wallet.wallet_address, id_type="40")
|
|
109
|
+
result.flow # "onboarding"
|
|
110
|
+
result.widget_url # URL to open in browser/iframe
|
|
111
|
+
result.operation_id # pass to complete_verification (onboarding)
|
|
112
|
+
result.transaction_id # pass to complete_verification (login)
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
---
|
|
116
|
+
|
|
117
|
+
#### `complete_verification(wallet_address, operation_id=None, transaction_id=None) → VerifyCompleteResult`
|
|
118
|
+
|
|
119
|
+
Finalises the verification session after the user completes the widget. Polls AuthID for up to 60 s.
|
|
120
|
+
|
|
121
|
+
```python
|
|
122
|
+
done = await sdk.complete_verification(
|
|
123
|
+
wallet.wallet_address,
|
|
124
|
+
operation_id=result.operation_id,
|
|
125
|
+
)
|
|
126
|
+
done.is_verified # True | False
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
---
|
|
130
|
+
|
|
131
|
+
#### `get_permissions(wallet_address) → PermissionsResult`
|
|
132
|
+
|
|
133
|
+
Queries the AIT on-chain record for this wallet to determine which tools the admin has granted.
|
|
134
|
+
|
|
135
|
+
```python
|
|
136
|
+
perms = await sdk.get_permissions(wallet.wallet_address)
|
|
137
|
+
perms.allowed # True if any permission grant exists
|
|
138
|
+
perms.allowed_tools # ["transfer_funds", "get_account_balance", ...]
|
|
139
|
+
perms.denied_tools # tools explicitly blocked
|
|
140
|
+
perms.reason # optional explanation from nxtlinq
|
|
141
|
+
|
|
142
|
+
perms.has_permission("transfer_funds") # True | False
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
---
|
|
146
|
+
|
|
147
|
+
#### `get_admin_granted_tools(wallet_address) → list[str]`
|
|
148
|
+
|
|
149
|
+
Returns the list of tools the admin has pre-approved for this user to self-service request. Empty list means the admin hasn't granted any tools yet.
|
|
150
|
+
|
|
151
|
+
```python
|
|
152
|
+
tools = await sdk.get_admin_granted_tools(wallet.wallet_address)
|
|
153
|
+
# ["calculate", "get_company_info", "get_account_balance"]
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
---
|
|
157
|
+
|
|
158
|
+
#### `set_permissions(wallet_address, permissions, granted_by=None) → dict`
|
|
159
|
+
|
|
160
|
+
Writes an AIT on-chain with the given tool list for `wallet_address`. Used for self-service permission requests (user selects a subset of admin-granted tools).
|
|
161
|
+
|
|
162
|
+
```python
|
|
163
|
+
await sdk.set_permissions(
|
|
164
|
+
wallet_address="0x...",
|
|
165
|
+
permissions=["calculate", "get_company_info"],
|
|
166
|
+
granted_by="user@example.com",
|
|
167
|
+
)
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
---
|
|
171
|
+
|
|
172
|
+
#### `register_tools(tools) → int`
|
|
173
|
+
|
|
174
|
+
Registers the agent's tool catalogue with nxtID so admins can grant permissions per tool. Call once at agent startup.
|
|
175
|
+
|
|
176
|
+
```python
|
|
177
|
+
count = await sdk.register_tools([
|
|
178
|
+
{"name": "transfer_funds", "description": "Move money between accounts"},
|
|
179
|
+
{"name": "get_account_balance", "description": "Read-only finance access"},
|
|
180
|
+
])
|
|
181
|
+
# count = 2
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
---
|
|
185
|
+
|
|
186
|
+
#### `log_chat(...) → None`
|
|
187
|
+
|
|
188
|
+
Sends one conversation turn to nxtID Dashboard for monitoring. Fails silently — logging is non-fatal. Requires `service_id` at SDK init time.
|
|
189
|
+
|
|
190
|
+
```python
|
|
191
|
+
await sdk.log_chat(
|
|
192
|
+
session_id="abc123",
|
|
193
|
+
user_message="What's my balance?",
|
|
194
|
+
assistant_message="Your balance is $500.",
|
|
195
|
+
external_user_id="user@example.com",
|
|
196
|
+
user_name="Alice",
|
|
197
|
+
model="meta/llama-3.3-70b-instruct",
|
|
198
|
+
has_tool_call=True,
|
|
199
|
+
tool_name="get_account_balance",
|
|
200
|
+
turn_index=0,
|
|
201
|
+
)
|
|
202
|
+
```
|
|
203
|
+
|
|
204
|
+
---
|
|
205
|
+
|
|
206
|
+
### `EntraAuth`
|
|
207
|
+
|
|
208
|
+
Optional module for adding Microsoft Entra SSO login to a FastAPI agent. When enabled, mounts two routes onto the host app that the browser MSAL popup calls.
|
|
209
|
+
|
|
210
|
+
```python
|
|
211
|
+
from nxtid_sdk import EntraAuth
|
|
212
|
+
|
|
213
|
+
entra = EntraAuth(
|
|
214
|
+
client_id=os.getenv("AZURE_AD_CLIENT_ID"),
|
|
215
|
+
tenant_id=os.getenv("AZURE_AD_TENANT_ID"),
|
|
216
|
+
get_session=get_or_create_session, # callable: session_id → Session object
|
|
217
|
+
)
|
|
218
|
+
entra.mount(app) # app is a FastAPI instance
|
|
219
|
+
```
|
|
220
|
+
|
|
221
|
+
If `client_id` or `tenant_id` is empty/`None`, `entra.enabled` returns `False` and the routes respond accordingly — no error is raised.
|
|
222
|
+
|
|
223
|
+
**Routes mounted:**
|
|
224
|
+
|
|
225
|
+
| Method | Path | Description |
|
|
226
|
+
|--------|---------------------|--------------------------------------------------|
|
|
227
|
+
| `GET` | `/auth/entra/config`| Returns `{ enabled, client_id, tenant_id }` for MSAL initialisation |
|
|
228
|
+
| `POST` | `/auth/entra` | Receives MSAL token claims, writes session state |
|
|
229
|
+
|
|
230
|
+
**POST `/auth/entra` body:**
|
|
231
|
+
|
|
232
|
+
```json
|
|
233
|
+
{
|
|
234
|
+
"session_id": "string",
|
|
235
|
+
"name": "string",
|
|
236
|
+
"email": "string",
|
|
237
|
+
"tenant_id": "string (optional)",
|
|
238
|
+
"roles": ["string"]
|
|
239
|
+
}
|
|
240
|
+
```
|
|
241
|
+
|
|
242
|
+
The `get_session` callable receives a `session_id` string and must return a session object with writable attributes: `entra_authenticated`, `entra_name`, `entra_email`, `entra_tenant_id`, `entra_roles`.
|
|
243
|
+
|
|
244
|
+
---
|
|
245
|
+
|
|
246
|
+
### `ToolCallBlockedError`
|
|
247
|
+
|
|
248
|
+
Exception raised to block a tool execution before it reaches the LLM tool handler.
|
|
249
|
+
|
|
250
|
+
```python
|
|
251
|
+
from nxtid_sdk import ToolCallBlockedError
|
|
252
|
+
|
|
253
|
+
try:
|
|
254
|
+
await guard_tool_call(tool_name, session_id)
|
|
255
|
+
except ToolCallBlockedError as exc:
|
|
256
|
+
exc.reason # "verification_required" | "permission_denied"
|
|
257
|
+
exc.message # user-facing explanation
|
|
258
|
+
```
|
|
259
|
+
|
|
260
|
+
**Factory method:**
|
|
261
|
+
|
|
262
|
+
```python
|
|
263
|
+
raise ToolCallBlockedError.permission_denied(
|
|
264
|
+
tool_name="transfer_funds",
|
|
265
|
+
allowed_tools=["calculate", "get_company_info"],
|
|
266
|
+
)
|
|
267
|
+
# message: "You don't have permission to use 'transfer_funds'.
|
|
268
|
+
# Your current permissions: [calculate, get_company_info].
|
|
269
|
+
# Contact your administrator."
|
|
270
|
+
```
|
|
271
|
+
|
|
272
|
+
---
|
|
273
|
+
|
|
274
|
+
### Return Types
|
|
275
|
+
|
|
276
|
+
| Class | Fields |
|
|
277
|
+
|----------------------|------------------------------------------------------------------------|
|
|
278
|
+
| `WalletResult` | `wallet_address: str`, `email: str` |
|
|
279
|
+
| `VerifyStartResult` | `flow: str`, `widget_url: str\|None`, `operation_id: str\|None`, `transaction_id: str\|None` |
|
|
280
|
+
| `VerifyCompleteResult` | `is_verified: bool` |
|
|
281
|
+
| `PermissionsResult` | `allowed: bool`, `allowed_tools: list[str]`, `denied_tools: list[str]`, `reason: str\|None`, `has_permission(tool_name) → bool` |
|
|
282
|
+
|
|
283
|
+
---
|
|
284
|
+
|
|
285
|
+
## Typical Agent Integration Pattern
|
|
286
|
+
|
|
287
|
+
```python
|
|
288
|
+
# main.py (FastAPI)
|
|
289
|
+
from nxtid_sdk import NxtidSDK, EntraAuth, ToolCallBlockedError
|
|
290
|
+
|
|
291
|
+
sdk = NxtidSDK(api_key=..., api_secret=..., env="staging", service_id=...)
|
|
292
|
+
|
|
293
|
+
entra = EntraAuth(
|
|
294
|
+
client_id=os.getenv("AZURE_AD_CLIENT_ID"),
|
|
295
|
+
tenant_id=os.getenv("AZURE_AD_TENANT_ID"),
|
|
296
|
+
get_session=get_or_create_session,
|
|
297
|
+
)
|
|
298
|
+
entra.mount(app)
|
|
299
|
+
|
|
300
|
+
@app.on_event("startup")
|
|
301
|
+
async def startup():
|
|
302
|
+
await sdk.register_tools(TOOL_DEFINITIONS)
|
|
303
|
+
|
|
304
|
+
# In your tool dispatcher:
|
|
305
|
+
async def guard_tool_call(tool_name: str, session_id: str) -> None:
|
|
306
|
+
ctx = get_identity_context(session_id)
|
|
307
|
+
if not ctx.is_verified:
|
|
308
|
+
raise ToolCallBlockedError("verification_required", "Complete identity verification first.")
|
|
309
|
+
if tool_name not in ctx.allowed_tools:
|
|
310
|
+
raise ToolCallBlockedError.permission_denied(tool_name, ctx.allowed_tools)
|
|
311
|
+
```
|
|
312
|
+
|
|
313
|
+
---
|
|
314
|
+
|
|
315
|
+
## Requirements
|
|
316
|
+
|
|
317
|
+
- Python ≥ 3.10
|
|
318
|
+
- `httpx >= 0.27`
|
|
319
|
+
- FastAPI (only required for `EntraAuth`)
|
|
@@ -0,0 +1,355 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
from typing import Optional
|
|
5
|
+
|
|
6
|
+
import httpx
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def _raise_for_status(resp: httpx.Response) -> None:
|
|
10
|
+
"""Like raise_for_status() but includes the response body in the exception message."""
|
|
11
|
+
if resp.is_error:
|
|
12
|
+
try:
|
|
13
|
+
detail = resp.json()
|
|
14
|
+
except Exception:
|
|
15
|
+
detail = resp.text
|
|
16
|
+
raise httpx.HTTPStatusError(
|
|
17
|
+
f"Client error '{resp.status_code} {resp.reason_phrase}' for url '{resp.url}' — {detail}",
|
|
18
|
+
request=resp.request,
|
|
19
|
+
response=resp,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass
|
|
24
|
+
class WalletResult:
|
|
25
|
+
wallet_address: str
|
|
26
|
+
email: str
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass
|
|
30
|
+
class VerifyStartResult:
|
|
31
|
+
flow: str # 'onboarding' | 'login' | 'choose_document'
|
|
32
|
+
widget_url: Optional[str] = None
|
|
33
|
+
operation_id: Optional[str] = None
|
|
34
|
+
transaction_id: Optional[str] = None
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass
|
|
38
|
+
class VerifyCompleteResult:
|
|
39
|
+
is_verified: bool
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass
|
|
43
|
+
class PermissionsResult:
|
|
44
|
+
allowed: bool
|
|
45
|
+
allowed_tools: list[str] = field(default_factory=list)
|
|
46
|
+
denied_tools: list[str] = field(default_factory=list)
|
|
47
|
+
reason: Optional[str] = None
|
|
48
|
+
|
|
49
|
+
def has_permission(self, tool_name: str) -> bool:
|
|
50
|
+
if not self.allowed:
|
|
51
|
+
return False
|
|
52
|
+
if not self.allowed_tools or "ALL" in self.allowed_tools:
|
|
53
|
+
return True
|
|
54
|
+
return tool_name in self.allowed_tools
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
_ENV_URLS: dict[str, dict[str, str]] = {
|
|
58
|
+
"production": {
|
|
59
|
+
"ait": "https://ait-service.nxtlinq.ai",
|
|
60
|
+
"dashboard": "https://dashboard.nxtlinq.ai",
|
|
61
|
+
},
|
|
62
|
+
"staging": {
|
|
63
|
+
"ait": "https://staging-ait-service.nxtlinq.ai",
|
|
64
|
+
"dashboard": "https://dashboard-staging.nxtlinq.ai",
|
|
65
|
+
},
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class NxtidSDK:
|
|
70
|
+
"""
|
|
71
|
+
nxtID Python SDK.
|
|
72
|
+
|
|
73
|
+
Provides wallet connection, AuthID identity verification, and AIT
|
|
74
|
+
permission checks.
|
|
75
|
+
|
|
76
|
+
Usage::
|
|
77
|
+
|
|
78
|
+
sdk = NxtidSDK(
|
|
79
|
+
api_key="...",
|
|
80
|
+
api_secret="...",
|
|
81
|
+
env="production", # "production" (default) or "staging"
|
|
82
|
+
service_id="your-service-id", # optional, for log_chat
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
# Register tools on startup
|
|
86
|
+
await sdk.register_tools([{"name": "transfer_funds", "description": "..."}])
|
|
87
|
+
|
|
88
|
+
# Connect wallet
|
|
89
|
+
wallet = await sdk.connect_wallet(email="user@example.com")
|
|
90
|
+
|
|
91
|
+
# Start IDV (returns widget URL for the user to complete)
|
|
92
|
+
verify = await sdk.start_verification(wallet.wallet_address, id_type="40")
|
|
93
|
+
|
|
94
|
+
# After user completes the widget:
|
|
95
|
+
result = await sdk.complete_verification(
|
|
96
|
+
wallet.wallet_address,
|
|
97
|
+
operation_id=verify.operation_id,
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
# Check AIT permissions
|
|
101
|
+
perms = await sdk.get_permissions(wallet.wallet_address)
|
|
102
|
+
if perms.has_permission("transfer_funds"):
|
|
103
|
+
...
|
|
104
|
+
"""
|
|
105
|
+
|
|
106
|
+
def __init__(
|
|
107
|
+
self,
|
|
108
|
+
api_key: str,
|
|
109
|
+
api_secret: str,
|
|
110
|
+
env: str = "production",
|
|
111
|
+
service_id: Optional[str] = None,
|
|
112
|
+
) -> None:
|
|
113
|
+
if env not in _ENV_URLS:
|
|
114
|
+
raise ValueError(f"env must be 'production' or 'staging', got '{env}'")
|
|
115
|
+
urls = _ENV_URLS[env]
|
|
116
|
+
self._base_url = urls["ait"]
|
|
117
|
+
self._dashboard_url = urls["dashboard"]
|
|
118
|
+
self._service_id = service_id
|
|
119
|
+
self._headers = {
|
|
120
|
+
"x-api-key": api_key,
|
|
121
|
+
"x-api-secret": api_secret,
|
|
122
|
+
"Content-Type": "application/json",
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
def _url(self, path: str) -> str:
|
|
126
|
+
return f"{self._base_url}{path}"
|
|
127
|
+
|
|
128
|
+
def _dashboard_api_url(self, path: str) -> str:
|
|
129
|
+
if not self._dashboard_url:
|
|
130
|
+
raise ValueError("dashboard_url is required for this operation")
|
|
131
|
+
return f"{self._dashboard_url}{path}"
|
|
132
|
+
|
|
133
|
+
# ── Wallet ────────────────────────────────────────────────────────────────
|
|
134
|
+
|
|
135
|
+
async def connect_wallet(self, email: str) -> WalletResult:
|
|
136
|
+
"""Get (or create) a MetaKeep-managed wallet for the given email."""
|
|
137
|
+
async with httpx.AsyncClient(timeout=30.0) as client:
|
|
138
|
+
resp = await client.post(
|
|
139
|
+
self._url("/api/wallet/connect"),
|
|
140
|
+
json={"email": email},
|
|
141
|
+
headers=self._headers,
|
|
142
|
+
)
|
|
143
|
+
_raise_for_status(resp)
|
|
144
|
+
data = resp.json()
|
|
145
|
+
return WalletResult(wallet_address=data["walletAddress"], email=data["email"])
|
|
146
|
+
|
|
147
|
+
# ── Verification ──────────────────────────────────────────────────────────
|
|
148
|
+
|
|
149
|
+
async def start_verification(
|
|
150
|
+
self,
|
|
151
|
+
wallet_address: str,
|
|
152
|
+
id_type: Optional[str] = None,
|
|
153
|
+
) -> VerifyStartResult:
|
|
154
|
+
"""
|
|
155
|
+
Start AuthID identity verification.
|
|
156
|
+
|
|
157
|
+
Returns a widget URL for the user to open in a browser or iframe.
|
|
158
|
+
- Returning users: flow='login' (face scan)
|
|
159
|
+
- New users: flow='onboarding' (document capture, id_type required)
|
|
160
|
+
- New users w/o id_type: flow='choose_document' (prompt user to pick)
|
|
161
|
+
"""
|
|
162
|
+
body: dict = {"walletAddress": wallet_address}
|
|
163
|
+
if id_type:
|
|
164
|
+
body["idType"] = id_type
|
|
165
|
+
|
|
166
|
+
async with httpx.AsyncClient(timeout=30.0) as client:
|
|
167
|
+
resp = await client.post(
|
|
168
|
+
self._url("/api/verify/start"),
|
|
169
|
+
json=body,
|
|
170
|
+
headers=self._headers,
|
|
171
|
+
)
|
|
172
|
+
_raise_for_status(resp)
|
|
173
|
+
data = resp.json()
|
|
174
|
+
return VerifyStartResult(
|
|
175
|
+
flow=data["flow"],
|
|
176
|
+
widget_url=data.get("widgetUrl"),
|
|
177
|
+
operation_id=data.get("operationId"),
|
|
178
|
+
transaction_id=data.get("transactionId"),
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
async def complete_verification(
|
|
182
|
+
self,
|
|
183
|
+
wallet_address: str,
|
|
184
|
+
operation_id: Optional[str] = None,
|
|
185
|
+
transaction_id: Optional[str] = None,
|
|
186
|
+
) -> VerifyCompleteResult:
|
|
187
|
+
"""
|
|
188
|
+
Call after the user completes the AuthID widget.
|
|
189
|
+
|
|
190
|
+
Pass operation_id (onboarding flow) or transaction_id (login flow)
|
|
191
|
+
from the VerifyStartResult.
|
|
192
|
+
"""
|
|
193
|
+
body: dict = {"walletAddress": wallet_address}
|
|
194
|
+
if operation_id:
|
|
195
|
+
body["operationId"] = operation_id
|
|
196
|
+
if transaction_id:
|
|
197
|
+
body["transactionId"] = transaction_id
|
|
198
|
+
|
|
199
|
+
# Generous timeout: complete_verification polls AuthID for up to 60s
|
|
200
|
+
async with httpx.AsyncClient(timeout=90.0) as client:
|
|
201
|
+
resp = await client.post(
|
|
202
|
+
self._url("/api/verify/complete"),
|
|
203
|
+
json=body,
|
|
204
|
+
headers=self._headers,
|
|
205
|
+
)
|
|
206
|
+
_raise_for_status(resp)
|
|
207
|
+
data = resp.json()
|
|
208
|
+
return VerifyCompleteResult(is_verified=data["isVerified"])
|
|
209
|
+
|
|
210
|
+
# ── Permissions ───────────────────────────────────────────────────────────
|
|
211
|
+
|
|
212
|
+
async def set_permissions(
|
|
213
|
+
self,
|
|
214
|
+
wallet_address: str,
|
|
215
|
+
permissions: list[str],
|
|
216
|
+
granted_by: Optional[str] = None,
|
|
217
|
+
) -> dict:
|
|
218
|
+
"""
|
|
219
|
+
Create an AIT on-chain with the given permissions for wallet_address.
|
|
220
|
+
|
|
221
|
+
requested_by: email or identifier of whoever triggered this request —
|
|
222
|
+
stored in the AIT record for audit purposes. Optional;
|
|
223
|
+
existing callers that omit it are unaffected.
|
|
224
|
+
"""
|
|
225
|
+
body: dict = {"address": wallet_address, "permissions": permissions}
|
|
226
|
+
if granted_by:
|
|
227
|
+
body["grantedBy"] = granted_by
|
|
228
|
+
|
|
229
|
+
async with httpx.AsyncClient(timeout=60.0) as client:
|
|
230
|
+
resp = await client.post(
|
|
231
|
+
self._url("/api/ait/set-permissions"),
|
|
232
|
+
json=body,
|
|
233
|
+
headers=self._headers,
|
|
234
|
+
)
|
|
235
|
+
_raise_for_status(resp)
|
|
236
|
+
return resp.json()
|
|
237
|
+
|
|
238
|
+
async def get_admin_granted_tools(self, wallet_address: str) -> list[str]:
|
|
239
|
+
"""
|
|
240
|
+
Return the tools admin has pre-approved for this user to self-service request.
|
|
241
|
+
|
|
242
|
+
Empty list means admin hasn't granted this user any tools yet — the caller
|
|
243
|
+
should show "waiting for administrator" rather than checkboxes.
|
|
244
|
+
"""
|
|
245
|
+
async with httpx.AsyncClient(timeout=15.0) as client:
|
|
246
|
+
resp = await client.get(
|
|
247
|
+
self._url("/api/agent/permissions"),
|
|
248
|
+
params={"walletAddress": wallet_address},
|
|
249
|
+
headers=self._headers,
|
|
250
|
+
)
|
|
251
|
+
_raise_for_status(resp)
|
|
252
|
+
return resp.json().get("allowedTools", [])
|
|
253
|
+
|
|
254
|
+
async def get_permissions(self, wallet_address: str) -> PermissionsResult:
|
|
255
|
+
"""Query admin-granted AIT tool permissions for the given wallet address."""
|
|
256
|
+
async with httpx.AsyncClient(timeout=15.0) as client:
|
|
257
|
+
resp = await client.get(
|
|
258
|
+
self._url("/api/agent/user-permission"),
|
|
259
|
+
params={"walletAddress": wallet_address},
|
|
260
|
+
headers=self._headers,
|
|
261
|
+
)
|
|
262
|
+
_raise_for_status(resp)
|
|
263
|
+
data = resp.json()
|
|
264
|
+
return PermissionsResult(
|
|
265
|
+
allowed=data.get("allowed", False),
|
|
266
|
+
allowed_tools=data.get("allowedTools", []),
|
|
267
|
+
denied_tools=data.get("deniedTools", []),
|
|
268
|
+
reason=data.get("reason"),
|
|
269
|
+
)
|
|
270
|
+
|
|
271
|
+
# ── Agent management ─────────────────────────────────────────────────────
|
|
272
|
+
|
|
273
|
+
async def register_tools(self, tools: list) -> int:
|
|
274
|
+
"""
|
|
275
|
+
Register this agent's tool list with nxtlinq so admins can grant permissions.
|
|
276
|
+
|
|
277
|
+
Call once at agent startup. Each tool should be a dict with 'name' and
|
|
278
|
+
optionally 'description'.
|
|
279
|
+
|
|
280
|
+
Returns the number of tools registered.
|
|
281
|
+
|
|
282
|
+
Usage::
|
|
283
|
+
|
|
284
|
+
tool_count = await sdk.register_tools([
|
|
285
|
+
{"name": "transfer_funds", "description": "Move money between accounts"},
|
|
286
|
+
{"name": "get_account_balance", "description": "Read-only finance access"},
|
|
287
|
+
])
|
|
288
|
+
"""
|
|
289
|
+
async with httpx.AsyncClient(timeout=15.0) as client:
|
|
290
|
+
resp = await client.post(
|
|
291
|
+
self._url("/api/agent/tools"),
|
|
292
|
+
json={"tools": tools},
|
|
293
|
+
headers=self._headers,
|
|
294
|
+
)
|
|
295
|
+
_raise_for_status(resp)
|
|
296
|
+
return resp.json().get("toolCount", len(tools))
|
|
297
|
+
|
|
298
|
+
async def log_chat(
|
|
299
|
+
self,
|
|
300
|
+
session_id: str,
|
|
301
|
+
user_message: str,
|
|
302
|
+
assistant_message: str,
|
|
303
|
+
external_user_id: str,
|
|
304
|
+
user_name: str,
|
|
305
|
+
model: str = "",
|
|
306
|
+
has_tool_call: bool = False,
|
|
307
|
+
tool_name: str = "",
|
|
308
|
+
turn_index: Optional[int] = None,
|
|
309
|
+
) -> None:
|
|
310
|
+
"""
|
|
311
|
+
Send one conversation turn to the nxtlinq Dashboard for monitoring.
|
|
312
|
+
|
|
313
|
+
Requires dashboard_url and service_id to be set at SDK init time.
|
|
314
|
+
Fails silently — chat logging is non-fatal.
|
|
315
|
+
|
|
316
|
+
Usage::
|
|
317
|
+
|
|
318
|
+
await sdk.log_chat(
|
|
319
|
+
session_id="abc123",
|
|
320
|
+
user_message="What's my balance?",
|
|
321
|
+
assistant_message="Your balance is $500.",
|
|
322
|
+
external_user_id="user@example.com",
|
|
323
|
+
user_name="Alice",
|
|
324
|
+
has_tool_call=True,
|
|
325
|
+
tool_name="get_account_balance",
|
|
326
|
+
)
|
|
327
|
+
"""
|
|
328
|
+
if not self._dashboard_url or not self._service_id:
|
|
329
|
+
return
|
|
330
|
+
payload: dict = {
|
|
331
|
+
"aiType": model,
|
|
332
|
+
"hasToolCall": has_tool_call,
|
|
333
|
+
"toolName": tool_name,
|
|
334
|
+
"userInfo": {
|
|
335
|
+
"externalUserId": external_user_id,
|
|
336
|
+
"userName": user_name,
|
|
337
|
+
},
|
|
338
|
+
"chatHistory": {
|
|
339
|
+
"externalChatHistoryId": session_id,
|
|
340
|
+
"userMessage": user_message,
|
|
341
|
+
"assistantMessage": assistant_message,
|
|
342
|
+
},
|
|
343
|
+
}
|
|
344
|
+
if turn_index is not None:
|
|
345
|
+
payload["chatHistory"]["externalTurnIndex"] = turn_index
|
|
346
|
+
try:
|
|
347
|
+
async with httpx.AsyncClient(timeout=10.0) as client:
|
|
348
|
+
await client.post(
|
|
349
|
+
self._dashboard_api_url(f"/api/service/{self._service_id}/external-service-call-log"),
|
|
350
|
+
json=payload,
|
|
351
|
+
headers=self._headers,
|
|
352
|
+
)
|
|
353
|
+
except Exception:
|
|
354
|
+
pass
|
|
355
|
+
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import List, Optional
|
|
4
|
+
|
|
5
|
+
from fastapi import APIRouter
|
|
6
|
+
from fastapi.responses import JSONResponse
|
|
7
|
+
from pydantic import BaseModel
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class EntraAuth:
|
|
11
|
+
"""
|
|
12
|
+
Optional Microsoft Entra SSO module for the nxtID SDK.
|
|
13
|
+
|
|
14
|
+
When enabled, mounts two FastAPI routes onto the host app:
|
|
15
|
+
GET /auth/entra/config — returns client_id/tenant_id for MSAL
|
|
16
|
+
POST /auth/entra — receives MSAL token claims, writes session state
|
|
17
|
+
|
|
18
|
+
Usage::
|
|
19
|
+
|
|
20
|
+
from nxtid_sdk import EntraAuth
|
|
21
|
+
|
|
22
|
+
entra = EntraAuth(
|
|
23
|
+
client_id="...",
|
|
24
|
+
tenant_id="...",
|
|
25
|
+
get_session=get_or_create_session, # callable: session_id -> Session
|
|
26
|
+
)
|
|
27
|
+
entra.mount(app) # app is a FastAPI instance
|
|
28
|
+
|
|
29
|
+
# Check if enabled
|
|
30
|
+
if entra.enabled:
|
|
31
|
+
...
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
def __init__(
|
|
35
|
+
self,
|
|
36
|
+
client_id: Optional[str],
|
|
37
|
+
tenant_id: Optional[str],
|
|
38
|
+
get_session,
|
|
39
|
+
) -> None:
|
|
40
|
+
self._client_id = client_id or ""
|
|
41
|
+
self._tenant_id = tenant_id or ""
|
|
42
|
+
self._get_session = get_session
|
|
43
|
+
|
|
44
|
+
@property
|
|
45
|
+
def enabled(self) -> bool:
|
|
46
|
+
return bool(self._client_id and self._tenant_id)
|
|
47
|
+
|
|
48
|
+
def mount(self, app) -> None:
|
|
49
|
+
"""Mount /auth/entra/config and /auth/entra routes onto a FastAPI app."""
|
|
50
|
+
router = self._build_router()
|
|
51
|
+
app.include_router(router)
|
|
52
|
+
|
|
53
|
+
def _build_router(self) -> APIRouter:
|
|
54
|
+
router = APIRouter()
|
|
55
|
+
client_id = self._client_id
|
|
56
|
+
tenant_id = self._tenant_id
|
|
57
|
+
enabled = self.enabled
|
|
58
|
+
get_session = self._get_session
|
|
59
|
+
|
|
60
|
+
class _EntraAuthRequest(BaseModel):
|
|
61
|
+
session_id: str
|
|
62
|
+
name: str
|
|
63
|
+
email: str
|
|
64
|
+
tenant_id: Optional[str] = None
|
|
65
|
+
roles: List[str] = []
|
|
66
|
+
|
|
67
|
+
@router.get("/auth/entra/config")
|
|
68
|
+
async def entra_config() -> dict:
|
|
69
|
+
return {
|
|
70
|
+
"enabled": enabled,
|
|
71
|
+
"client_id": client_id,
|
|
72
|
+
"tenant_id": tenant_id,
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
@router.post("/auth/entra")
|
|
76
|
+
async def auth_entra(req: _EntraAuthRequest) -> dict:
|
|
77
|
+
session = get_session(req.session_id)
|
|
78
|
+
session.entra_authenticated = True
|
|
79
|
+
session.entra_name = req.name
|
|
80
|
+
session.entra_email = req.email
|
|
81
|
+
session.entra_tenant_id = req.tenant_id
|
|
82
|
+
session.entra_roles = req.roles
|
|
83
|
+
return {
|
|
84
|
+
"session_id": session.session_id,
|
|
85
|
+
"entra_authenticated": True,
|
|
86
|
+
"name": req.name,
|
|
87
|
+
"email": req.email,
|
|
88
|
+
"roles": req.roles,
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return router
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class ToolCallBlockedError(Exception):
|
|
5
|
+
"""
|
|
6
|
+
Raised to block a tool execution before it reaches the LLM tool handler.
|
|
7
|
+
|
|
8
|
+
reason: 'verification_required' | 'permission_denied'
|
|
9
|
+
message: user-facing explanation
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
def __init__(self, reason: str, message: str) -> None:
|
|
13
|
+
self.reason = reason
|
|
14
|
+
self.message = message
|
|
15
|
+
|
|
16
|
+
@classmethod
|
|
17
|
+
def permission_denied(cls, tool_name: str, allowed_tools: list[str]) -> "ToolCallBlockedError":
|
|
18
|
+
tools_str = ", ".join(allowed_tools) if allowed_tools else "none"
|
|
19
|
+
return cls(
|
|
20
|
+
"permission_denied",
|
|
21
|
+
f"You don't have permission to use '{tool_name}'. "
|
|
22
|
+
f"Your current permissions: [{tools_str}]. "
|
|
23
|
+
"Contact your administrator.",
|
|
24
|
+
)
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
README.md
|
|
2
|
+
pyproject.toml
|
|
3
|
+
nxtid_sdk/__init__.py
|
|
4
|
+
nxtid_sdk/client.py
|
|
5
|
+
nxtid_sdk/entra.py
|
|
6
|
+
nxtid_sdk/exceptions.py
|
|
7
|
+
nxtid_sdk.egg-info/PKG-INFO
|
|
8
|
+
nxtid_sdk.egg-info/SOURCES.txt
|
|
9
|
+
nxtid_sdk.egg-info/dependency_links.txt
|
|
10
|
+
nxtid_sdk.egg-info/requires.txt
|
|
11
|
+
nxtid_sdk.egg-info/top_level.txt
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
httpx>=0.27
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
nxtid_sdk
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "nxtid-sdk"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "nxtID Python SDK — AIT-gated AI agent tool permissions"
|
|
9
|
+
requires-python = ">=3.10"
|
|
10
|
+
dependencies = [
|
|
11
|
+
"httpx>=0.27",
|
|
12
|
+
]
|
|
13
|
+
|
|
14
|
+
[tool.setuptools.packages.find]
|
|
15
|
+
where = ["."]
|
|
16
|
+
include = ["nxtid_sdk*"]
|