connectonion 0.6.4__py3-none-any.whl → 0.6.5__py3-none-any.whl

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.
Files changed (33) hide show
  1. connectonion/__init__.py +1 -1
  2. connectonion/cli/co_ai/main.py +2 -2
  3. connectonion/cli/co_ai/prompts/connectonion/concepts/trust.md +166 -208
  4. connectonion/cli/commands/copy_commands.py +21 -0
  5. connectonion/cli/commands/trust_commands.py +152 -0
  6. connectonion/cli/main.py +82 -0
  7. connectonion/core/llm.py +2 -2
  8. connectonion/docs/concepts/fast_rules.md +237 -0
  9. connectonion/docs/concepts/onboarding.md +465 -0
  10. connectonion/docs/concepts/trust.md +933 -192
  11. connectonion/docs/design-decisions/023-trust-policy-system-design.md +323 -0
  12. connectonion/docs/network/README.md +23 -1
  13. connectonion/docs/network/connect.md +135 -0
  14. connectonion/docs/network/host.md +73 -4
  15. connectonion/network/__init__.py +7 -6
  16. connectonion/network/asgi/__init__.py +3 -0
  17. connectonion/network/asgi/http.py +125 -19
  18. connectonion/network/asgi/websocket.py +276 -15
  19. connectonion/network/connect.py +145 -29
  20. connectonion/network/host/auth.py +70 -67
  21. connectonion/network/host/routes.py +88 -3
  22. connectonion/network/host/server.py +100 -17
  23. connectonion/network/trust/__init__.py +27 -19
  24. connectonion/network/trust/factory.py +51 -24
  25. connectonion/network/trust/fast_rules.py +100 -0
  26. connectonion/network/trust/tools.py +316 -32
  27. connectonion/network/trust/trust_agent.py +403 -0
  28. connectonion/transcribe.py +1 -1
  29. {connectonion-0.6.4.dist-info → connectonion-0.6.5.dist-info}/METADATA +1 -1
  30. {connectonion-0.6.4.dist-info → connectonion-0.6.5.dist-info}/RECORD +32 -27
  31. connectonion/network/trust/prompts.py +0 -71
  32. {connectonion-0.6.4.dist-info → connectonion-0.6.5.dist-info}/WHEEL +0 -0
  33. {connectonion-0.6.4.dist-info → connectonion-0.6.5.dist-info}/entry_points.txt +0 -0
