diff --git a/app/src/components/MeshPanel.tsx b/app/src/components/MeshPanel.tsx
index 516d09e..cab9885 100644
--- a/app/src/components/MeshPanel.tsx
+++ b/app/src/components/MeshPanel.tsx
@@ -19,25 +19,23 @@ export function MeshPanel({ mesh }: MeshPanelProps) {
- {mesh.mqtt_connected ? (
+ {mesh.connected ? (
) : (
)}
- {mesh.mqtt_connected ? "connected" : "disconnected"}
+ {mesh.connected ? "connected" : "disconnected"}
- {mesh.mqtt_broker_host && (
-
- mqtt://{mesh.mqtt_broker_host}:{mesh.mqtt_broker_port}
-
+ {mesh.nats_url && (
+ {mesh.nats_url}
)}
{mesh.mdns_enabled && (
mDNS active
diff --git a/app/src/types/index.ts b/app/src/types/index.ts
index 8b6fc4a..915d51d 100644
--- a/app/src/types/index.ts
+++ b/app/src/types/index.ts
@@ -325,9 +325,8 @@ export interface NodeDetail extends NodeSummary {
export interface MeshStatus {
enabled: boolean
- mqtt_connected: boolean
- mqtt_broker_host: string | null
- mqtt_broker_port: number | null
+ connected: boolean
+ nats_url: string | null
mdns_enabled: boolean
peer_count: number
peers: string[]
diff --git a/castle-api/pyproject.toml b/castle-api/pyproject.toml
index aff58e3..4d3591e 100644
--- a/castle-api/pyproject.toml
+++ b/castle-api/pyproject.toml
@@ -9,7 +9,7 @@ dependencies = [
"pydantic-settings>=2.0.0",
"httpx>=0.27.0",
"castle-core",
- "paho-mqtt>=2.0.0",
+ "nats-py>=2.9.0",
"zeroconf>=0.131.0",
]
diff --git a/castle-api/src/castle_api/config.py b/castle-api/src/castle_api/config.py
index d641ad2..e85a8b0 100644
--- a/castle-api/src/castle_api/config.py
+++ b/castle-api/src/castle_api/config.py
@@ -15,9 +15,8 @@ class Settings(BaseSettings):
port: int = 9020
# Mesh coordination (all off by default — single-node works without them)
- mqtt_enabled: bool = False
- mqtt_host: str = "localhost"
- mqtt_port: int = 1883
+ nats_enabled: bool = False
+ nats_url: str = "nats://localhost:4222"
mdns_enabled: bool = False
model_config = {
diff --git a/castle-api/src/castle_api/main.py b/castle-api/src/castle_api/main.py
index 7a71039..f291f39 100644
--- a/castle-api/src/castle_api/main.py
+++ b/castle-api/src/castle_api/main.py
@@ -56,25 +56,24 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
poll_task = asyncio.create_task(health_poll_loop())
# --- Mesh coordination (opt-in) ---
- mqtt_client = None
+ nats_client = None
mdns_service = None
- if settings.mqtt_enabled:
+ if settings.nats_enabled:
try:
- from castle_api.mqtt_client import CastleMQTTClient
+ from castle_api.nats_client import CastleNATSClient
registry = get_registry()
- mqtt_client = CastleMQTTClient(
+ nats_client = CastleNATSClient(
local_hostname=registry.node.hostname,
local_registry=registry,
- broker_host=settings.mqtt_host,
- broker_port=settings.mqtt_port,
+ servers=settings.nats_url,
)
- await mqtt_client.start()
- app.state.mqtt_client = mqtt_client
+ await nats_client.start()
+ app.state.nats_client = nats_client
except Exception:
- logger.exception("Failed to start MQTT client")
- mqtt_client = None
+ logger.exception("Failed to start NATS mesh client")
+ nats_client = None
if settings.mdns_enabled:
try:
@@ -97,8 +96,8 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
_shutting_down = True
poll_task.cancel()
- if mqtt_client:
- await mqtt_client.stop()
+ if nats_client:
+ await nats_client.stop()
if mdns_service:
mdns_service.stop()
diff --git a/castle-api/src/castle_api/mdns.py b/castle-api/src/castle_api/mdns.py
index 6205315..844cbaa 100644
--- a/castle-api/src/castle_api/mdns.py
+++ b/castle-api/src/castle_api/mdns.py
@@ -1,8 +1,7 @@
-"""mDNS discovery for castle mesh — zero-config LAN peer and broker discovery.
+"""mDNS discovery for castle mesh — zero-config LAN peer discovery.
-Advertises this node as _castle._tcp and browses for:
- - _castle._tcp — peer castle nodes
- - _mqtt._tcp — MQTT broker address
+Advertises this node as _castle._tcp and browses for _castle._tcp peers. This is
+the on-LAN convenience path; cross-network meshes use explicit NATS seed URLs.
"""
from __future__ import annotations
@@ -15,11 +14,10 @@ from zeroconf import ServiceBrowser, ServiceInfo, ServiceStateChange, Zeroconf
logger = logging.getLogger(__name__)
CASTLE_SERVICE_TYPE = "_castle._tcp.local."
-MQTT_SERVICE_TYPE = "_mqtt._tcp.local."
class CastleMDNS:
- """Advertise this castle node and discover peers + MQTT broker via mDNS."""
+ """Advertise this castle node and discover peers via mDNS."""
def __init__(
self,
@@ -38,7 +36,6 @@ class CastleMDNS:
self.peers: dict[
str, dict
] = {} # hostname -> {gateway_port, api_port, addresses}
- self.mqtt_broker: dict | None = None # {host, port} or None
def _on_service_state_change(
self,
@@ -55,8 +52,6 @@ class CastleMDNS:
if service_type == CASTLE_SERVICE_TYPE:
self._handle_castle_peer(info)
- elif service_type == MQTT_SERVICE_TYPE:
- self._handle_mqtt_broker(info)
elif state_change == ServiceStateChange.Removed:
if service_type == CASTLE_SERVICE_TYPE:
@@ -89,20 +84,6 @@ class CastleMDNS:
}
logger.info("mDNS: discovered peer %s at %s", peer_hostname, addresses)
- def _handle_mqtt_broker(self, info: ServiceInfo) -> None:
- """Process a discovered MQTT broker."""
- addresses = [
- socket.inet_ntoa(addr) for addr in info.addresses if len(addr) == 4
- ]
- if addresses:
- self.mqtt_broker = {
- "host": addresses[0],
- "port": info.port,
- }
- logger.info(
- "mDNS: discovered MQTT broker at %s:%d", addresses[0], info.port
- )
-
def start(self) -> None:
"""Start advertising and browsing."""
self._zeroconf = Zeroconf()
@@ -123,7 +104,7 @@ class CastleMDNS:
"mDNS: advertising %s on port %d", self._hostname, self._gateway_port
)
- # Browse for peers and MQTT broker
+ # Browse for peer castle nodes
self._browsers.append(
ServiceBrowser(
self._zeroconf,
@@ -131,13 +112,6 @@ class CastleMDNS:
handlers=[self._on_service_state_change],
)
)
- self._browsers.append(
- ServiceBrowser(
- self._zeroconf,
- MQTT_SERVICE_TYPE,
- handlers=[self._on_service_state_change],
- )
- )
def stop(self) -> None:
"""Stop advertising and close."""
diff --git a/castle-api/src/castle_api/mesh_wire.py b/castle-api/src/castle_api/mesh_wire.py
new file mode 100644
index 0000000..9f66509
--- /dev/null
+++ b/castle-api/src/castle_api/mesh_wire.py
@@ -0,0 +1,99 @@
+"""Mesh wire format — (de)serialize a NodeRegistry for cross-node transport.
+
+Transport-agnostic (no MQTT/NATS imports). Only the fields needed for mesh
+routing are included; env vars, run_cmd, and castle_root are **excluded** to
+avoid leaking secrets — this invariant is load-bearing and must be preserved by
+any transport that carries this payload.
+"""
+
+from __future__ import annotations
+
+import json
+
+from castle_core.registry import (
+ Deployment,
+ NodeConfig,
+ NodeRegistry,
+)
+
+
+def registry_to_json(registry: NodeRegistry) -> str:
+ """Serialize a NodeRegistry to JSON (secret-stripped)."""
+ data: dict = {
+ "node": {
+ "hostname": registry.node.hostname,
+ "gateway_port": registry.node.gateway_port,
+ # acme domain — lets peers build launch URLs (.)
+ # for this node's exposed apps. Omitted when the node has no domain.
+ "gateway_domain": registry.node.gateway_domain,
+ },
+ "deployed": {},
+ }
+
+ for _kind, name, comp in registry.all():
+ entry: dict = {
+ "manager": comp.manager,
+ "launcher": comp.launcher,
+ "kind": comp.kind,
+ }
+ if comp.stack:
+ entry["stack"] = comp.stack
+ if comp.description:
+ entry["description"] = comp.description
+ if comp.port is not None:
+ entry["port"] = comp.port
+ if comp.health_path:
+ entry["health_path"] = comp.health_path
+ if comp.subdomain:
+ entry["subdomain"] = comp.subdomain
+ if comp.schedule:
+ entry["schedule"] = comp.schedule
+ if comp.managed:
+ entry["managed"] = comp.managed
+ # Socket surface + external target — so a peer can resolve cross-node
+ # consumption endpoints (still no secrets: only ports/URLs).
+ if getattr(comp, "tcp_port", None) is not None:
+ entry["tcp_port"] = comp.tcp_port
+ if getattr(comp, "base_url", None):
+ entry["base_url"] = comp.base_url
+ # requires — deployment refs (no secrets), so peers can draw cross-node deps.
+ if getattr(comp, "requires", None):
+ entry["requires"] = comp.requires
+ data["deployed"][NodeRegistry.key(comp.kind, name)] = entry
+
+ return json.dumps(data)
+
+
+def json_to_registry(payload: str) -> NodeRegistry:
+ """Deserialize a NodeRegistry from a JSON payload."""
+ data = json.loads(payload)
+ node_data = data.get("node", {})
+ node = NodeConfig(
+ hostname=node_data.get("hostname", ""),
+ castle_root=node_data.get("castle_root"),
+ gateway_port=node_data.get("gateway_port", 9000),
+ gateway_domain=node_data.get("gateway_domain"),
+ )
+ deployed: dict[str, Deployment] = {}
+ for key, comp_data in data.get("deployed", {}).items():
+ key_kind, name = key.split("/", 1) if "/" in key else (None, key)
+ kind = comp_data.get("kind") or key_kind or "service"
+ deployed[NodeRegistry.key(kind, name)] = Deployment(
+ manager=comp_data.get("manager", "systemd"),
+ launcher=comp_data.get("launcher"),
+ run_cmd=comp_data.get("run_cmd", []),
+ env=comp_data.get("env", {}),
+ description=comp_data.get("description"),
+ name=name,
+ kind=kind,
+ stack=comp_data.get("stack"),
+ port=comp_data.get("port"),
+ health_path=comp_data.get("health_path"),
+ subdomain=comp_data.get("subdomain"),
+ schedule=comp_data.get("schedule"),
+ managed=comp_data.get("managed", False),
+ tcp_port=comp_data.get("tcp_port"),
+ base_url=comp_data.get("base_url"),
+ requires=comp_data.get("requires", []),
+ )
+ return NodeRegistry(node=node, deployed=deployed)
diff --git a/castle-api/src/castle_api/models.py b/castle-api/src/castle_api/models.py
index d2459c3..d7fce04 100644
--- a/castle-api/src/castle_api/models.py
+++ b/castle-api/src/castle_api/models.py
@@ -218,9 +218,8 @@ class MeshStatus(BaseModel):
"""Current state of the mesh coordination layer."""
enabled: bool = False
- mqtt_connected: bool = False
- mqtt_broker_host: str | None = None
- mqtt_broker_port: int | None = None
+ connected: bool = False
+ nats_url: str | None = None
mdns_enabled: bool = False
peer_count: int = 0
peers: list[str] = []
diff --git a/castle-api/src/castle_api/nats_client.py b/castle-api/src/castle_api/nats_client.py
new file mode 100644
index 0000000..0194058
--- /dev/null
+++ b/castle-api/src/castle_api/nats_client.py
@@ -0,0 +1,184 @@
+"""NATS JetStream client for inter-node mesh coordination.
+
+Replaces the MQTT transport. State lives in a JetStream **KV bucket** rather than
+retained MQTT messages:
+
+ castle-registry — key=, value=secret-stripped NodeRegistry JSON
+
+Lifecycle:
+ * on connect: ensure the bucket, PUT our registry, seed local state from every
+ existing key, then watch the bucket for peer changes.
+ * heartbeat: re-PUT our registry every HEARTBEAT_SEC so peers refresh their
+ last-seen clock (crash liveness rides the existing stale-TTL).
+ * graceful stop: DELETE our key → peers get an immediate offline signal.
+
+Being asyncio-native, the watch callback calls ``broadcast`` directly — no
+cross-thread ``run_coroutine_threadsafe`` hop (which the paho client needed).
+"""
+
+from __future__ import annotations
+
+import asyncio
+import contextlib
+import logging
+
+import nats
+from nats.js.api import KeyValueConfig
+
+from castle_core.registry import NodeRegistry
+
+from castle_api.mesh import mesh_state
+from castle_api.mesh_wire import json_to_registry, registry_to_json
+from castle_api.stream import broadcast
+
+logger = logging.getLogger(__name__)
+
+REGISTRY_BUCKET = "castle-registry"
+HEARTBEAT_SEC = 30.0
+PRUNE_SEC = 30.0
+
+
+class CastleNATSClient:
+ """Async NATS/JetStream mesh client."""
+
+ def __init__(
+ self,
+ local_hostname: str,
+ local_registry: NodeRegistry,
+ servers: str | list[str] = "nats://localhost:4222",
+ ) -> None:
+ self._local_hostname = local_hostname
+ self._local_registry = local_registry
+ self._servers = servers
+ self._nc: nats.NATS | None = None
+ self._kv = None
+ self._tasks: list[asyncio.Task] = []
+ self._last_json: dict[str, str] = {}
+ self._online: set[str] = set()
+
+ @property
+ def connected(self) -> bool:
+ return self._nc is not None and self._nc.is_connected
+
+ @property
+ def servers(self) -> str | list[str]:
+ return self._servers
+
+ async def start(self) -> None:
+ """Connect, publish our registry, seed state, and start watchers."""
+ self._nc = await nats.connect(
+ self._servers,
+ name=f"castle-{self._local_hostname}",
+ max_reconnect_attempts=-1, # reconnect forever — nodes come and go
+ )
+ js = self._nc.jetstream()
+ try:
+ self._kv = await js.key_value(REGISTRY_BUCKET)
+ except Exception:
+ self._kv = await js.create_key_value(
+ config=KeyValueConfig(bucket=REGISTRY_BUCKET, history=1)
+ )
+
+ await self.publish_registry(self._local_registry)
+ await self._seed_existing()
+
+ self._tasks = [
+ asyncio.create_task(self._watch_loop()),
+ asyncio.create_task(self._heartbeat_loop()),
+ asyncio.create_task(self._prune_loop()),
+ ]
+ logger.info("NATS mesh client started (servers=%s)", self._servers)
+
+ async def stop(self) -> None:
+ """Delete our key (immediate offline to peers) and disconnect."""
+ for t in self._tasks:
+ t.cancel()
+ for t in self._tasks:
+ with contextlib.suppress(asyncio.CancelledError, Exception):
+ await t
+ self._tasks = []
+ if self._kv is not None:
+ with contextlib.suppress(Exception):
+ await self._kv.delete(self._local_hostname)
+ if self._nc is not None:
+ with contextlib.suppress(Exception):
+ await self._nc.drain()
+ self._nc = None
+ logger.info("NATS mesh client stopped")
+
+ async def publish_registry(self, registry: NodeRegistry) -> None:
+ """PUT (or refresh) our local registry into the KV bucket."""
+ self._local_registry = registry
+ if self._kv is None:
+ return
+ await self._kv.put(
+ self._local_hostname, registry_to_json(registry).encode()
+ )
+
+ async def _seed_existing(self) -> None:
+ """Load every peer key already present in the bucket."""
+ if self._kv is None:
+ return
+ try:
+ keys = await self._kv.keys()
+ except Exception:
+ keys = [] # empty bucket raises NoKeysError in nats-py
+ for key in keys:
+ if key == self._local_hostname:
+ continue
+ with contextlib.suppress(Exception):
+ entry = await self._kv.get(key)
+ if entry.value:
+ self._apply_put(key, entry.value.decode())
+
+ async def _watch_loop(self) -> None:
+ assert self._kv is not None
+ watcher = await self._kv.watchall()
+ async for entry in watcher:
+ if entry is None: # "caught up with current values" sentinel
+ continue
+ key = entry.key
+ if key == self._local_hostname:
+ continue
+ try:
+ if entry.operation in ("DEL", "PURGE"):
+ await self._apply_delete(key)
+ elif entry.value:
+ changed = self._apply_put(key, entry.value.decode())
+ if changed:
+ await broadcast(
+ "mesh", {"event": "node_updated", "hostname": key}
+ )
+ except Exception:
+ logger.exception("Error handling mesh entry for %s", key)
+
+ def _apply_put(self, hostname: str, payload: str) -> bool:
+ """Update mesh state from a peer PUT. Returns True if content changed."""
+ registry = json_to_registry(payload)
+ mesh_state.update_node(hostname, registry) # always refresh last-seen
+ self._online.add(hostname)
+ changed = self._last_json.get(hostname) != payload
+ self._last_json[hostname] = payload
+ return changed
+
+ async def _apply_delete(self, hostname: str) -> None:
+ mesh_state.set_offline(hostname)
+ self._online.discard(hostname)
+ self._last_json.pop(hostname, None)
+ await broadcast("mesh", {"event": "node_offline", "hostname": hostname})
+
+ async def _heartbeat_loop(self) -> None:
+ while True:
+ await asyncio.sleep(HEARTBEAT_SEC)
+ with contextlib.suppress(Exception):
+ await self.publish_registry(self._local_registry)
+
+ async def _prune_loop(self) -> None:
+ """Mark crashed peers (no refresh within the stale TTL) offline."""
+ while True:
+ await asyncio.sleep(PRUNE_SEC)
+ all_nodes = mesh_state.all_nodes(include_stale=True)
+ for host in list(self._online):
+ node = all_nodes.get(host)
+ if node is None or node.is_stale:
+ await self._apply_delete(host)
diff --git a/castle-api/src/castle_api/nodes.py b/castle-api/src/castle_api/nodes.py
index cd5851c..9fd31da 100644
--- a/castle-api/src/castle_api/nodes.py
+++ b/castle-api/src/castle_api/nodes.py
@@ -66,15 +66,14 @@ def _deployed_to_summaries(registry: object, hostname: str) -> list[DeploymentSu
@router.get("/mesh/status", response_model=MeshStatus)
def get_mesh_status(request: Request) -> MeshStatus:
"""Get the current state of the mesh coordination layer."""
- mqtt_client = getattr(request.app.state, "mqtt_client", None)
+ nats_client = getattr(request.app.state, "nats_client", None)
peers = list(mesh_state.all_nodes(include_stale=True).keys())
return MeshStatus(
- enabled=settings.mqtt_enabled,
- mqtt_connected=mqtt_client.connected if mqtt_client else False,
- mqtt_broker_host=mqtt_client.broker_host if mqtt_client else None,
- mqtt_broker_port=mqtt_client.broker_port if mqtt_client else None,
+ enabled=settings.nats_enabled,
+ connected=nats_client.connected if nats_client else False,
+ nats_url=str(nats_client.servers) if nats_client else None,
mdns_enabled=settings.mdns_enabled,
peer_count=len(peers),
peers=peers,
diff --git a/castle-api/tests/test_mesh_wire.py b/castle-api/tests/test_mesh_wire.py
new file mode 100644
index 0000000..1467b69
--- /dev/null
+++ b/castle-api/tests/test_mesh_wire.py
@@ -0,0 +1,110 @@
+"""Tests for the transport-agnostic mesh wire format (registry (de)serialization)."""
+
+import json
+
+from castle_core.registry import Deployment, NodeConfig, NodeRegistry
+
+from castle_api.mesh_wire import json_to_registry, registry_to_json
+
+
+def _make_registry() -> NodeRegistry:
+ return NodeRegistry(
+ node=NodeConfig(
+ hostname="tower", castle_root="/data/repos/castle", gateway_port=9000
+ ),
+ deployed={
+ "my-svc": Deployment(
+ manager="systemd",
+ launcher="python",
+ run_cmd=["uv", "run", "my-svc"],
+ env={"PORT": "9001", "SECRET_KEY": "super-secret"},
+ description="My service",
+ name="my-svc",
+ kind="service",
+ stack="python-fastapi",
+ port=9001,
+ health_path="/health",
+ subdomain="my-svc",
+ managed=True,
+ ),
+ "my-job": Deployment(
+ manager="systemd",
+ launcher="command",
+ run_cmd=["my-job"],
+ name="my-job",
+ kind="job",
+ stack="python-cli",
+ schedule="0 2 * * *",
+ ),
+ },
+ )
+
+
+class TestRegistrySerialization:
+ """Round-trip serialization of NodeRegistry to/from JSON."""
+
+ def test_round_trip(self) -> None:
+ original = _make_registry()
+ json_str = registry_to_json(original)
+ restored = json_to_registry(json_str)
+
+ assert restored.node.hostname == "tower"
+ assert restored.node.gateway_port == 9000
+
+ def test_deployed_components_preserved(self) -> None:
+ original = _make_registry()
+ restored = json_to_registry(registry_to_json(original))
+
+ svc = restored.get("service", "my-svc")
+ assert svc is not None
+ assert svc.manager == "systemd"
+ assert svc.launcher == "python"
+ assert svc.port == 9001
+ assert svc.health_path == "/health"
+ assert svc.subdomain == "my-svc"
+ assert svc.managed is True
+ assert svc.kind == "service"
+ assert svc.stack == "python-fastapi"
+
+ def test_job_fields_preserved(self) -> None:
+ original = _make_registry()
+ restored = json_to_registry(registry_to_json(original))
+
+ job = restored.get("job", "my-job")
+ assert job is not None
+ assert job.launcher == "command"
+ assert job.schedule == "0 2 * * *"
+ assert job.kind == "job"
+ assert job.stack == "python-cli"
+
+ def test_optional_fields_omitted(self) -> None:
+ """Fields like port, health_path are None when not set."""
+ reg = NodeRegistry(
+ node=NodeConfig(hostname="minimal"),
+ deployed={
+ "bare": Deployment(
+ manager="systemd", launcher="command", run_cmd=["bare"], name="bare"
+ ),
+ },
+ )
+ restored = json_to_registry(registry_to_json(reg))
+ bare = restored.get("service", "bare")
+ assert bare.port is None
+ assert bare.health_path is None
+ assert bare.subdomain is None
+ assert bare.schedule is None
+ assert bare.managed is False
+
+ def test_no_secrets_in_payload(self) -> None:
+ """env vars, run_cmd, and castle_root must never appear on the wire."""
+ original = _make_registry()
+ json_str = registry_to_json(original)
+ data = json.loads(json_str)
+
+ # No castle_root in node
+ assert "castle_root" not in data["node"]
+
+ # No env or run_cmd in any component
+ for name, comp in data["deployed"].items():
+ assert "env" not in comp, f"{name} has env in payload"
+ assert "run_cmd" not in comp, f"{name} has run_cmd in payload"
diff --git a/uv.lock b/uv.lock
index fb1eaf0..ecaf328 100644
--- a/uv.lock
+++ b/uv.lock
@@ -48,7 +48,7 @@ dependencies = [
{ name = "castle-core" },
{ name = "fastapi" },
{ name = "httpx" },
- { name = "paho-mqtt" },
+ { name = "nats-py" },
{ name = "pydantic-settings" },
{ name = "uvicorn", extra = ["standard"] },
{ name = "zeroconf" },
@@ -67,7 +67,7 @@ requires-dist = [
{ name = "castle-core", editable = "core" },
{ name = "fastapi", specifier = ">=0.115.0" },
{ name = "httpx", specifier = ">=0.27.0" },
- { name = "paho-mqtt", specifier = ">=2.0.0" },
+ { name = "nats-py", specifier = ">=2.9.0" },
{ name = "pydantic-settings", specifier = ">=2.0.0" },
{ name = "uvicorn", extras = ["standard"], specifier = ">=0.34.0" },
{ name = "zeroconf", specifier = ">=0.131.0" },
@@ -349,6 +349,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" },
]
+[[package]]
+name = "nats-py"
+version = "2.15.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/02/f0/fc5e93f2b0dd14a202590ad9d30eda1955ea872039b5204357348d0f4b1e/nats_py-2.15.0.tar.gz", hash = "sha256:6622c547d9a7d2313d9c147d46c386188f4ec2c7b5c9f9a0438a4d1b55f54a93", size = 75995, upload-time = "2026-06-05T07:34:03.904Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/db/a8/b55606c7c621fb813c8ec78baf201d2c78bf6051091ec0c7ada572999e95/nats_py-2.15.0-py3-none-any.whl", hash = "sha256:9f8d36aa52a9926a88b8f1d70cf1fdce0ad387941479b500ee9ab3e51073cefd", size = 90334, upload-time = "2026-06-05T07:34:02.81Z" },
+]
+
[[package]]
name = "nodeenv"
version = "1.10.0"
@@ -367,15 +376,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529", size = 74366, upload-time = "2026-01-21T20:50:37.788Z" },
]
-[[package]]
-name = "paho-mqtt"
-version = "2.1.0"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/39/15/0a6214e76d4d32e7f663b109cf71fb22561c2be0f701d67f93950cd40542/paho_mqtt-2.1.0.tar.gz", hash = "sha256:12d6e7511d4137555a3f6ea167ae846af2c7357b10bc6fa4f7c3968fc1723834", size = 148848, upload-time = "2024-04-29T19:52:55.591Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/c4/cb/00451c3cf31790287768bb12c6bec834f5d292eaf3022afc88e14b8afc94/paho_mqtt-2.1.0-py3-none-any.whl", hash = "sha256:6db9ba9b34ed5bc6b6e3812718c7e06e2fd7444540df2455d2c51bd58808feee", size = 67219, upload-time = "2024-04-29T19:52:48.345Z" },
-]
-
[[package]]
name = "pluggy"
version = "1.6.0"