fleetmesh-node 0.2.0__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 (338) hide show
  1. fleetmesh_node-0.2.0.dist-info/METADATA +57 -0
  2. fleetmesh_node-0.2.0.dist-info/RECORD +338 -0
  3. fleetmesh_node-0.2.0.dist-info/WHEEL +5 -0
  4. fleetmesh_node-0.2.0.dist-info/entry_points.txt +2 -0
  5. fleetmesh_node-0.2.0.dist-info/licenses/LICENSE +121 -0
  6. fleetmesh_node-0.2.0.dist-info/top_level.txt +2 -0
  7. node/RELEASE +1 -0
  8. node/__init__.py +35 -0
  9. node/app/__init__.py +49 -0
  10. node/app/commands.py +265 -0
  11. node/app/config.py +131 -0
  12. node/app/control.py +670 -0
  13. node/app/core.py +867 -0
  14. node/app/plugins.py +76 -0
  15. node/app/router.py +83 -0
  16. node/app/socket.py +431 -0
  17. node/app/systemd.py +36 -0
  18. node/app/views.py +204 -0
  19. node/channels/__init__.py +378 -0
  20. node/channels/bus.py +331 -0
  21. node/channels/journal.py +46 -0
  22. node/commands/__init__.py +140 -0
  23. node/commands/adapter.py +210 -0
  24. node/commands/base.py +278 -0
  25. node/commands/cluster_cmds.py +183 -0
  26. node/commands/compute_cmds.py +401 -0
  27. node/commands/core.py +474 -0
  28. node/commands/gossip_cmds.py +106 -0
  29. node/commands/nsite_cmds.py +141 -0
  30. node/commands/peering.py +402 -0
  31. node/commands/plugin_cmds.py +135 -0
  32. node/commands/sidecar_cmds.py +120 -0
  33. node/commands/sync.py +388 -0
  34. node/commands/transport_cmds.py +451 -0
  35. node/commands/video_cmds.py +99 -0
  36. node/compartment/__init__.py +124 -0
  37. node/compartment/base.py +868 -0
  38. node/compartment/command.py +458 -0
  39. node/compartment/context.py +163 -0
  40. node/compartment/every.py +108 -0
  41. node/compartment/handler.py +131 -0
  42. node/compartment/lifecycle.py +460 -0
  43. node/compartment/lint.py +388 -0
  44. node/compartment/need.py +158 -0
  45. node/compartment/problem.py +521 -0
  46. node/compartment/refusals.py +120 -0
  47. node/compartment/registry.py +790 -0
  48. node/compartment/schema.py +290 -0
  49. node/compartment/sidecar.py +499 -0
  50. node/compartment/stores.py +662 -0
  51. node/compartment/wire.py +225 -0
  52. node/compartment/worker.py +61 -0
  53. node/config/__init__.py +43 -0
  54. node/config/base.py +116 -0
  55. node/config/change.py +441 -0
  56. node/config/changestore.py +201 -0
  57. node/config/compartments.py +1144 -0
  58. node/config/node.py +318 -0
  59. node/config/parameter.py +563 -0
  60. node/config/protocols.py +274 -0
  61. node/config/records.py +171 -0
  62. node/examples/__init__.py +1 -0
  63. node/examples/demo.py +226 -0
  64. node/examples/follower.py +46 -0
  65. node/interfaces.py +235 -0
  66. node/keystore/__init__.py +27 -0
  67. node/keystore/core.py +103 -0
  68. node/keystore/enclave.py +688 -0
  69. node/meshnode.py +111 -0
  70. node/metrics/__init__.py +396 -0
  71. node/metrics/adapters.py +102 -0
  72. node/metrics/face.py +110 -0
  73. node/planes/__init__.py +21 -0
  74. node/planes/identity/__init__.py +20 -0
  75. node/planes/identity/announce/__init__.py +9 -0
  76. node/planes/identity/announce/compartment.py +447 -0
  77. node/planes/identity/announce/types.py +26 -0
  78. node/planes/identity/discovery/__init__.py +9 -0
  79. node/planes/identity/discovery/compartment.py +522 -0
  80. node/planes/identity/discovery/types.py +13 -0
  81. node/planes/identity/linked/__init__.py +1 -0
  82. node/planes/identity/linked/compartment.py +270 -0
  83. node/planes/identity/linked/types.py +25 -0
  84. node/planes/identity/presence/__init__.py +7 -0
  85. node/planes/identity/presence/compartment.py +409 -0
  86. node/planes/identity/presence/types.py +14 -0
  87. node/planes/operations/__init__.py +13 -0
  88. node/planes/operations/scheduler/__init__.py +9 -0
  89. node/planes/operations/scheduler/compartment.py +353 -0
  90. node/planes/operations/scheduler/types.py +21 -0
  91. node/planes/operations/updates/__init__.py +17 -0
  92. node/planes/operations/updates/core.py +310 -0
  93. node/planes/operations/updates/store.py +79 -0
  94. node/planes/operations/updates/types.py +20 -0
  95. node/planes/peering/__init__.py +28 -0
  96. node/planes/peering/budget/__init__.py +43 -0
  97. node/planes/peering/budget/chain.py +43 -0
  98. node/planes/peering/budget/core.py +537 -0
  99. node/planes/peering/budget/evaluation.py +80 -0
  100. node/planes/peering/budget/meter.py +100 -0
  101. node/planes/peering/budget/receipts.py +439 -0
  102. node/planes/peering/budget/shape.py +101 -0
  103. node/planes/peering/budget/tick.py +110 -0
  104. node/planes/peering/budget/types.py +67 -0
  105. node/planes/peering/budget/windows.py +346 -0
  106. node/planes/peering/capacity/__init__.py +7 -0
  107. node/planes/peering/capacity/compartment.py +342 -0
  108. node/planes/peering/capacity/types.py +24 -0
  109. node/planes/peering/peering/__init__.py +25 -0
  110. node/planes/peering/peering/core.py +464 -0
  111. node/planes/peering/peering/envelope.py +142 -0
  112. node/planes/peering/peering/handshake.py +198 -0
  113. node/planes/peering/peering/shape.py +80 -0
  114. node/planes/peering/peering/types.py +39 -0
  115. node/planes/peering/reporter/__init__.py +42 -0
  116. node/planes/peering/reporter/constants.py +29 -0
  117. node/planes/peering/reporter/core.py +299 -0
  118. node/planes/peering/reporter/finding.py +74 -0
  119. node/planes/peering/reporter/findings.py +171 -0
  120. node/planes/peering/reporter/publish.py +285 -0
  121. node/planes/peering/reporter/reading.py +193 -0
  122. node/planes/peering/reporter/shape.py +86 -0
  123. node/planes/peering/reporter/types.py +50 -0
  124. node/planes/peering/strain/__init__.py +9 -0
  125. node/planes/peering/strain/compartment.py +204 -0
  126. node/planes/peering/strain/types.py +21 -0
  127. node/planes/peering/trust/__init__.py +9 -0
  128. node/planes/peering/trust/compartment.py +239 -0
  129. node/planes/peering/trust/types.py +30 -0
  130. node/planes/services/__init__.py +33 -0
  131. node/planes/services/compute/__init__.py +160 -0
  132. node/planes/services/compute/common.py +134 -0
  133. node/planes/services/compute/compartment.py +681 -0
  134. node/planes/services/compute/executors/__init__.py +121 -0
  135. node/planes/services/compute/executors/agent.py +840 -0
  136. node/planes/services/compute/executors/bacalhau.py +605 -0
  137. node/planes/services/compute/executors/base.py +127 -0
  138. node/planes/services/compute/executors/harness.py +439 -0
  139. node/planes/services/compute/executors/mcp.py +454 -0
  140. node/planes/services/compute/executors/media.py +898 -0
  141. node/planes/services/compute/executors/nondet.py +283 -0
  142. node/planes/services/compute/executors/providers.py +499 -0
  143. node/planes/services/compute/executors/registry.py +140 -0
  144. node/planes/services/compute/executors/wasm.py +244 -0
  145. node/planes/services/compute/executors/zk.py +604 -0
  146. node/planes/services/compute/jobs.py +227 -0
  147. node/planes/services/compute/load.py +180 -0
  148. node/planes/services/compute/market.py +433 -0
  149. node/planes/services/compute/sandbox.py +282 -0
  150. node/planes/services/compute/types.py +84 -0
  151. node/planes/services/compute_wire/__init__.py +58 -0
  152. node/planes/services/compute_wire/compartment.py +256 -0
  153. node/planes/services/compute_wire/messages.py +320 -0
  154. node/planes/services/compute_wire/sealing.py +147 -0
  155. node/planes/services/compute_wire/session.py +572 -0
  156. node/planes/services/compute_wire/types.py +49 -0
  157. node/planes/services/compute_wire/verify.py +331 -0
  158. node/planes/services/dvm/__init__.py +24 -0
  159. node/planes/services/dvm/gateway.py +526 -0
  160. node/planes/services/dvm/protocol.py +35 -0
  161. node/planes/services/dvm/translate.py +15 -0
  162. node/planes/services/dvm/types.py +25 -0
  163. node/planes/services/gateway/__init__.py +40 -0
  164. node/planes/services/gateway/settle.py +279 -0
  165. node/planes/services/gateway/translate.py +644 -0
  166. node/planes/services/gossip/__init__.py +22 -0
  167. node/planes/services/gossip/core.py +929 -0
  168. node/planes/services/gossip/envelope.py +154 -0
  169. node/planes/services/gossip/mesh.py +127 -0
  170. node/planes/services/gossip/types.py +50 -0
  171. node/planes/services/nsite/__init__.py +7 -0
  172. node/planes/services/nsite/client.py +62 -0
  173. node/planes/services/nsite/compartment.py +752 -0
  174. node/planes/services/nsite/types.py +44 -0
  175. node/planes/services/paidfetch/__init__.py +7 -0
  176. node/planes/services/paidfetch/compartment.py +862 -0
  177. node/planes/services/paidfetch/types.py +99 -0
  178. node/planes/services/settle/__init__.py +27 -0
  179. node/planes/services/settle/compartment.py +384 -0
  180. node/planes/services/settle/engine.py +456 -0
  181. node/planes/services/settle/gateways.py +541 -0
  182. node/planes/services/settle/payment.py +512 -0
  183. node/planes/services/settle/store.py +74 -0
  184. node/planes/services/settle/types.py +58 -0
  185. node/planes/services/sidecars/__init__.py +8 -0
  186. node/planes/services/sidecars/compartment.py +461 -0
  187. node/planes/services/sidecars/manifest.py +304 -0
  188. node/planes/services/sidecars/manifests/10-grasp.json +37 -0
  189. node/planes/services/sidecars/manifests/20-ncontainer.json +31 -0
  190. node/planes/services/sidecars/manifests/30-magpie.json +28 -0
  191. node/planes/services/sidecars/types.py +43 -0
  192. node/planes/services/video/__init__.py +106 -0
  193. node/planes/services/video/barter.py +407 -0
  194. node/planes/services/video/cli.py +158 -0
  195. node/planes/services/video/compartment.py +932 -0
  196. node/planes/services/video/ingest.py +524 -0
  197. node/planes/services/video/ledger.py +705 -0
  198. node/planes/services/video/packager.py +847 -0
  199. node/planes/services/video/player.py +456 -0
  200. node/planes/services/video/timed_segments.py +124 -0
  201. node/planes/services/video/transit.py +478 -0
  202. node/planes/store/__init__.py +24 -0
  203. node/planes/store/blob_replication/__init__.py +7 -0
  204. node/planes/store/blob_replication/compartment.py +496 -0
  205. node/planes/store/blob_replication/types.py +35 -0
  206. node/planes/store/blob_wire/__init__.py +48 -0
  207. node/planes/store/blob_wire/client.py +57 -0
  208. node/planes/store/blob_wire/core.py +359 -0
  209. node/planes/store/blob_wire/intake.py +74 -0
  210. node/planes/store/blob_wire/messages.py +85 -0
  211. node/planes/store/projected/__init__.py +7 -0
  212. node/planes/store/projected/compartment.py +258 -0
  213. node/planes/store/projected/types.py +29 -0
  214. node/planes/store/retention/__init__.py +7 -0
  215. node/planes/store/retention/compartment.py +214 -0
  216. node/planes/store/retention/types.py +12 -0
  217. node/planes/store/sync/__init__.py +35 -0
  218. node/planes/store/sync/core.py +311 -0
  219. node/planes/store/sync/envelope.py +106 -0
  220. node/planes/store/sync/handover.py +206 -0
  221. node/planes/store/sync/initiator.py +128 -0
  222. node/planes/store/sync/responder.py +114 -0
  223. node/planes/store/sync/rules.py +179 -0
  224. node/planes/store/sync/shape.py +80 -0
  225. node/planes/store/sync/types.py +51 -0
  226. node/planes/transport/__init__.py +23 -0
  227. node/planes/transport/bridge/__init__.py +13 -0
  228. node/planes/transport/bridge/compartment.py +785 -0
  229. node/planes/transport/bridge/ratelimit.py +44 -0
  230. node/planes/transport/bridge/types.py +43 -0
  231. node/planes/transport/lan/__init__.py +9 -0
  232. node/planes/transport/lan/compartment.py +446 -0
  233. node/planes/transport/lan/types.py +25 -0
  234. node/planes/transport/link/__init__.py +7 -0
  235. node/planes/transport/link/compartment.py +257 -0
  236. node/planes/transport/link/types.py +27 -0
  237. node/planes/transport/mediation/__init__.py +45 -0
  238. node/planes/transport/mediation/compartment.py +238 -0
  239. node/planes/transport/mediation/leaf.py +107 -0
  240. node/planes/transport/mediation/messages.py +302 -0
  241. node/planes/transport/mediation/service.py +233 -0
  242. node/planes/transport/mediation/store.py +37 -0
  243. node/planes/transport/mediation/types.py +20 -0
  244. node/planes/transport/p2p/__init__.py +7 -0
  245. node/planes/transport/p2p/compartment.py +349 -0
  246. node/planes/transport/p2p/types.py +22 -0
  247. node/protocols/__init__.py +277 -0
  248. node/protocols/activitypub/__init__.py +56 -0
  249. node/protocols/activitypub/actor.py +230 -0
  250. node/protocols/activitypub/federation.py +490 -0
  251. node/protocols/activitypub/media.py +145 -0
  252. node/protocols/activitypub/models.py +286 -0
  253. node/protocols/activitypub/package.py +271 -0
  254. node/protocols/activitypub/store.py +564 -0
  255. node/protocols/arcade/__init__.py +26 -0
  256. node/protocols/atproto/__init__.py +13 -0
  257. node/protocols/atproto/firehose.py +126 -0
  258. node/protocols/atproto/metering.py +165 -0
  259. node/protocols/atproto/package.py +155 -0
  260. node/protocols/atproto/repo.py +250 -0
  261. node/protocols/atproto/xrpc.py +285 -0
  262. node/protocols/base.py +109 -0
  263. node/protocols/circles/__init__.py +29 -0
  264. node/protocols/dm/__init__.py +50 -0
  265. node/protocols/grasp/__init__.py +22 -0
  266. node/protocols/isotime.py +15 -0
  267. node/protocols/nostr/__init__.py +51 -0
  268. node/protocols/nostr/blobstore.py +383 -0
  269. node/protocols/nostr/blossom.py +627 -0
  270. node/protocols/nostr/client.py +377 -0
  271. node/protocols/nostr/descriptor.py +211 -0
  272. node/protocols/nostr/eventstore.py +304 -0
  273. node/protocols/nostr/package.py +182 -0
  274. node/protocols/nostr/peers.py +109 -0
  275. node/protocols/nostr/relayserver.py +656 -0
  276. node/protocols/p2panda/__init__.py +22 -0
  277. node/protocols/p2panda/identity.py +19 -0
  278. node/protocols/p2panda/package.py +236 -0
  279. node/protocols/p2panda/rung.py +152 -0
  280. node/protocols/p2panda/store.py +273 -0
  281. node/protocols/rhio/__init__.py +33 -0
  282. node/protocols/rhio/package.py +275 -0
  283. node/protocols/rhio/rung.py +136 -0
  284. node/protocols/rhio/store.py +1423 -0
  285. node/protocols/ssb/muxrpc.py +256 -0
  286. node/protocols/ssb/package.py +267 -0
  287. node/protocols/textile/__init__.py +111 -0
  288. node/protocols/textile/transducers.py +259 -0
  289. node/release.py +119 -0
  290. node/storage/__init__.py +88 -0
  291. node/storage/accounts.py +305 -0
  292. node/storage/adapter.py +161 -0
  293. node/storage/base.py +385 -0
  294. node/storage/dialect.py +209 -0
  295. node/storage/grants.py +317 -0
  296. node/storage/locator.py +150 -0
  297. node/storage/postgres.py +294 -0
  298. node/storage/projected.py +953 -0
  299. node/storage/sharding.py +448 -0
  300. node/storage/standing.py +568 -0
  301. node/testnet/__init__.py +9 -0
  302. node/testnet/cluster.py +731 -0
  303. node/testnet/fips_daemon.py +230 -0
  304. node/transport/__init__.py +94 -0
  305. node/transport/const.py +110 -0
  306. node/transport/didcomm_inbound.py +251 -0
  307. node/transport/dnstxt.py +169 -0
  308. node/transport/fips_rung/__init__.py +67 -0
  309. node/transport/fips_rung/core.py +666 -0
  310. node/transport/fips_rung/listener.py +96 -0
  311. node/transport/fips_rung/types.py +12 -0
  312. node/transport/host.py +334 -0
  313. node/transport/iroh_rung/__init__.py +29 -0
  314. node/transport/iroh_rung/core.py +477 -0
  315. node/transport/iroh_rung/quic.py +942 -0
  316. node/transport/iroh_rung/types.py +22 -0
  317. node/transport/ladder.py +145 -0
  318. node/transport/lineproto.py +49 -0
  319. node/transport/mdns.py +303 -0
  320. node/transport/netfetch.py +283 -0
  321. node/transport/relay_rung/__init__.py +23 -0
  322. node/transport/relay_rung/cooldown.py +87 -0
  323. node/transport/relay_rung/core.py +138 -0
  324. node/transport/relay_rung/inbound.py +161 -0
  325. node/transport/relay_rung/ledger.py +104 -0
  326. node/transport/relay_rung/listen.py +121 -0
  327. node/transport/relay_rung/outbound.py +181 -0
  328. node/transport/relay_rung/shape.py +71 -0
  329. node/transport/relay_rung/types.py +58 -0
  330. node/transport/relay_rung/wire.py +63 -0
  331. node/transport/router.py +761 -0
  332. node/transport/rung.py +276 -0
  333. node/transport/ssb_rung.py +166 -0
  334. node/transport/ws.py +502 -0
  335. sidecar/fleetmesh_announce.py +338 -0
  336. sidecar/fleetmesh_p2p.py +373 -0
  337. sidecar/fleetmesh_rhio.py +352 -0
  338. sidecar/fleetmesh_ssb.py +603 -0
