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,377 @@
1
+ """A nostr relay client: publish an event and wait for its OK, or query.
2
+
3
+ The only module in the node that speaks the relay wire (REQ/EVENT/OK/EOSE/
4
+ AUTH). Two kinds of caller: the relay rung (node.transport.relay_rung), for
5
+ gift wraps to and from peers, and the control-plane publishers (descriptor,
6
+ presence, discovery, reports), for the node's own signed events. Neither is
7
+ the other's business, so they share this and nothing else.
8
+
9
+ The node talks to relays for three things: to publish its own descriptor,
10
+ grants and reports; to read other nodes' descriptors; and, as its default
11
+ transport, to send and receive gift wraps. Query collects until EOSE; a
12
+ subscription yields events as they arrive and is how the transport listens.
13
+ """
14
+ from __future__ import annotations
15
+
16
+ import json
17
+ import logging
18
+ import os
19
+ import socket
20
+ import time
21
+ from typing import Any, Callable, cast, Iterator, Mapping, Optional, Sequence
22
+
23
+ from node.interfaces import EventLike, NostrEvent, NostrFilter
24
+ from node.transport import ws
25
+
26
+ log = logging.getLogger(__name__)
27
+
28
+
29
+ class RelayError(Exception):
30
+ pass
31
+
32
+
33
+ # A subscription that receives nothing is either quiet or dead, and a socket
34
+ # that was dropped without a close frame looks quiet forever. Past PING_AFTER
35
+ # seconds of silence the client pings; past DEAD_AFTER with no frame back,
36
+ # not even a pong, the subscription is declared dead and the listener
37
+ # reconnects.
38
+ PING_AFTER: float = 60.0
39
+ DEAD_AFTER: float = 150.0
40
+
41
+
42
+ class Relay:
43
+ def __init__(
44
+ self,
45
+ url: str,
46
+ timeout: float = 15.0,
47
+ auth_signer: Optional[Callable[[str, str], Optional[EventLike]]] = None,
48
+ ping_after: float = PING_AFTER,
49
+ dead_after: float = DEAD_AFTER,
50
+ on_eose: Optional[Callable[[str], None]] = None,
51
+ ) -> None:
52
+ self.url: str = url
53
+ self.timeout: float = timeout
54
+ # Liveness thresholds are per connection so a test (or an operator
55
+ # on a flaky link) sets them on the one Relay, not on the module.
56
+ self.ping_after: float = ping_after
57
+ self.dead_after: float = dead_after
58
+ self.auth_signer: Optional[Callable[[str, str], Optional[EventLike]]] = auth_signer
59
+ self.on_eose: Optional[Callable[[str], None]] = on_eose
60
+ self.ws: Optional[ws.WebSocket] = None
61
+ self._n: int = 0
62
+ self._subs: dict[str, list[NostrFilter | Mapping[str, Any]]] = {}
63
+ self._pending: dict[str, list[NostrEvent]] = {} # events read while waiting for an EOSE
64
+ self._confirmed: set[str] = set() # subscriptions the relay answered with an EOSE
65
+
66
+ def connect(self) -> Relay:
67
+ if self.ws is None:
68
+ # Every way the handshake can fail is one answer to the caller:
69
+ # the relay is not reachable. ws.connect raises OSError for the
70
+ # socket (refused, timed out, DNS), ws.ConnectionClosed for a
71
+ # peer that closed mid-handshake or sent an endless header, and
72
+ # ConnectionError for a refused upgrade (both are OSError
73
+ # subclasses; named so a reader need not know that). Callers
74
+ # that catch RelayError must not see a bare ConnectionClosed.
75
+ try:
76
+ self.ws = ws.connect(self.url, self.timeout)
77
+ except (OSError, ws.ConnectionClosed) as e:
78
+ raise RelayError('%s: %s: %s' % (self.url, type(e).__name__, e)) from e
79
+ if hasattr(self.ws.sock, 'settimeout'):
80
+ self.ws.sock.settimeout(self.timeout)
81
+ return self
82
+
83
+ def close(self) -> None:
84
+ if self.ws is not None:
85
+ self.ws.close()
86
+ self.ws = None
87
+ self._subs.clear()
88
+
89
+ def _send(self, msg: list[Any]) -> None:
90
+ self.connect()
91
+ assert self.ws is not None
92
+ self.ws.send_text(json.dumps(msg, separators=(',', ':')))
93
+
94
+ def _recv(self) -> list[Any]:
95
+ assert self.ws is not None
96
+ raw = self.ws.recv_text()
97
+ try:
98
+ msg = json.loads(raw)
99
+ except ValueError:
100
+ raise RelayError('relay sent something that is not JSON')
101
+ if not isinstance(msg, list) or not msg:
102
+ raise RelayError('relay sent a malformed message')
103
+ return msg
104
+
105
+ def _handle_auth(self, challenge: str) -> bool:
106
+ """Sign and send a NIP-42 AUTH message if auth_signer is configured.
107
+
108
+ Returns True if an AUTH response was sent.
109
+ """
110
+ if self.auth_signer is not None:
111
+ try:
112
+ auth_ev = self.auth_signer(self.url, challenge)
113
+ except Exception:
114
+ # The signer is caller-supplied code (keystore, HSM, test
115
+ # stub); a fault in it means "no AUTH", which the caller
116
+ # sees as the relay refusing the subscription. Logged, since
117
+ # a broken signer is a bug and not a relay condition.
118
+ log.exception('relay %s: auth_signer failed', self.url)
119
+ return False
120
+ if auth_ev is not None:
121
+ self._send(['AUTH', auth_ev])
122
+ return True
123
+ return False
124
+
125
+ def _sub_id(self) -> str:
126
+ self._n += 1
127
+ return 'sub-%d-%s' % (self._n, os.urandom(3).hex())
128
+
129
+ def publish(self, event: EventLike) -> tuple[bool, str]:
130
+ """(accepted, message) for one event."""
131
+ self._send(['EVENT', event])
132
+ deadline = time.time() + self.timeout
133
+ authed = False
134
+ retried = False
135
+ last_rejected_reason: Optional[str] = None
136
+ while time.time() < deadline:
137
+ msg = self._recv()
138
+ if msg[0] == 'AUTH' and len(msg) > 1 and isinstance(msg[1], str):
139
+ if self._handle_auth(msg[1]):
140
+ authed = True
141
+ if last_rejected_reason and 'auth-required' in last_rejected_reason and not retried:
142
+ retried = True
143
+ self._send(['EVENT', event])
144
+ continue
145
+ if msg[0] == 'OK' and len(msg) > 2:
146
+ if msg[1] == event['id']:
147
+ accepted = bool(msg[2])
148
+ reason = msg[3] if len(msg) > 3 else ''
149
+ if not accepted and 'auth-required' in reason:
150
+ last_rejected_reason = reason
151
+ if authed and not retried:
152
+ retried = True
153
+ self._send(['EVENT', event])
154
+ continue
155
+ if self.auth_signer and not retried:
156
+ # Wait for AUTH challenge if one arrives
157
+ continue
158
+ return accepted, reason
159
+ # OK for another event (e.g. kind 22242 AUTH response); continue
160
+ continue
161
+ if msg[0] == 'NOTICE':
162
+ return False, 'NOTICE: %s' % (msg[1] if len(msg) > 1 else '')
163
+ if last_rejected_reason is not None:
164
+ return False, last_rejected_reason
165
+ return False, 'no OK within %.0fs' % self.timeout
166
+
167
+ def query(self, filters: Sequence[NostrFilter | Mapping[str, Any]], limit_time: Optional[float] = None) -> list[NostrEvent]:
168
+ """Every stored event matching the filters, until EOSE."""
169
+ sid = self._sub_id()
170
+ self._subs[sid] = list(filters)
171
+ self._send(['REQ', sid] + list(filters))
172
+ out: list[NostrEvent] = []
173
+ seen: set[str] = set()
174
+ deadline = time.time() + (limit_time or self.timeout)
175
+ authed = False
176
+ re_requested = False
177
+ while time.time() < deadline:
178
+ msg = self._recv()
179
+ if msg[0] == 'AUTH' and len(msg) > 1 and isinstance(msg[1], str):
180
+ if self._handle_auth(msg[1]):
181
+ authed = True
182
+ if not re_requested:
183
+ re_requested = True
184
+ self._send(['REQ', sid] + self._subs[sid])
185
+ continue
186
+ if msg[0] == 'EVENT' and len(msg) > 2 and msg[1] == sid:
187
+ ev = msg[2]
188
+ # Anything that is not an object with an id is not an event and
189
+ # every caller indexes ev['id'], so it is dropped here rather
190
+ # than crash the first reader.
191
+ if isinstance(ev, dict) and isinstance(ev.get('id'), str) and ev['id'] not in seen:
192
+ seen.add(ev['id'])
193
+ # The wire boundary: what a relay serves under EVENT is an
194
+ # event by contract, and callers verify the ones they trust.
195
+ out.append(cast(NostrEvent, ev))
196
+ elif msg[0] == 'EOSE' and len(msg) > 1 and msg[1] == sid:
197
+ if re_requested:
198
+ re_requested = False
199
+ continue
200
+ break
201
+ elif msg[0] == 'CLOSED' and len(msg) > 1 and msg[1] == sid:
202
+ reason = msg[2] if len(msg) > 2 else ''
203
+ if 'auth-required' in reason and self.auth_signer and not re_requested:
204
+ if authed:
205
+ re_requested = True
206
+ self._send(['REQ', sid] + self._subs[sid])
207
+ continue
208
+ try:
209
+ next_msg = self._recv()
210
+ if next_msg[0] == 'AUTH' and len(next_msg) > 1 and isinstance(next_msg[1], str):
211
+ if self._handle_auth(next_msg[1]):
212
+ authed = True
213
+ re_requested = True
214
+ self._send(['REQ', sid] + self._subs[sid])
215
+ continue
216
+ except (RelayError, ws.ConnectionClosed, OSError) as e:
217
+ # The relay closed us after CLOSED without a usable AUTH
218
+ # challenge; the RelayError below is the answer either way.
219
+ log.debug('relay %s: no AUTH after CLOSED: %s', self.url, e)
220
+ raise RelayError('relay closed the subscription: %s' % reason)
221
+ self._send(['CLOSE', sid])
222
+ self._subs.pop(sid, None)
223
+ return out
224
+
225
+ def subscribe(self, filters: Sequence[NostrFilter | Mapping[str, Any]], ready: bool = False) -> str:
226
+ """Open a subscription; returns its id. Read with events().
227
+
228
+ `ready` waits for the relay's EOSE before returning, which is the only
229
+ way a caller can know the subscription is registered. Without it this
230
+ sends REQ and returns, so a caller that subscribes and then causes an
231
+ event races the server: an ephemeral kind fanned out before the REQ is
232
+ processed reaches nobody, is never stored, and no timeout on events()
233
+ can recover it. Stored events that arrive before the EOSE are kept and
234
+ handed to events() rather than dropped on the floor.
235
+ """
236
+ sid = self._sub_id()
237
+ self._subs[sid] = list(filters)
238
+ self._send(['REQ', sid] + list(filters))
239
+ if ready and self._await_eose(sid):
240
+ self._confirmed.add(sid)
241
+ return sid
242
+
243
+ def confirmed(self, sid: str) -> bool:
244
+ """True if the relay answered this subscription's REQ with an EOSE.
245
+
246
+ Only ever true for a `subscribe(ready=True)`. A caller that waited and
247
+ did not get one has a socket that is up and a subscription the relay
248
+ has not owned up to, which is worth saying rather than assuming either
249
+ way.
250
+ """
251
+ return sid in self._confirmed
252
+
253
+ def _await_eose(self, sid: str) -> bool:
254
+ """Read until this subscription's EOSE, buffering what comes first.
255
+
256
+ True if the EOSE came. False if the wait ran out, the relay closed the
257
+ subscription, or the socket went - the three cases where a caller that
258
+ is about to cause an event must not believe it is being listened to.
259
+ """
260
+ deadline = time.time() + self.timeout
261
+ while time.time() < deadline:
262
+ try:
263
+ msg = self._recv()
264
+ except socket.timeout:
265
+ continue
266
+ except (OSError, ws.ConnectionClosed):
267
+ return False
268
+ if msg[0] == 'EOSE' and len(msg) > 1 and msg[1] == sid:
269
+ return True
270
+ if msg[0] == 'AUTH' and len(msg) > 1 and isinstance(msg[1], str):
271
+ if self._handle_auth(msg[1]):
272
+ self._send(['REQ', sid] + self._subs[sid])
273
+ continue
274
+ if msg[0] == 'CLOSED' and len(msg) > 1 and msg[1] == sid:
275
+ return False
276
+ if msg[0] == 'EVENT' and len(msg) > 2 and msg[1] == sid:
277
+ self._pending.setdefault(sid, []).append(msg[2])
278
+ return False
279
+
280
+ def events(self, sid: str, until: Optional[float] = None) -> Iterator[NostrEvent]:
281
+ """Yield events on a subscription until `until` (a time) or the socket closes."""
282
+ for held in self._pending.pop(sid, []):
283
+ yield held # arrived before the EOSE `ready` waited for
284
+ pinged_at: Optional[float] = None
285
+ authed = False
286
+ while until is None or time.time() < until:
287
+ try:
288
+ msg = self._recv()
289
+ except socket.timeout:
290
+ assert self.ws is not None
291
+ idle = self.ws.idle()
292
+ if idle > self.dead_after:
293
+ raise ws.ConnectionClosed('no frame in %.0fs, not even a pong: the subscription is dead' % idle)
294
+ if idle > self.ping_after and (pinged_at is None or time.time() - pinged_at > self.ping_after / 2):
295
+ try:
296
+ self.ws.ping()
297
+ except OSError:
298
+ return
299
+ pinged_at = time.time()
300
+ continue # nothing arrived; the subscription is still open
301
+ except (OSError, ws.ConnectionClosed):
302
+ return
303
+ if msg[0] == 'AUTH' and len(msg) > 1 and isinstance(msg[1], str):
304
+ if self._handle_auth(msg[1]):
305
+ authed = True
306
+ continue
307
+ if msg[0] == 'CLOSED' and len(msg) > 1 and msg[1] == sid:
308
+ reason = msg[2] if len(msg) > 2 else ''
309
+ if 'auth-required' in reason and self.auth_signer and sid in self._subs:
310
+ if authed:
311
+ self._send(['REQ', sid] + self._subs[sid])
312
+ continue
313
+ try:
314
+ next_msg = self._recv()
315
+ if next_msg[0] == 'AUTH' and len(next_msg) > 1 and isinstance(next_msg[1], str):
316
+ if self._handle_auth(next_msg[1]):
317
+ authed = True
318
+ self._send(['REQ', sid] + self._subs[sid])
319
+ continue
320
+ except (RelayError, ws.ConnectionClosed, OSError) as e:
321
+ # Same as query(): CLOSED with no AUTH to answer ends the stream.
322
+ log.debug('relay %s: no AUTH after CLOSED: %s', self.url, e)
323
+ return
324
+ if msg[0] == 'EOSE' and len(msg) > 1 and msg[1] == sid:
325
+ self._confirmed.add(sid)
326
+ if self.on_eose is not None:
327
+ self.on_eose(sid)
328
+ continue
329
+ if msg[0] == 'EVENT' and len(msg) > 2 and msg[1] == sid and isinstance(msg[2], dict):
330
+ # The guard used to sit in every consumer; it belongs here,
331
+ # where the promise of the return type is made.
332
+ yield cast(NostrEvent, msg[2])
333
+
334
+ def unsubscribe(self, sid: str) -> None:
335
+ self._send(['CLOSE', sid])
336
+ self._subs.pop(sid, None)
337
+
338
+
339
+ def publish_all(
340
+ urls: Sequence[str],
341
+ event: EventLike,
342
+ timeout: float = 15.0,
343
+ auth_signer: Optional[Callable[[str, str], Optional[EventLike]]] = None,
344
+ ) -> dict[str, tuple[bool, str]]:
345
+ """{url: (accepted, message)}; one failed relay does not stop the others."""
346
+ out: dict[str, tuple[bool, str]] = {}
347
+ for url in urls:
348
+ r = Relay(url, timeout, auth_signer=auth_signer)
349
+ try:
350
+ out[url] = r.publish(event)
351
+ except Exception as e:
352
+ out[url] = (False, '%s: %s' % (type(e).__name__, e))
353
+ finally:
354
+ r.close()
355
+ return out
356
+
357
+
358
+ def query_all(
359
+ urls: Sequence[str],
360
+ filters: Sequence[NostrFilter | Mapping[str, Any]],
361
+ timeout: float = 15.0,
362
+ auth_signer: Optional[Callable[[str, str], Optional[EventLike]]] = None,
363
+ ) -> list[NostrEvent]:
364
+ """Events from every relay, deduplicated by id."""
365
+ seen: dict[str, NostrEvent] = {}
366
+ for url in urls:
367
+ r = Relay(url, timeout, auth_signer=auth_signer)
368
+ try:
369
+ for ev in r.query(filters):
370
+ seen.setdefault(ev['id'], ev)
371
+ except (RelayError, ws.ConnectionClosed, OSError) as e:
372
+ # One relay down must not cost the answers of the others; the
373
+ # caller asked for the union of what is reachable.
374
+ log.info('relay %s: query skipped: %s', url, e)
375
+ finally:
376
+ r.close()
377
+ return list(seen.values())
@@ -0,0 +1,211 @@
1
+ """The kind 11801 a node publishes, and the DID document a resolver derives.
2
+
3
+ Built from the node's keys and its MeshNode manifest: class, endpoints,
4
+ operator, the protocols it implements as c tags, the plain capabilities as
5
+ hints, expiry within the class maximum, and a NIP-13 nonce mined to the
6
+ policy's bits. Every descriptor is run through fleetmesh_core.identity.validate_descriptor
7
+ before it leaves, so this node can never publish what it would decline.
8
+ """
9
+ from __future__ import annotations
10
+
11
+ import time
12
+ from typing import TYPE_CHECKING, Any, Mapping, Optional, Sequence, TypedDict
13
+ from fleetmesh_core.crypto import crypto
14
+ from fleetmesh_core.identity import did, identity
15
+ from fleetmesh_core.identity.did import Document as DIDDocument
16
+ from fleetmesh_core.wire import schema_files
17
+ from fleetmesh_core.storage import store # noqa: E402
18
+ from node.interfaces import EventLike, NostrEvent, TagList
19
+
20
+ if TYPE_CHECKING:
21
+ from node.keystore import Keystore
22
+
23
+ CONSTANTS = identity.CONSTANTS
24
+ DIGEST = schema_files.digest()
25
+ VERSION = schema_files.load_kinds()['version']
26
+ SOFTWARE = (identity.FLEETMESH.rstrip('/'), '0.0.1') # the project URL, derived and never written down
27
+ KIND: int = 11801
28
+
29
+
30
+ def mine(
31
+ pubkey: str,
32
+ created_at: int,
33
+ kind: int,
34
+ tags: TagList,
35
+ content: str,
36
+ bits: int,
37
+ max_tries: int = 1 << 26,
38
+ ) -> tuple[TagList, str]:
39
+ """Find a NIP-13 nonce so the event id has at least `bits` leading zeros.
40
+
41
+ The nonce tag is ['nonce', '<n>', '<target bits>'] and it is the last tag,
42
+ so everything else about the event is fixed before mining starts.
43
+ """
44
+ base = [t for t in tags if t[0] != 'nonce']
45
+ for n in range(max_tries):
46
+ trial = base + [['nonce', str(n), str(bits)]]
47
+ eid = store.event_id(pubkey, created_at, kind, trial, content)
48
+ if identity.pow_bits(eid) >= bits:
49
+ return trial, eid
50
+ raise RuntimeError('no nonce found in %d tries' % max_tries)
51
+
52
+
53
+
54
+ def build(
55
+ keystore: Keystore,
56
+ node_class: str,
57
+ endpoints: Sequence[tuple[str, str, int] | list[Any]],
58
+ operator_pubkey: str,
59
+ protocols: Sequence[str],
60
+ now: Optional[int] = None,
61
+ capabilities: Sequence[str] = (),
62
+ mediator: Optional[str] = None,
63
+ policy: Optional[tuple[str, ...] | list[str]] = None,
64
+ coverage: Optional[str] = None,
65
+ nips: Optional[Sequence[int]] = None,
66
+ lifetime: Optional[int] = None,
67
+ bits: Optional[int] = None,
68
+ content: str = '',
69
+ fips_key: Optional[str] = None,
70
+ extra_tags: Sequence[Sequence[str]] = (),
71
+ ) -> NostrEvent:
72
+ """A signed kind 11801.
73
+
74
+ endpoints: [(scheme, address, preference)]. protocols: the fleetmesh protocol
75
+ identifiers this node implements; peering/1.0 and budget/1.0 are required
76
+ of every class and are added if missing. policy: (sha256 hex, url).
77
+ extra_tags: optional tags a subsystem contributes (mint, price), placed
78
+ before operator so the fixed tail stays where readers expect it.
79
+ """
80
+ now = int(now if now is not None else time.time())
81
+ cap = CONSTANTS['descriptor']
82
+ max_life = cap['max_lifetime_seconds_leaf' if node_class == 'leaf' else 'max_lifetime_seconds_anchor_edge']
83
+ lifetime = min(lifetime or max_life, max_life)
84
+ bits = cap['default_pow_bits'] if bits is None else bits
85
+ mk = keystore.multikeys()
86
+ protos = sorted(set(protocols) | set(identity.REQUIRED_PROTOCOLS))
87
+ tags: TagList = [
88
+ ['class', node_class],
89
+ ['protocol', VERSION, DIGEST],
90
+ ['software', SOFTWARE[0], SOFTWARE[1]],
91
+ ['key', 'agreement', mk['agreement']],
92
+ ['key', 'transport', mk['transport']],
93
+ ]
94
+ if fips_key:
95
+ tags.append(['key', 'fips', fips_key])
96
+ for scheme, address, pref in sorted(endpoints, key=lambda e: (int(e[2]), e[0])):
97
+ tags.append(['endpoint', scheme, address, str(pref)])
98
+ if mediator:
99
+ tags.append(['mediator', mediator])
100
+ tags += [['c', p] for p in protos]
101
+ tags += [['c', c] for c in capabilities]
102
+ if nips:
103
+ tags.append(['nips'] + [str(n) for n in nips])
104
+ if coverage:
105
+ tags.append(['coverage', coverage])
106
+ if policy:
107
+ tags.append(['policy', policy[0]] + ([policy[1]] if len(policy) > 1 and policy[1] else []))
108
+ for t in extra_tags:
109
+ tags.append([str(x) for x in t])
110
+ tags.append(['operator', operator_pubkey])
111
+ tags.append(['expiration', str(now + lifetime)])
112
+ if bits:
113
+ tags, _ = mine(keystore.node_pk, now, KIND, tags, content, bits)
114
+ ev: NostrEvent = store.make_signed_event(keystore.node_sk, now, KIND, tags, content)
115
+ problems = identity.validate_descriptor(ev, now=now, digest=DIGEST)
116
+ if problems:
117
+ raise ValueError('refusing to publish an invalid descriptor: %s' % '; '.join(problems))
118
+ return ev
119
+
120
+
121
+ class ManifestEndpoint(TypedDict, total=False):
122
+ transport: str
123
+ address: str
124
+ ticket: str
125
+ priority: int
126
+
127
+
128
+ class ManifestOperator(TypedDict, total=False):
129
+ pubkey: str
130
+ policyUrl: str
131
+
132
+
133
+ ManifestSpecDict = TypedDict('ManifestSpecDict', {
134
+ 'class': str,
135
+ 'endpoints': list[ManifestEndpoint],
136
+ 'capabilities': list[str],
137
+ 'operator': ManifestOperator,
138
+ }, total=False)
139
+
140
+
141
+ class MeshNodeManifest(TypedDict, total=False):
142
+ spec: ManifestSpecDict
143
+
144
+
145
+ def from_manifest(
146
+ keystore: Keystore,
147
+ manifest: MeshNodeManifest | Mapping[str, Any],
148
+ now: Optional[int] = None,
149
+ bits: Optional[int] = None,
150
+ ) -> NostrEvent:
151
+ """A descriptor from a MeshNode manifest (manifests/meshnode-*.yaml, parsed)."""
152
+ spec = manifest['spec']
153
+ eps: list[tuple[str, str, int]] = []
154
+ for e in spec.get('endpoints', []):
155
+ addr = str(e.get('address') or e.get('ticket') or '')
156
+ eps.append((str(e.get('transport', '')), addr, int(e.get('priority', 50))))
157
+ caps = [c for c in spec.get('capabilities', []) if not c.startswith('https://')]
158
+ protos = [c for c in spec.get('capabilities', []) if c.startswith('https://')]
159
+ pol = spec.get('operator', {}).get('policyUrl')
160
+ policy = (crypto.sha256(pol.encode()), pol) if pol else None
161
+ return build(keystore, spec['class'], eps, spec['operator']['pubkey'], protos, now=now,
162
+ capabilities=caps, policy=policy, bits=bits)
163
+
164
+
165
+ # ---------------------------------------------------------- resolution ----
166
+ def genuine(desc: Any, pubkey: Optional[str] = None) -> bool:
167
+ """Signed by its own pubkey, and by the pubkey it was asked for.
168
+
169
+ A relay answers a query by author with whatever it chooses to serve under
170
+ that name. Nothing verified the signature of a resolved descriptor, so a
171
+ relay could hand a node a descriptor naming an agreement key of the
172
+ relay's choosing, and every envelope to that peer would then be encrypted
173
+ to it. The rule for heartbeats already says it: believing an event
174
+ unsigned lets a relay speak as any peer.
175
+ """
176
+ from fleetmesh_core.crypto import giftwrap
177
+ return (isinstance(desc, dict) and (pubkey is None or desc.get('pubkey') == pubkey)
178
+ and giftwrap.verify_event(desc))
179
+
180
+
181
+ def newest(
182
+ descriptors: Sequence[NostrEvent | EventLike],
183
+ now: Optional[int] = None,
184
+ digest: str = DIGEST,
185
+ pubkey: Optional[str] = None,
186
+ ) -> Optional[NostrEvent]:
187
+ """The live, genuine descriptor among candidates for one pubkey, or None."""
188
+ now = int(now if now is not None else time.time())
189
+ cands = list(descriptors)
190
+ live = [d for d in cands if genuine(d, pubkey)
191
+ and not identity.validate_descriptor(d, now=now, digest=digest)]
192
+ picked = max(live, key=lambda d: int(d['created_at'])) if live else None
193
+ return picked # type: ignore[return-value]
194
+
195
+
196
+ def resolve(
197
+ pubkey: str,
198
+ relay_urls: Sequence[str],
199
+ now: Optional[int] = None,
200
+ timeout: float = 15.0,
201
+ ) -> Optional[NostrEvent]:
202
+ """The live kind 11801 for a pubkey from these relays, or None."""
203
+ from . import client
204
+ found = client.query_all(relay_urls, [{'kinds': [KIND], 'authors': [pubkey]}], timeout)
205
+ return newest(found, now, pubkey=pubkey)
206
+
207
+
208
+ def did_document(desc: EventLike) -> DIDDocument:
209
+ """The node profile a did:nostr resolver emits for a kind 11801 (fleetmesh_core/did)."""
210
+ return did.document(desc)
211
+