@@ -107,6 +107,7 @@ async def handle_http(
107
107
  route_handlers: dict,
108
108
  storage,
109
109
  trust: str,
110
+ trust_config: dict | None = None,
110
111
  start_time: float,
111
112
  blacklist: list | None = None,
112
113
  whitelist: list | None = None,
@@ -120,6 +121,7 @@ async def handle_http(
120
121
  route_handlers: Dict of route handler functions (input, session, sessions, health, info, auth)
121
122
  storage: SessionStorage instance
122
123
  trust: Trust level (open/careful/strict)
124
+ trust_config: Parsed YAML config from trust policy (for /info onboard)
123
125
  start_time: Server start time
124
126
  blacklist: Blocked identities
125
127
  whitelist: Allowed identities
@@ -133,26 +135,130 @@ async def handle_http(
133
135
  await send({"type": "http.response.body", "body": b""})
134
136
  return
135
137
 
136
- # Admin endpoints require API key auth
138
+ # Admin endpoints
137
139
  if path.startswith("/admin"):
138
- headers = dict(scope.get("headers", []))
139
- auth = headers.get(b"authorization", b"").decode()
140
- expected = os.environ.get("OPENONION_API_KEY", "")
141
- if not expected or not auth.startswith("Bearer ") or not hmac.compare_digest(auth[7:], expected):
142
- await send_json(send, {"error": "unauthorized"}, 401)
143
- return
144
-
145
- if method == "GET" and path == "/admin/logs":
146
- result = route_handlers["admin_logs"]()
147
- if "error" in result:
148
- await send_json(send, result, 404)
140
+ # Legacy admin endpoints (Bearer token auth)
141
+ if path in ["/admin/logs", "/admin/sessions"]:
142
+ headers_dict = dict(scope.get("headers", []))
143
+ auth = headers_dict.get(b"authorization", b"").decode()
144
+ expected = os.environ.get("OPENONION_API_KEY", "")
145
+ if not expected or not auth.startswith("Bearer ") or not hmac.compare_digest(auth[7:], expected):
146
+ await send_json(send, {"error": "unauthorized"}, 401)
147
+ return
148
+
149
+ if method == "GET" and path == "/admin/logs":
150
+ result = route_handlers["admin_logs"]()
151
+ if "error" in result:
152
+ await send_json(send, result, 404)
153
+ else:
154
+ await send_text(send, result["content"])
155
+ return
156
+
157
+ if method == "GET" and path == "/admin/sessions":
158
+ await send_json(send, route_handlers["admin_sessions"]())
159
+ return
160
+
161
+ # Admin trust routes (signed request + admin check)
162
+ if path.startswith("/admin/trust/") or path.startswith("/superadmin/"):
163
+ trust_agent = route_handlers["trust_agent"]
164
+
165
+ # For GET requests, allow auth via headers (bodies often stripped by proxies)
166
+ headers_dict = dict(scope.get("headers", []))
167
+ header_from = headers_dict.get(b"x-from", b"").decode()
168
+ header_sig = headers_dict.get(b"x-signature", b"").decode()
169
+ header_ts = headers_dict.get(b"x-timestamp", b"").decode()
170
+
171
+ if method == "GET" and header_from and header_sig and header_ts:
172
+ # Build signed request from headers
173
+ try:
174
+ data = {
175
+ "payload": {"timestamp": float(header_ts)},
176
+ "from": header_from,
177
+ "signature": header_sig
178
+ }
179
+ except ValueError:
180
+ await send_json(send, {"error": "Invalid X-Timestamp header"}, 400)
181
+ return
149
182
  else:
150
- await send_text(send, result["content"])
151
- return
152
-
153
- if method == "GET" and path == "/admin/sessions":
154
- await send_json(send, route_handlers["admin_sessions"]())
155
- return
183
+ # Read from body (POST or GET without headers)
184
+ body = await read_body(receive)
185
+ try:
186
+ data = json.loads(body) if body else {}
187
+ except json.JSONDecodeError:
188
+ await send_json(send, {"error": "Invalid JSON"}, 400)
189
+ return
190
+
191
+ # Authenticate signed request (reuse same auth as /input, but with "open" trust)
192
+ _, identity, sig_valid, err = route_handlers["auth"](data, "open")
193
+ if err or not sig_valid:
194
+ await send_json(send, {"error": err or "unauthorized: invalid signature"}, 401)
195
+ return
196
+
197
+ # Check admin permission
198
+ if path.startswith("/superadmin/"):
199
+ # Super admin only (self address)
200
+ if not trust_agent.is_super_admin(identity):
201
+ await send_json(send, {"error": "forbidden: super admin only"}, 403)
202
+ return
203
+ else:
204
+ # Any admin
205
+ if not trust_agent.is_admin(identity):
206
+ await send_json(send, {"error": "forbidden: admin only"}, 403)
207
+ return
208
+
209
+ payload = data.get("payload", {})
210
+ client_id = payload.get("client_id")
211
+
212
+ # Route to handlers
213
+ if method == "POST" and path == "/admin/trust/promote":
214
+ if not client_id:
215
+ await send_json(send, {"error": "client_id required"}, 400)
216
+ return
217
+ await send_json(send, route_handlers["admin_trust_promote"](client_id))
218
+ return
219
+
220
+ if method == "POST" and path == "/admin/trust/demote":
221
+ if not client_id:
222
+ await send_json(send, {"error": "client_id required"}, 400)
223
+ return
224
+ await send_json(send, route_handlers["admin_trust_demote"](client_id))
225
+ return
226
+
227
+ if method == "POST" and path == "/admin/trust/block":
228
+ if not client_id:
229
+ await send_json(send, {"error": "client_id required"}, 400)
230
+ return
231
+ reason = payload.get("reason", "")
232
+ await send_json(send, route_handlers["admin_trust_block"](client_id, reason))
233
+ return
234
+
235
+ if method == "POST" and path == "/admin/trust/unblock":
236
+ if not client_id:
237
+ await send_json(send, {"error": "client_id required"}, 400)
238
+ return
239
+ await send_json(send, route_handlers["admin_trust_unblock"](client_id))
240
+ return
241
+
242
+ if method == "GET" and path.startswith("/admin/trust/level/"):
243
+ client_id = path[len("/admin/trust/level/"):]
244
+ await send_json(send, route_handlers["admin_trust_level"](client_id))
245
+ return
246
+
247
+ if method == "POST" and path == "/superadmin/add":
248
+ admin_id = payload.get("admin_id")
249
+ if not admin_id:
250
+ await send_json(send, {"error": "admin_id required"}, 400)
251
+ return
252
+ await send_json(send, route_handlers["admin_admins_add"](admin_id))
253
+ return
254
+
255
+ if method == "POST" and path == "/superadmin/remove":
256
+ admin_id = payload.get("admin_id")
257
+ if not admin_id:
258
+ await send_json(send, {"error": "admin_id required"}, 400)
259
+ return
260
+ await send_json(send, route_handlers["admin_admins_remove"](admin_id))
261
+ return
156
262
 
157
263
  await send_json(send, {"error": "not found"}, 404)
158
264
  return
@@ -189,7 +295,7 @@ async def handle_http(
189
295
  await send_json(send, route_handlers["health"](start_time))
190
296
 
191
297
  elif method == "GET" and path == "/info":
192
- await send_json(send, route_handlers["info"](trust))
298
+ await send_json(send, route_handlers["info"](trust, trust_config))
193
299
 
194
300
  elif method == "GET" and path == "/docs":
195
301
  # Serve static docs page
@@ -1,13 +1,13 @@
1
1
  """
2
- Purpose: WebSocket bidirectional communication for ASGI server with real-time agent I/O
2
+ Purpose: WebSocket bidirectional communication for ASGI server with real-time agent I/O and keep-alive
3
3
  LLM-Note:
4
4
  Dependencies: imports from [network/io/websocket.py, network/asgi/http.py pydantic_json_encoder, asyncio, json, queue, threading] | imported by [network/asgi/__init__.py] | tested by [tests/network/test_asgi_websocket.py]
5
- Data flow: handle_websocket() accepts connection → receives INPUT message with prompt+session → authenticates via route_handlers["auth"] → starts agent in background thread with WebSocketIO → agent sends events via io.log()/send() → forwards to client via websocket.send → client sends ASK_USER_RESPONSE for approvals → io receives via queue → agent resumes → returns OUTPUT with result+session_id
6
- State/Effects: maintains WebSocket connection during agent execution | runs agent in daemon thread (non-blocking) | uses queue.Queue for thread-safe I/O between agent and WebSocket | no persistent state (connection-scoped)
7
- Integration: exposes handle_websocket(scope, receive, send, route_handlers, storage, trust, blacklist, whitelist) | uses WebSocketIO for bidirectional I/O | supports session continuation (same as HTTP) | message types: INPUT (client), OUTPUT/ERROR (server), ASK_USER_RESPONSE (client), trace events (server)
8
- Performance: async WebSocket handling | agent runs in separate thread to avoid blocking | queue-based message passing | streams events in real-time (thinking, tool_result, approval_needed)
9
- Errors: sends ERROR message for invalid JSON, auth failures, missing prompt | closes connection with code 4004 for wrong path | catches exceptions in agent thread and sends ERROR
10
- WebSocket handling for ASGI.
5
+ Data flow: handle_websocket() accepts connection → receives INPUT message with prompt+session → authenticates via route_handlers["auth"] → starts agent in background thread with WebSocketIO → sends PING every 30s for keep-alive → agent sends events via io.log()/send() → forwards to client via websocket.send → client responds with PONG to PING → client sends ASK_USER_RESPONSE for approvals → io receives via queue → agent resumes → returns OUTPUT with result+session_id → result saved to SessionStorage even if client disconnects
6
+ State/Effects: maintains WebSocket connection during agent execution | runs agent in daemon thread (non-blocking) | uses queue.Queue for thread-safe I/O between agent and WebSocket | no persistent state (connection-scoped) | results persisted to .co/session_results.jsonl for 24h TTL
7
+ Integration: exposes handle_websocket(scope, receive, send, route_handlers, storage, trust, blacklist, whitelist) | uses WebSocketIO for bidirectional I/O | supports session continuation (same as HTTP) | message types: INPUT (client), OUTPUT/ERROR (server), PING (server), PONG (client), ASK_USER_RESPONSE (client), trace events (server) | clients can poll GET /sessions/{id} to recover results after disconnect
8
+ Performance: async WebSocket handling | agent runs in separate thread to avoid blocking | queue-based message passing | streams events in real-time (thinking, tool_result, approval_needed) | PING task keeps connection alive through proxies/firewalls
9
+ Errors: sends ERROR message for invalid JSON, auth failures, missing prompt | closes connection with code 4004 for wrong path | catches exceptions in agent thread and sends ERROR | saves result to storage even on disconnect (enables polling recovery)
10
+ WebSocket handling for ASGI with keep-alive and session recovery.
11
11
 
12
12
  ASGI Protocol Types (not our custom types - this is the ASGI spec):
13
13
  - websocket.connect : ASGI sends when client wants to connect
@@ -18,10 +18,21 @@ ASGI Protocol Types (not our custom types - this is the ASGI spec):
18
18
  - websocket.close : We send to close the connection
19
19
 
20
20
  Our Application Types (sent inside websocket.send text payload):
21
- - INPUT : Client sends prompt
22
- - OUTPUT : Server sends final result
21
+ - INPUT : Client sends prompt with session_id
22
+ - OUTPUT : Server sends final result with session_id
23
23
  - ERROR : Server sends error message
24
+ - PING : Server keep-alive (every 30s)
25
+ - PONG : Client acknowledges keep-alive
24
26
  - ASK_USER_RESPONSE : Client responds to approval request
27
+ - ONBOARD_REQUIRED : Server sends when stranger needs to onboard
28
+ - ONBOARD_SUBMIT : Client sends invite_code or payment
29
+ - ONBOARD_SUCCESS : Server confirms onboard complete
30
+ - ADMIN_PROMOTE : Client (admin) requests promote
31
+ - ADMIN_DEMOTE : Client (admin) requests demote
32
+ - ADMIN_BLOCK : Client (admin) requests block
33
+ - ADMIN_UNBLOCK : Client (admin) requests unblock
34
+ - ADMIN_GET_LEVEL : Client (admin) requests client level
35
+ - ADMIN_RESULT : Server sends admin action result
25
36
  - (trace events) : thinking, tool_result, approval_needed, etc.
26
37
  """
27
38
 
@@ -30,7 +41,10 @@ import json
30
41
  import queue
31
42
  import threading
32
43
 
44
+ from rich.console import Console
33
45
  from ..io import WebSocketIO
46
+
47
+ console = Console()
34
48
  # Import pydantic_json_encoder for serializing Pydantic models (e.g., TokenUsage) in WebSocket responses
35
49
  from .http import pydantic_json_encoder
36
50
 
@@ -78,10 +92,37 @@ async def handle_websocket(
78
92
  "text": json.dumps({"type": "ERROR", "message": "Invalid JSON"})})
79
93
  continue
80
94
 
81
- if data.get("type") == "INPUT": # Our app type: client wants to run agent
95
+ msg_type = data.get("type")
96
+
97
+ # Handle ONBOARD_SUBMIT (stranger submitting invite code or payment)
98
+ if msg_type == "ONBOARD_SUBMIT":
99
+ await _handle_onboard_submit(data, send, route_handlers)
100
+ continue
101
+
102
+ # Handle ADMIN messages (promote, demote, block, unblock, get_level)
103
+ if msg_type and msg_type.startswith("ADMIN_"):
104
+ await _handle_admin_message(data, send, route_handlers)
105
+ continue
106
+
107
+ if msg_type == "INPUT": # Our app type: client wants to run agent
82
108
  prompt, identity, sig_valid, err = route_handlers["auth"](
83
109
  data, trust, blacklist=blacklist, whitelist=whitelist
84
110
  )
111
+
112
+ # Check if stranger needs onboarding
113
+ if err and "forbidden" in err.lower():
114
+ # Get onboard requirements from trust config
115
+ trust_agent = route_handlers["trust_agent"]
116
+ onboard_info = _get_onboard_requirements(trust_agent)
117
+ if onboard_info:
118
+ await send({"type": "websocket.send",
119
+ "text": json.dumps({
120
+ "type": "ONBOARD_REQUIRED",
121
+ "identity": identity,
122
+ **onboard_info
123
+ })})
124
+ continue
125
+
85
126
  if err:
86
127
  await send({"type": "websocket.send",
87
128
  "text": json.dumps({"type": "ERROR", "message": err})})
@@ -139,16 +180,23 @@ async def handle_websocket(
139
180
 
140
181
 
141
182
  async def _pump_messages(ws_receive, ws_send, io: WebSocketIO, agent_done: threading.Event) -> bool:
142
- """Pump messages between WebSocket and IO queues.
183
+ """Pump messages between WebSocket and IO queues with keep-alive.
143
184
 
144
185
  Runs until agent completes. Handles:
145
- - Outgoing: io._outgoing queue -> WebSocket
146
- - Incoming: WebSocket -> io._incoming queue (for approval responses)
186
+ - Outgoing: io._outgoing queue -> WebSocket (agent events)
187
+ - Incoming: WebSocket -> io._incoming queue (approval responses, PONG)
188
+ - Keep-alive: PING messages every 30s to detect dead connections
147
189
 
148
190
  Returns:
149
191
  True if client disconnected before agent completed, False otherwise.
150
192
  When True, caller should skip sending final OUTPUT (client is gone,
151
- but result is already saved to SessionStorage).
193
+ but result is already saved to SessionStorage for polling recovery).
194
+
195
+ Keep-alive mechanism:
196
+ - send_ping() task sends PING every 30s
197
+ - Client responds with PONG (handled in receive_incoming)
198
+ - Keeps connection alive through proxies/firewalls
199
+ - Client can detect dead connection if no PING for 60s
152
200
 
153
201
  Implementation note:
154
202
  Uses asyncio.Event for signaling disconnect between nested async functions.
@@ -191,7 +239,11 @@ async def _pump_messages(ws_receive, ws_send, io: WebSocketIO, agent_done: threa
191
239
  if msg["type"] == "websocket.receive":
192
240
  try:
193
241
  data = json.loads(msg.get("text", "{}"))
194
- io._incoming.put(data)
242
+ # Handle PONG responses (client responding to PING)
243
+ if data.get("type") == "PONG":
244
+ pass # Just acknowledge, no action needed
245
+ else:
246
+ io._incoming.put(data)
195
247
  except json.JSONDecodeError:
196
248
  pass
197
249
  elif msg["type"] == "websocket.disconnect":
@@ -201,17 +253,226 @@ async def _pump_messages(ws_receive, ws_send, io: WebSocketIO, agent_done: threa
201
253
  except asyncio.TimeoutError:
202
254
  continue
203
255
 
256
+ async def send_ping():
257
+ """Send PING messages every 30 seconds to keep connection alive."""
258
+ while not agent_done.is_set() and not disconnected.is_set():
259
+ await asyncio.sleep(30) # Send ping every 30 seconds
260
+ if not agent_done.is_set() and not disconnected.is_set():
261
+ try:
262
+ await ws_send({"type": "websocket.send", "text": json.dumps({"type": "PING"})})
263
+ except Exception:
264
+ # Connection likely closed, stop pinging
265
+ break
266
+
204
267
  send_task = asyncio.create_task(send_outgoing())
205
268
  recv_task = asyncio.create_task(receive_incoming())
269
+ ping_task = asyncio.create_task(send_ping())
206
270
 
207
271
  while not agent_done.is_set():
208
272
  await asyncio.sleep(0.05)
209
273
 
274
+ # Cancel all tasks
210
275
  recv_task.cancel()
276
+ ping_task.cancel()
211
277
  try:
212
278
  await recv_task
213
279
  except asyncio.CancelledError:
214
280
  pass
281
+ try:
282
+ await ping_task
283
+ except asyncio.CancelledError:
284
+ pass
215
285
  await send_task
216
286
 
217
287
  return disconnected.is_set()
288
+
289
+
290
+ def _get_onboard_requirements(trust_agent) -> dict | None:
291
+ """Get onboard requirements from trust config."""
292
+ config = trust_agent.config
293
+ onboard = config.get("onboard", {})
294
+ if not onboard:
295
+ return None
296
+
297
+ result = {"methods": []}
298
+ if "invite_code" in onboard:
299
+ result["methods"].append("invite_code")
300
+ if "payment" in onboard:
301
+ result["methods"].append("payment")
302
+ result["payment_amount"] = onboard["payment"]
303
+ # Include agent's address for payment transfer
304
+ payment_address = trust_agent.get_self_address()
305
+ if payment_address:
306
+ result["payment_address"] = payment_address
307
+
308
+ return result if result["methods"] else None
309
+
310
+
311
+ async def _handle_onboard_submit(data: dict, send, route_handlers: dict):
312
+ """Handle ONBOARD_SUBMIT message from client."""
313
+ trust_agent = route_handlers["trust_agent"]
314
+
315
+ # Authenticate the request (signature check only, open trust)
316
+ _, identity, sig_valid, err = route_handlers["auth"](data, "open")
317
+ if err or not sig_valid:
318
+ await send({"type": "websocket.send",
319
+ "text": json.dumps({"type": "ERROR", "message": err or "invalid signature"})})
320
+ return
321
+
322
+ # Check if blocked BEFORE onboarding (don't waste invite codes/payments)
323
+ if trust_agent.is_blocked(identity):
324
+ await send({"type": "websocket.send",
325
+ "text": json.dumps({"type": "ERROR", "message": "forbidden: blocked"})})
326
+ return
327
+
328
+ payload = data.get("payload", {})
329
+ invite_code = payload.get("invite_code")
330
+ payment = payload.get("payment", 0)
331
+
332
+ # Try invite code
333
+ if invite_code:
334
+ if trust_agent.verify_invite(identity, invite_code):
335
+ # Get actual level after promotion (may differ if already promoted)
336
+ actual_level = trust_agent.get_level(identity)
337
+ # Log to server console for auditing
338
+ console.print(f"[green]✓[/green] Verified [bold]{identity[:16]}...[/bold] with invite code [cyan]{invite_code}[/cyan] → {actual_level}")
339
+ await send({"type": "websocket.send",
340
+ "text": json.dumps({
341
+ "type": "ONBOARD_SUCCESS",
342
+ "identity": identity,
343
+ "level": actual_level,
344
+ "message": f"Invite code verified. You are now a {actual_level}."
345
+ })})
346
+ return
347
+ else:
348
+ console.print(f"[red]✗[/red] Invalid invite code [cyan]{invite_code}[/cyan] from {identity[:16]}...")
349
+ await send({"type": "websocket.send",
350
+ "text": json.dumps({"type": "ERROR", "message": "Invalid invite code"})})
351
+ return
352
+
353
+ # Try payment
354
+ if payment > 0:
355
+ if trust_agent.verify_payment(identity, payment):
356
+ # Get actual level after promotion
357
+ actual_level = trust_agent.get_level(identity)
358
+ # Log to server console for auditing
359
+ console.print(f"[green]✓[/green] Verified [bold]{identity[:16]}...[/bold] with payment [cyan]${payment}[/cyan] → {actual_level}")
360
+ await send({"type": "websocket.send",
361
+ "text": json.dumps({
362
+ "type": "ONBOARD_SUCCESS",
363
+ "identity": identity,
364
+ "level": actual_level,
365
+ "message": f"Payment verified. You are now a {actual_level}."
366
+ })})
367
+ return
368
+ else:
369
+ console.print(f"[red]✗[/red] Insufficient payment [cyan]${payment}[/cyan] from {identity[:16]}...")
370
+ await send({"type": "websocket.send",
371
+ "text": json.dumps({"type": "ERROR", "message": "Insufficient payment"})})
372
+ return
373
+
374
+ await send({"type": "websocket.send",
375
+ "text": json.dumps({"type": "ERROR", "message": "invite_code or payment required"})})
376
+
377
+
378
+ async def _handle_admin_message(data: dict, send, route_handlers: dict):
379
+ """Handle ADMIN_* messages from client."""
380
+ trust_agent = route_handlers["trust_agent"]
381
+ msg_type = data.get("type")
382
+
383
+ # Authenticate the request
384
+ _, identity, sig_valid, err = route_handlers["auth"](data, "open")
385
+ if err or not sig_valid:
386
+ await send({"type": "websocket.send",
387
+ "text": json.dumps({"type": "ERROR", "message": err or "invalid signature"})})
388
+ return
389
+
390
+ # Check admin permission
391
+ if not trust_agent.is_admin(identity):
392
+ await send({"type": "websocket.send",
393
+ "text": json.dumps({"type": "ERROR", "message": "forbidden: admin only"})})
394
+ return
395
+
396
+ payload = data.get("payload", {})
397
+ client_id = payload.get("client_id")
398
+
399
+ # Handle each admin action
400
+ if msg_type == "ADMIN_PROMOTE":
401
+ if not client_id:
402
+ await send({"type": "websocket.send",
403
+ "text": json.dumps({"type": "ERROR", "message": "client_id required"})})
404
+ return
405
+ result = route_handlers["admin_trust_promote"](client_id)
406
+ await send({"type": "websocket.send",
407
+ "text": json.dumps({"type": "ADMIN_RESULT", "action": "promote", **result})})
408
+
409
+ elif msg_type == "ADMIN_DEMOTE":
410
+ if not client_id:
411
+ await send({"type": "websocket.send",
412
+ "text": json.dumps({"type": "ERROR", "message": "client_id required"})})
413
+ return
414
+ result = route_handlers["admin_trust_demote"](client_id)
415
+ await send({"type": "websocket.send",
416
+ "text": json.dumps({"type": "ADMIN_RESULT", "action": "demote", **result})})
417
+
418
+ elif msg_type == "ADMIN_BLOCK":
419
+ if not client_id:
420
+ await send({"type": "websocket.send",
421
+ "text": json.dumps({"type": "ERROR", "message": "client_id required"})})
422
+ return
423
+ reason = payload.get("reason", "")
424
+ result = route_handlers["admin_trust_block"](client_id, reason)
425
+ await send({"type": "websocket.send",
426
+ "text": json.dumps({"type": "ADMIN_RESULT", "action": "block", **result})})
427
+
428
+ elif msg_type == "ADMIN_UNBLOCK":
429
+ if not client_id:
430
+ await send({"type": "websocket.send",
431
+ "text": json.dumps({"type": "ERROR", "message": "client_id required"})})
432
+ return
433
+ result = route_handlers["admin_trust_unblock"](client_id)
434
+ await send({"type": "websocket.send",
435
+ "text": json.dumps({"type": "ADMIN_RESULT", "action": "unblock", **result})})
436
+
437
+ elif msg_type == "ADMIN_GET_LEVEL":
438
+ if not client_id:
439
+ await send({"type": "websocket.send",
440
+ "text": json.dumps({"type": "ERROR", "message": "client_id required"})})
441
+ return
442
+ result = route_handlers["admin_trust_level"](client_id)
443
+ await send({"type": "websocket.send",
444
+ "text": json.dumps({"type": "ADMIN_RESULT", "action": "get_level", **result})})
445
+
446
+ elif msg_type == "ADMIN_ADD":
447
+ # Super admin only
448
+ if not trust_agent.is_super_admin(identity):
449
+ await send({"type": "websocket.send",
450
+ "text": json.dumps({"type": "ERROR", "message": "forbidden: super admin only"})})
451
+ return
452
+ admin_id = payload.get("admin_id")
453
+ if not admin_id:
454
+ await send({"type": "websocket.send",
455
+ "text": json.dumps({"type": "ERROR", "message": "admin_id required"})})
456
+ return
457
+ result = route_handlers["admin_admins_add"](admin_id)
458
+ await send({"type": "websocket.send",
459
+ "text": json.dumps({"type": "ADMIN_RESULT", "action": "add_admin", **result})})
460
+
461
+ elif msg_type == "ADMIN_REMOVE":
462
+ # Super admin only
463
+ if not trust_agent.is_super_admin(identity):
464
+ await send({"type": "websocket.send",
465
+ "text": json.dumps({"type": "ERROR", "message": "forbidden: super admin only"})})
466
+ return
467
+ admin_id = payload.get("admin_id")
468
+ if not admin_id:
469
+ await send({"type": "websocket.send",
470
+ "text": json.dumps({"type": "ERROR", "message": "admin_id required"})})
471
+ return
472
+ result = route_handlers["admin_admins_remove"](admin_id)
473
+ await send({"type": "websocket.send",
474
+ "text": json.dumps({"type": "ADMIN_RESULT", "action": "remove_admin", **result})})
475
+
476
+ else:
477
+ await send({"type": "websocket.send",
478
+ "text": json.dumps({"type": "ERROR", "message": f"Unknown admin action: {msg_type}"})})