protocol.py
1 """Wire protocol for gateway ↔ node RPC. 2 3 Everything is a JSON object with the same envelope shape: 4 5 Request: {"type": <str>, "id": <str>, "token": <str>, "payload": <dict>} 6 Response: {"type": "<req-type>_res", "id": <req-id>, "payload": <dict>} 7 Error: {"type": "error", "id": <req-id>, "error": <str>} 8 9 Requests must carry the shared bearer token (set up via 10 ``hermes meet node approve`` on the gateway and read off disk on the 11 server). Mismatched tokens are rejected before dispatch. 12 """ 13 14 from __future__ import annotations 15 16 import json 17 import uuid 18 from typing import Any, Dict, Tuple 19 20 21 VALID_REQUEST_TYPES = frozenset({ 22 "start_bot", 23 "stop", 24 "status", 25 "transcript", 26 "say", 27 "ping", 28 }) 29 30 31 def make_request( 32 type: str, 33 token: str, 34 payload: Dict[str, Any], 35 req_id: str | None = None, 36 ) -> Dict[str, Any]: 37 """Construct a request envelope. 38 39 ``req_id`` is auto-generated (uuid4 hex) when not supplied so callers 40 can correlate async responses. 41 """ 42 if not isinstance(type, str) or not type: 43 raise ValueError("type must be a non-empty string") 44 if type not in VALID_REQUEST_TYPES: 45 raise ValueError(f"unknown request type: {type!r}") 46 if not isinstance(token, str): 47 raise ValueError("token must be a string") 48 if not isinstance(payload, dict): 49 raise ValueError("payload must be a dict") 50 return { 51 "type": type, 52 "id": req_id or uuid.uuid4().hex, 53 "token": token, 54 "payload": payload, 55 } 56 57 58 def make_response(req_id: str, payload: Dict[str, Any]) -> Dict[str, Any]: 59 """Build a success response. The caller supplies the *request* type; 60 we suffix it with ``_res`` so clients can assert they got the right 61 reply. 62 63 For simplicity we don't require the type here — clients usually just 64 key off ``id``. But we still emit a generic ``*_res`` envelope. 65 """ 66 if not isinstance(payload, dict): 67 raise ValueError("payload must be a dict") 68 return {"type": "response", "id": req_id, "payload": payload} 69 70 71 def make_error(req_id: str, error: str) -> Dict[str, Any]: 72 return {"type": "error", "id": req_id, "error": str(error)} 73 74 75 def encode(msg: Dict[str, Any]) -> str: 76 """Serialize a message envelope to a JSON string.""" 77 return json.dumps(msg, separators=(",", ":"), ensure_ascii=False) 78 79 80 def decode(raw: str) -> Dict[str, Any]: 81 """Parse a JSON envelope, raising ValueError on anything malformed. 82 83 Minimal type validation: must be an object, must contain ``type`` and 84 ``id``. Heavier validation (token match, payload shape) happens in 85 :func:`validate_request` on the server side. 86 """ 87 try: 88 obj = json.loads(raw) 89 except (TypeError, json.JSONDecodeError) as exc: 90 raise ValueError(f"malformed JSON: {exc}") from exc 91 if not isinstance(obj, dict): 92 raise ValueError("envelope must be a JSON object") 93 if "type" not in obj or not isinstance(obj["type"], str): 94 raise ValueError("envelope missing string 'type'") 95 if "id" not in obj or not isinstance(obj["id"], str): 96 raise ValueError("envelope missing string 'id'") 97 return obj 98 99 100 def validate_request(msg: Dict[str, Any], expected_token: str) -> Tuple[bool, str]: 101 """Check a decoded request against the server's shared token. 102 103 Returns ``(True, "")`` when the envelope is acceptable or 104 ``(False, <reason>)`` otherwise. Reason strings are safe to surface 105 back to the client in an error envelope. 106 """ 107 if not isinstance(msg, dict): 108 return False, "envelope must be a dict" 109 t = msg.get("type") 110 if not isinstance(t, str) or not t: 111 return False, "missing or non-string 'type'" 112 if t not in VALID_REQUEST_TYPES: 113 return False, f"unknown request type: {t!r}" 114 if not isinstance(msg.get("id"), str) or not msg.get("id"): 115 return False, "missing or non-string 'id'" 116 token = msg.get("token") 117 if not isinstance(token, str) or not token: 118 return False, "missing token" 119 if token != expected_token: 120 return False, "token mismatch" 121 payload = msg.get("payload") 122 if not isinstance(payload, dict): 123 return False, "payload must be a dict" 124 return True, ""