@@ -0,0 +1,761 @@
1
+ """The router: TransportRouter, the ladder from direct rungs down to the gift-wrap rung.
2
+
3
+ Separate from the rungs because it is the only module that knows about all of them,
4
+ and the direct rungs import from const.py without ever importing the router back.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import collections
9
+ import logging
10
+ import os
11
+ import queue
12
+ import threading
13
+ import time
14
+ from typing import (
15
+ Any,
16
+ Callable,
17
+ Counter,
18
+ Deque,
19
+ Mapping,
20
+ MutableMapping,
21
+ Optional,
22
+ Sequence,
23
+ TypedDict,
24
+ )
25
+
26
+ from fleetmesh_core.identity.did import did_nostr
27
+ from fleetmesh_core.identity import didcomm
28
+ from . import didcomm_inbound, fips_rung
29
+ from fleetmesh_core.identity import iroh_ticket
30
+ from . import ladder as transport_ladder
31
+ from fleetmesh_core.storage import replication
32
+ from . import const
33
+ from .const import Inbound
34
+ from .fips_rung import FipsRungMixin
35
+ from . import rung as rung_mod
36
+ from ..interfaces import DIDCommMessage, RelayHealthRow
37
+ from .relay_rung import RelayRung as NostrTransport
38
+ from .relay_rung.cooldown import RelayCooldown
39
+ from .relay_rung.types import AckVerdict, AwaitingEntry
40
+
41
+
42
+ class RefusalRow(TypedDict):
43
+ """A direct rung's refusal of one peer, for the ladder's telemetry."""
44
+ peer: str
45
+ transport: str
46
+ error: str
47
+ at: int
48
+
49
+
50
+ class LadderHealthRow(TypedDict):
51
+ """Circuit breaker and availability status of one transport rung."""
52
+ available: bool
53
+ circuit_breakers: dict[str, str]
54
+ failures: dict[str, int]
55
+
56
+
57
+ log = logging.getLogger(__name__)
58
+
59
+
60
+ class NullTransport:
61
+ """A null transport rung used when Nostr is disabled in configuration."""
62
+ name: str = 'none'
63
+ health: Mapping[str, Any] = {}
64
+ awaiting: Mapping[str, Any] = {}
65
+ acked: Mapping[str, int] = {}
66
+ acks_in: int = 0
67
+ acks_out: int = 0
68
+ unacked: Sequence[Any] = ()
69
+ wraps_in: Mapping[str, int] = {}
70
+ wraps_seen_again: Mapping[str, int] = {}
71
+ cooldown: Optional[Any] = None
72
+
73
+ def listening(self) -> tuple[str, ...]:
74
+ return ()
75
+
76
+ def wait_listening(self, timeout: float = 0) -> bool:
77
+ return True
78
+
79
+ def start(self, since: int = 0) -> None:
80
+ pass
81
+
82
+ def stop(self) -> None:
83
+ pass
84
+
85
+ def unfinished(self) -> list[Any]:
86
+ return []
87
+
88
+ def sweep(self, now: int) -> list[Any]:
89
+ return []
90
+
91
+ def take_unacked(self) -> list[Any]:
92
+ return []
93
+
94
+ def listen(self, thid: str) -> None:
95
+ pass
96
+
97
+ def unlisten(self, thid: str) -> None:
98
+ pass
99
+
100
+ def available(self) -> bool:
101
+ return False
102
+
103
+ def send(self, *args: Any, **kwargs: Any) -> Mapping[str, Any]:
104
+ return {}
105
+
106
+
107
+ class TransportRouter(FipsRungMixin):
108
+ """The multi-transport router and ladder negotiator.
109
+
110
+ Implements TransportProtocol across all active transports:
111
+ - fips-native: datagram flows through the fips sidecar (mesh-encrypted)
112
+ - iroh: EXPERIMENTAL / unauthenticated. Direct TCP stream or the
113
+ iroh_live UDP framing; the wire itself is neither encrypted nor
114
+ authenticated, only the DIDComm JWE inside is. Opt-in: built only
115
+ when config names an iroh endpoint (see __init__).
116
+ - libp2p: stream through the p2p sidecar, when the node builds one
117
+ - nostr: NIP-59 gift-wrapped DIDComm over relays (Kinds 21059 / 1059)
118
+
119
+ The ladder in send() ranks fips-native, iroh, libp2p, nostr by
120
+ preference; the rank says which is tried first, not which is safer.
121
+ Every rung is held to the same envelope rule (didcomm_inbound).
122
+
123
+ Performs dynamic ladder negotiation (fleetmesh_core.storage.replication.negotiate) based on
124
+ peer descriptor endpoints, automatically falling back down the ladder to
125
+ Nostr when direct streams fail or are unavailable.
126
+ """
127
+
128
+ scheme: str = 'router'
129
+
130
+ def __init__(
131
+ self,
132
+ keystore: Any,
133
+ peers: Any,
134
+ relay_urls: Sequence[str] = (),
135
+ endpoints: Optional[Sequence[Any]] = None,
136
+ clock: Optional[Callable[[], int]] = None,
137
+ timeout: float = 15.0,
138
+ seen_store: Optional[tuple[Callable[[str], bool], Callable[[str, int], None]]] = None,
139
+ ack_store: Optional[Any] = None,
140
+ iroh_host: str = '127.0.0.1',
141
+ iroh_port: int = 0,
142
+ sidecar_dir: Optional[str] = None,
143
+ enable_iroh: Optional[bool] = None,
144
+ iroh_mode: str = 'stream',
145
+ fips_sock_path: Optional[str] = None,
146
+ enable_fips: bool = True,
147
+ fips_port: int = 11801,
148
+ fips_client: Optional[Any] = None,
149
+ enable_nostr: bool = True,
150
+ ) -> None:
151
+ self.ks: Any = keystore
152
+ self.peers: Any = peers
153
+ self.relays: list[str] = list(relay_urls)
154
+ self.clock: Callable[[], int] = clock or (lambda: int(time.time()))
155
+ self.timeout: float = timeout
156
+ self.endpoints_cfg: list[Any] = list(endpoints or ())
157
+ self.enable_nostr: bool = enable_nostr
158
+
159
+ if enable_nostr:
160
+ self.nostr: Any = NostrTransport(
161
+ keystore=keystore,
162
+ peers=peers,
163
+ relay_urls=relay_urls,
164
+ clock=clock,
165
+ timeout=timeout,
166
+ seen_store=seen_store,
167
+ ack_store=ack_store,
168
+ )
169
+ else:
170
+ self.nostr = NullTransport()
171
+
172
+ # Check for configured iroh endpoint
173
+ iroh_ep = None
174
+ for ep in self.endpoints_cfg:
175
+ if isinstance(ep, dict) and ep.get('transport') == 'iroh':
176
+ iroh_ep = ep
177
+ break
178
+ elif isinstance(ep, (tuple, list)) and len(ep) >= 2 and ep[0] == 'iroh':
179
+ iroh_ep = {'transport': 'iroh', 'address': ep[1], 'priority': ep[2] if len(ep) > 2 else 10}
180
+ break
181
+
182
+ self.iroh: Optional[Any] = None
183
+ # The iroh rung is EXPERIMENTAL and opt-in. Its framing (TCP stream
184
+ # or the iroh_live UDP engine) is neither encrypted nor authenticated
185
+ # at the transport layer: only the DIDComm JWE inside protects the
186
+ # message. So the rung is built only when the operator names an iroh
187
+ # endpoint in config (or a caller passes enable_iroh=True); a node
188
+ # with no such endpoint binds no iroh listener and never dials iroh.
189
+ if enable_iroh is None:
190
+ enable_iroh = iroh_ep is not None
191
+ if enable_iroh:
192
+ from .iroh_rung import IrohTransport
193
+ specified_ticket = iroh_ep.get('address') or iroh_ep.get('ticket') if iroh_ep else None
194
+ h = iroh_host
195
+ p = iroh_port
196
+ if specified_ticket:
197
+ try:
198
+ t_parsed = iroh_ticket.IrohTicket.parse(specified_ticket)
199
+ h, p = t_parsed.host, t_parsed.port
200
+ except ValueError as e:
201
+ # An empty or non-numeric-port ticket: the configured
202
+ # host/port stand, and the ticket string is still handed
203
+ # to the transport as the advertised address.
204
+ log.warning('iroh: configured ticket unparseable, using host/port: %s', e)
205
+ eff_mode = (iroh_ep.get('mode') if iroh_ep else None) or iroh_mode
206
+ self.iroh = IrohTransport(
207
+ keystore=keystore,
208
+ peers=peers,
209
+ host=h,
210
+ port=p,
211
+ ticket=specified_ticket,
212
+ sidecar_dir=sidecar_dir,
213
+ clock=clock,
214
+ timeout=min(timeout, 5.0),
215
+ mode=eff_mode,
216
+ )
217
+
218
+ # Check for configured fips endpoint
219
+ fips_ep = None
220
+ for ep in self.endpoints_cfg:
221
+ if isinstance(ep, dict) and ep.get('transport') in ('fips', 'fips-native'):
222
+ fips_ep = ep
223
+ break
224
+ elif isinstance(ep, (tuple, list)) and len(ep) >= 2 and ep[0] in ('fips', 'fips-native'):
225
+ fips_ep = {'transport': ep[0], 'address': ep[1], 'priority': ep[2] if len(ep) > 2 else 5}
226
+ break
227
+
228
+ self.fips: Optional[Any] = fips_client
229
+ self.fips_port: int = fips_port
230
+ self.fips_sock_path: Optional[str] = fips_sock_path or (fips_ep.get('sock_path') if fips_ep else None)
231
+ self._fips_listener_sock: Optional[Any] = None
232
+ self._fips_thread: Optional[threading.Thread] = None
233
+
234
+ if enable_fips and self.fips is None:
235
+ sock_p = self.fips_sock_path or fips_rung.find_api_socket()
236
+ if sock_p and os.path.exists(sock_p):
237
+ try:
238
+ self.fips = fips_rung.FipsNativeTransport(sock_path=sock_p, node_pk=self.ks.node_pk)
239
+ except Exception:
240
+ self.fips = None
241
+
242
+ # The libp2p rung is a compartment (node/p2p_wire.P2PTransport) that
243
+ # registers itself here when the node builds it; a router without one
244
+ # simply has no rung to try.
245
+ self.p2p: Optional[Any] = None
246
+
247
+ self.transports: MutableMapping[str, Any] = {'nostr': self.nostr} if self.enable_nostr else {}
248
+ if self.iroh is not None:
249
+ self.transports['iroh'] = self.iroh
250
+ if self.fips is not None:
251
+ self.transports['fips-native'] = self.fips
252
+
253
+ # Unified inbox and thread routing: directly shared with underlying transports
254
+ self.inbox: queue.Queue[Inbound] = queue.Queue(maxsize=const.INBOX_MAX)
255
+ self._waiting: dict[str, queue.Queue[Inbound]] = {}
256
+ self._lock: threading.Lock = threading.Lock()
257
+
258
+ # Connect both transports directly to this router's inbox and waiter map
259
+ self.nostr.inbox = self.inbox
260
+ self.nostr._waiting = self._waiting
261
+ if self.iroh is not None:
262
+ self.iroh.inbox = self.inbox
263
+ self.iroh._waiting = self._waiting
264
+
265
+ # Telemetry metrics
266
+ self.success_counts: Counter[str] = collections.Counter()
267
+ self.refusals_log: Deque[RefusalRow] = collections.deque(maxlen=const.DROPPED_KEPT)
268
+ # What the ladder itself refused: a direct-rung payload that would
269
+ # not open, a rung that failed and was cascaded past. Each rung keeps
270
+ # its own; faults() reads them all.
271
+ self.faults: transport_ladder.FaultLog = transport_ladder.FaultLog('router', clock=self.clock,
272
+ keep=const.DROPPED_KEPT)
273
+ # Replay and freshness for the direct rungs that deliver through
274
+ # deliver_direct (fips-native, libp2p). The iroh rung holds its own.
275
+ self.guard: didcomm_inbound.InboundGuard = didcomm_inbound.InboundGuard()
276
+ self.circuit_breakers: dict[str, transport_ladder.CircuitBreaker] = {
277
+ 'fips-native': transport_ladder.CircuitBreaker(failure_threshold=3, recovery_timeout=30.0),
278
+ 'iroh': transport_ladder.CircuitBreaker(failure_threshold=3, recovery_timeout=30.0),
279
+ 'libp2p': transport_ladder.CircuitBreaker(failure_threshold=3, recovery_timeout=30.0),
280
+ 'nostr': transport_ladder.CircuitBreaker(failure_threshold=5, recovery_timeout=15.0),
281
+ }
282
+ # The ladder, top to floor. Each rung knows how to reach a peer and
283
+ # how to deliver once; send() walks them and keeps the books. The
284
+ # relay rung is the floor and is always last.
285
+ seal: rung_mod.Seal = lambda m, pk, anon: self._seal_direct(m, pk, self.peers.agreement(pk), anon)
286
+ self.rungs: list[rung_mod.RungProtocol] = [
287
+ rung_mod.FipsRung(self, seal),
288
+ rung_mod.IrohRung(self),
289
+ rung_mod.Libp2pRung(self, seal),
290
+ ]
291
+ if self.enable_nostr:
292
+ self.rungs.append(rung_mod.RelayRungAdapter(self))
293
+ self._send_interceptor: Optional[Callable[..., dict[str, tuple[bool, str]]]] = None
294
+ self._rung_interceptor: Optional[Callable[[str, DIDCommMessage | Mapping[str, Any], str, Any, bool], Optional[dict[str, tuple[bool, str]]]]] = None
295
+
296
+ def register_rung(self, rung: rung_mod.RungProtocol) -> None:
297
+ """Register a dynamic transport rung into the ladder."""
298
+ if not hasattr(rung, 'name') or not hasattr(rung, 'deliver'):
299
+ raise TypeError(f"Object {rung!r} does not conform to RungProtocol")
300
+ # Replace existing rung if already present, else append
301
+ existing_idx = next((i for i, r in enumerate(self.rungs) if r.name == rung.name), None)
302
+ if existing_idx is not None:
303
+ self.rungs[existing_idx] = rung
304
+ else:
305
+ self.rungs.append(rung)
306
+ # Keep ladder sorted by preference rank (lower rank is tried first)
307
+ self.rungs.sort(key=lambda r: getattr(r, 'rank', 50))
308
+ if rung.name not in self.circuit_breakers:
309
+ self.circuit_breakers[rung.name] = transport_ladder.CircuitBreaker(
310
+ failure_threshold=3, recovery_timeout=30.0
311
+ )
312
+
313
+ def add_rung(self, rung: rung_mod.RungProtocol) -> None:
314
+ """Alias for register_rung to satisfy ProtocolManager."""
315
+ self.register_rung(rung)
316
+
317
+ def unregister_rung(self, name: str) -> Optional[rung_mod.RungProtocol]:
318
+ """Remove a transport rung by name from the ladder."""
319
+ removed: Optional[rung_mod.RungProtocol] = None
320
+ new_rungs = []
321
+ for r in self.rungs:
322
+ if r.name == name:
323
+ removed = r
324
+ else:
325
+ new_rungs.append(r)
326
+ self.rungs = new_rungs
327
+ return removed
328
+
329
+ def get_rung(self, name: str) -> Optional[rung_mod.RungProtocol]:
330
+ """Retrieve a registered transport rung by name."""
331
+ return next((r for r in self.rungs if r.name == name), None)
332
+
333
+ # ---- What the ladder shows the compartments: the relay rung's books
334
+ # by name (it is the store-and-forward floor), every rung's health and
335
+ # faults through the ladder. Nothing resolves by accident.
336
+
337
+ @property
338
+ def health(self) -> Mapping[str, RelayHealthRow]:
339
+ # Expose relay health map expected by Link subsystem
340
+ h: Mapping[str, RelayHealthRow] = dict(self.nostr.health)
341
+ return h
342
+
343
+ def rung_health(self) -> dict[str, Mapping[str, int | str | bool | float]]:
344
+ """Every rung's own report, keyed by rung name; a rung whose daemon
345
+ is not built is absent. The one view of the ladder's state."""
346
+ out: dict[str, Mapping[str, int | str | bool | float]] = {}
347
+ for rung in self.rungs:
348
+ h = rung.health()
349
+ if h is not None:
350
+ out[rung.name] = h
351
+ return out
352
+
353
+ @property
354
+ def ladder_health(self) -> dict[str, LadderHealthRow]:
355
+ """Circuit breaker and availability status across transport rungs."""
356
+ return {
357
+ name: {
358
+ 'available': name in self.transports or (name == 'libp2p' and self._p2p_ready()),
359
+ 'circuit_breakers': dict(cb._states),
360
+ 'failures': dict(cb._failures),
361
+ }
362
+ for name, cb in self.circuit_breakers.items()
363
+ }
364
+
365
+ def _p2p_ready(self) -> bool:
366
+ return self.p2p is not None and bool(getattr(self.p2p, 'connected', False))
367
+
368
+ def fault_logs(self) -> list[transport_ladder.FaultLog]:
369
+ """The ladder's own fault log and every rung's that keeps one."""
370
+ logs = [self.faults]
371
+ for rung in self.rungs:
372
+ log = rung.fault_log()
373
+ if log is not None:
374
+ logs.append(log)
375
+ return logs
376
+
377
+ def recent_faults(self, since: Optional[int] = None) -> list[transport_ladder.Fault]:
378
+ """What every rung refused or lost, newest last, with reasons."""
379
+ return transport_ladder.merge_faults(self.fault_logs(), since)
380
+
381
+ @property
382
+ def drop_counts(self) -> Counter[str]:
383
+ c: Counter[str] = collections.Counter()
384
+ for lg in self.fault_logs():
385
+ c.update(lg.counts)
386
+ return c
387
+
388
+ @property
389
+ def dropped(self) -> Deque[tuple[str, Optional[str]]]:
390
+ return collections.deque(
391
+ ((f['reason'], f['ident']) for f in self.recent_faults()), maxlen=const.DROPPED_KEPT)
392
+
393
+ @property
394
+ def cooldown(self) -> Optional[RelayCooldown]:
395
+ """The relay rung's cooldown ledger: which relays are being rested
396
+ after a rate-limit and until when. None for a relay-rung double
397
+ that keeps none."""
398
+ cd = getattr(self.nostr, 'cooldown', None)
399
+ return cd if isinstance(cd, RelayCooldown) else None
400
+
401
+ @property
402
+ def awaiting(self) -> dict[str, AwaitingEntry]:
403
+ return self.nostr.awaiting
404
+
405
+ @property
406
+ def acked(self) -> Counter[str]:
407
+ return self.nostr.acked
408
+
409
+ @property
410
+ def acks_in(self) -> int:
411
+ return self.nostr.acks_in
412
+
413
+ @property
414
+ def acks_out(self) -> int:
415
+ return self.nostr.acks_out
416
+
417
+ @property
418
+ def unacked(self) -> Deque[AckVerdict]:
419
+ return self.nostr.unacked
420
+
421
+ @property
422
+ def wraps_in(self) -> Counter[Optional[int]]:
423
+ return self.nostr.wraps_in
424
+
425
+ @property
426
+ def wraps_seen_again(self) -> Counter[Optional[int]]:
427
+ return self.nostr.wraps_seen_again
428
+
429
+ def unfinished(self) -> int:
430
+ return self.nostr.unfinished()
431
+
432
+ def sweep(self, now: Optional[int] = None) -> list[AckVerdict]:
433
+ return self.nostr.sweep(now)
434
+
435
+ def take_unacked(self) -> list[AckVerdict]:
436
+ return self.nostr.take_unacked()
437
+
438
+ def listening(self) -> int:
439
+ base = self.nostr.listening()
440
+ if self.iroh is not None and getattr(self.iroh, '_server_sock', None) is not None:
441
+ base += 1
442
+ if self._fips_listener_sock is not None:
443
+ base += 1
444
+ return base
445
+
446
+ def wait_listening(self, timeout: float = 8.0) -> bool:
447
+ return self.nostr.wait_listening(timeout)
448
+
449
+ # ---- Lifecycle ---------------------------------------------------------
450
+
451
+ def start(self, since: Optional[int] = None) -> TransportRouter:
452
+ self.nostr.start(since=since)
453
+ if self.iroh is not None:
454
+ self.iroh.start()
455
+ if self.fips is not None and self.fips.is_available():
456
+ try:
457
+ self.fips.connect()
458
+ self._fips_listener_sock = self.fips.listen_port(self.fips_port)
459
+ self._fips_thread = threading.Thread(target=self._listen_fips, daemon=True)
460
+ self._fips_thread.start()
461
+ except (OSError, fips_rung.FipsTransportError) as e:
462
+ # The daemon answered is_available() but would not give us a
463
+ # listener: the rung stays built but inert, and the ladder
464
+ # falls through to the next rung. Said once, loudly.
465
+ log.warning('fips-native: listener not started, rung inert: %s', e)
466
+ return self
467
+
468
+ def deliver_direct(
469
+ self,
470
+ scheme: str,
471
+ payload: Any,
472
+ wrap_id: str,
473
+ origin: Optional[str] = None,
474
+ ) -> Optional[str]:
475
+ """One payload off a direct rung (fips-native, libp2p): a JWE, or nothing.
476
+
477
+ Returns None when the message was queued or was an empty ping, else
478
+ the reason it was dropped, which is also recorded.
479
+
480
+ The daemon behind a direct rung proves for itself who the other end
481
+ of a flow or stream is, but this node never sees that proof - it
482
+ reads a payload off a local socket, and on a cluster daemon, a mock,
483
+ or a shared host anyone with the socket can write one. So the payload
484
+ is treated exactly as a stream off the internet is: it must be a JWE
485
+ this node can open, and the sender is whoever the agreement proves,
486
+ never the `from` field. A plaintext frame on this path used to arrive
487
+ as an authenticated message from anyone it cared to name, and
488
+ resolved whatever request() was waiting for; now it is a drop with a
489
+ reason, and nothing else.
490
+
491
+ `origin`, when a sidecar reports one, is the raw Ed25519 transport
492
+ key hex the remote stream authenticated with. It is a hint, not the
493
+ sender: the sender's descriptor publishes its transport key, and a
494
+ message whose proven sender advertises a different one did not come
495
+ from that sender's node, so it is dropped rather than delivered as
496
+ something it is not.
497
+
498
+ Nothing in here may raise: the reader loop ends on any exception,
499
+ which would let one malformed frame take the flow down.
500
+ """
501
+ if not payload:
502
+ return None
503
+ try:
504
+ inbound, why = didcomm_inbound.open_inbound(payload, self.ks, self.peers, wrap_id,
505
+ guard=self.guard, now=self.clock())
506
+ except Exception as e: # belt and braces: open_inbound is meant not to raise
507
+ inbound, why = None, 'envelope: %s: %s' % (type(e).__name__, e)
508
+ if inbound is None:
509
+ reason = '%s: %s' % (scheme, why or 'refused')
510
+ self.faults.record(reason, wrap_id)
511
+ return reason
512
+ if not inbound.authenticated and not const.anonymous_allowed(inbound.message.get('type')):
513
+ # Anoncrypt off a direct rung: no skid and no seal, so the sender
514
+ # is a claim. Only a type whose handler treats the sender as a
515
+ # stranger (const.ANONYMOUS_OK) is delivered; everything else is
516
+ # refused here, before any handler can read inbound.sender.
517
+ reason = '%s: unauthenticated: anoncrypt %s is not a first-contact type' % (
518
+ scheme, str(inbound.message.get('type'))[:80])
519
+ self.refusals_log.append(RefusalRow(peer=inbound.sender, transport=scheme, error='unauthenticated %s' % str(inbound.message.get('type'))[:80], at=self.clock()))
520
+ self.faults.record(reason, wrap_id)
521
+ return reason
522
+ if origin is not None and inbound.authenticated:
523
+ try:
524
+ from ..planes.transport.p2p import compartment as p2p_wire
525
+ published = p2p_wire.transport_key_of(self.peers.descriptor(inbound.sender))
526
+ except Exception:
527
+ published = None
528
+ if published is not None and published.hex() != origin:
529
+ reason = '%s: stream from transport key %s, but sender %s publishes %s' % (
530
+ scheme, origin[:8], inbound.sender[:8], published.hex()[:8])
531
+ self.faults.record(reason, wrap_id)
532
+ return reason
533
+ if inbound.message.get('type') == const.EMPTY_TYPE:
534
+ return None
535
+ thid = inbound.thid
536
+ with self._lock:
537
+ waiter = self._waiting.get(thid) if thid else None
538
+ try:
539
+ (waiter if waiter is not None else self.inbox).put_nowait(inbound)
540
+ except queue.Full:
541
+ # Queue saturated; drop the frame rather than block the reader.
542
+ reason = '%s: inbox full' % scheme
543
+ self.faults.record(reason, wrap_id)
544
+ return reason
545
+ return None
546
+
547
+ def stop(self) -> None:
548
+ if self._fips_listener_sock is not None:
549
+ try:
550
+ self._fips_listener_sock.close()
551
+ except OSError:
552
+ pass # best-effort close on shutdown
553
+ self._fips_listener_sock = None
554
+ if self.fips is not None:
555
+ try:
556
+ self.fips.close()
557
+ except (OSError, fips_rung.FipsTransportError):
558
+ pass # best-effort close on shutdown; the daemon may be gone already
559
+ if self.iroh is not None:
560
+ self.iroh.stop()
561
+ self.nostr.stop()
562
+
563
+ # ---- Dispatch & Ladder Negotiation ------------------------------------
564
+
565
+ def set_send_interceptor(
566
+ self,
567
+ interceptor: Optional[Callable[..., dict[str, tuple[bool, str]]]],
568
+ ) -> None:
569
+ """Register a send interceptor (e.g. for cluster partition and loss simulation)."""
570
+ self._send_interceptor = interceptor
571
+
572
+ def clear_send_interceptor(self) -> None:
573
+ """Remove any registered send interceptor."""
574
+ self._send_interceptor = None
575
+
576
+ def set_rung_interceptor(
577
+ self,
578
+ interceptor: Optional[Callable[[str, DIDCommMessage | Mapping[str, Any], str, Any, bool], Optional[dict[str, tuple[bool, str]]]]],
579
+ ) -> None:
580
+ """Register a per-rung delivery interceptor (e.g. for directed partition and cascade testing)."""
581
+ self._rung_interceptor = interceptor
582
+
583
+ def clear_rung_interceptor(self) -> None:
584
+ """Remove any registered rung interceptor."""
585
+ self._rung_interceptor = None
586
+
587
+ def send(
588
+ self,
589
+ message: DIDCommMessage | Mapping[str, Any],
590
+ to_pubkey: str,
591
+ anonymous: bool = False,
592
+ stored: bool = False,
593
+ ) -> dict[str, tuple[bool, str]]:
594
+ """Dispatch a message over the top negotiated transport ladder rung.
595
+
596
+ `stored=True` requires store-and-forward relays (NIP-59 Kind 1059).
597
+ Live messages negotiate `fips-native` or `iroh` if mutually supported,
598
+ falling back down the ladder to relay gift-wraps if dialing fails.
599
+ """
600
+ if self._send_interceptor is not None:
601
+ return self._send_interceptor(message, to_pubkey, anonymous=anonymous, stored=stored)
602
+ return self._send_ladder(message, to_pubkey, anonymous=anonymous, stored=stored)
603
+
604
+ def _send_ladder(
605
+ self,
606
+ message: DIDCommMessage | Mapping[str, Any],
607
+ to_pubkey: str,
608
+ anonymous: bool = False,
609
+ stored: bool = False,
610
+ ) -> dict[str, tuple[bool, str]]:
611
+ if stored:
612
+ nostr_rung = getattr(self, 'nostr', None)
613
+ if nostr_rung is not None and hasattr(nostr_rung, 'send') and getattr(nostr_rung, 'available', lambda: True)():
614
+ res = self.nostr.send(message, to_pubkey, anonymous=anonymous, stored=True)
615
+ if any(v[0] for v in res.values()):
616
+ self.success_counts['nostr'] += 1
617
+ return res
618
+ return {'router': (False, 'no store-and-forward transport available')}
619
+
620
+ desc = self.peers.descriptor(to_pubkey)
621
+ theirs, _ = rung_mod.endpoints_of(desc)
622
+ mine = {r.name: r.rank for r in self.rungs if r.available()}
623
+ if self.enable_nostr:
624
+ mine['wss'] = 40
625
+ order = replication.negotiate(mine, theirs) if theirs else [r.name for r in self.rungs]
626
+ by_name = {r.name: r for r in self.rungs}
627
+
628
+ # The walk. Each rung in negotiated order that is up, that the peer
629
+ # advertises, and whose breaker allows the peer gets one attempt;
630
+ # a failure is this rung's fault and the next rung's turn. The
631
+ # relay rung is appended if available and negotiation left it out.
632
+ nostr_rung = by_name.get('nostr')
633
+ if nostr_rung is not None and nostr_rung.available() and 'nostr' not in order:
634
+ order.append('nostr')
635
+ last: rung_mod.Verdict = {}
636
+ for name in order:
637
+ rung = by_name.get(name)
638
+ if rung is None or not rung.available():
639
+ continue
640
+ target = rung.target_of(desc, theirs)
641
+ if target is None:
642
+ continue
643
+ cb = self.circuit_breakers.get(name)
644
+ if cb is not None and not cb.allow_attempt(to_pubkey, now=self.clock()):
645
+ continue
646
+ try:
647
+ if self._rung_interceptor is not None:
648
+ inter_res = self._rung_interceptor(name, message, to_pubkey, target, anonymous)
649
+ if inter_res is not None:
650
+ res = inter_res
651
+ else:
652
+ res = rung.deliver(message, to_pubkey, target, anonymous)
653
+ else:
654
+ res = rung.deliver(message, to_pubkey, target, anonymous)
655
+ except Exception as e:
656
+ if cb is not None:
657
+ cb.record_failure(to_pubkey, now=self.clock())
658
+ self.refusals_log.append(RefusalRow(peer=to_pubkey, transport=name, error=str(e), at=self.clock()))
659
+ self.faults.record('%s failed, cascading down the ladder: %s' % (name, e), None)
660
+ continue
661
+ if any(v[0] for v in res.values()):
662
+ self.success_counts[name] += 1
663
+ if cb is not None:
664
+ cb.record_success(to_pubkey)
665
+ if name == 'nostr' or any(v[0] for v in res.values()):
666
+ return res
667
+ last = res
668
+ return last
669
+
670
+ def _seal_direct(
671
+ self,
672
+ message: DIDCommMessage | Mapping[str, Any],
673
+ to_pubkey: str,
674
+ their_agree: Any,
675
+ anonymous: bool,
676
+ ) -> Mapping[str, Any]:
677
+ """The envelope every direct rung sends: the same JWE the relays carry.
678
+
679
+ A direct path is one its daemon authenticates for itself, but the
680
+ node's proof of who wrote a message is the ECDH-1PU agreement, and
681
+ the receiving side (deliver_direct) accepts nothing else.
682
+ """
683
+ msg_dict = dict(message)
684
+ msg_dict.setdefault('from', self.ks.did)
685
+ msg_dict.setdefault('to', [did_nostr(to_pubkey)])
686
+ msg_dict.setdefault('created_time', self.clock())
687
+ kid = did_nostr(to_pubkey) + '#agree'
688
+ sender = None if anonymous else (self.ks.kid('agreement'), self.ks.agreement_priv)
689
+ jwe = didcomm.encrypt(msg_dict, [(kid, their_agree)], sender=sender)
690
+ return jwe
691
+
692
+ def request(
693
+ self,
694
+ message: DIDCommMessage | Mapping[str, Any],
695
+ to_pubkey: str,
696
+ timeout: float = 30.0,
697
+ anonymous: bool = False,
698
+ ) -> Optional[Inbound]:
699
+ """Send and wait for the reply threaded on this message's id."""
700
+ return self.converse(message, to_pubkey, message['id'], timeout, anonymous)
701
+
702
+ def converse(
703
+ self,
704
+ message: DIDCommMessage | Mapping[str, Any],
705
+ to_pubkey: str,
706
+ thid: str,
707
+ timeout: float = 30.0,
708
+ anonymous: bool = False,
709
+ ) -> Optional[Inbound]:
710
+ """Send and wait for next message on thread across any active transport."""
711
+ q = self.listen(thid)
712
+ try:
713
+ self.send(message, to_pubkey, anonymous=anonymous, stored=False)
714
+ try:
715
+ return q.get(timeout=timeout)
716
+ except queue.Empty:
717
+ return None
718
+ finally:
719
+ self.unlisten(thid)
720
+
721
+ def listen(self, thid: str) -> queue.Queue[Inbound]:
722
+ """Route messages on this thread to a dedicated queue."""
723
+ with self._lock:
724
+ q = self._waiting.get(thid)
725
+ if q is None:
726
+ q = self._waiting[thid] = queue.Queue()
727
+ self.nostr.listen(thid)
728
+ if self.iroh is not None:
729
+ self.iroh.listen(thid)
730
+ return q
731
+
732
+ def unlisten(self, thid: str) -> None:
733
+ with self._lock:
734
+ self._waiting.pop(thid, None)
735
+ self.nostr.unlisten(thid)
736
+ if self.iroh is not None:
737
+ self.iroh.unlisten(thid)
738
+
739
+ def receive(self, timeout: Optional[float] = None) -> Optional[Inbound]:
740
+ """Fetch next delivered message from unified inbox."""
741
+ try:
742
+ return self.inbox.get(timeout=timeout)
743
+ except queue.Empty:
744
+ return None
745
+
746
+ def metric_tags(self) -> list[list[str]]:
747
+ """Telemetry metric tags per spec section 6444."""
748
+ tag = [
749
+ 'metric',
750
+ 'transport_success',
751
+ f"iroh:{self.success_counts['iroh']}",
752
+ f"nostr:{self.success_counts['nostr']}",
753
+ ]
754
+ if self.success_counts['libp2p'] > 0 or self._p2p_ready():
755
+ tag.append(f"libp2p:{self.success_counts['libp2p']}")
756
+ if self.success_counts['fips-native'] > 0 or self.fips is not None:
757
+ tag.append(f"fips-native:{self.success_counts['fips-native']}")
758
+ for name, count in self.success_counts.items():
759
+ if name not in ('iroh', 'nostr', 'libp2p', 'fips-native') and count > 0:
760
+ tag.append(f"{name}:{count}")
761
+ return [tag]