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,656 @@
1
+ """The relay face: a minimal NIP-01 relay over a MeshStore.
2
+
3
+ EVENT is verified, stored and fanned out to every live subscription that
4
+ matches; REQ answers from the store and stays open; CLOSE ends it. Ephemeral
5
+ kinds are fanned out and never stored, which is what lets kind 21059 gift
6
+ wraps be a transport. NIP-11 answers on GET. This is the seed of the node's
7
+ relay face (step 5), and the fixture the identity and transport tests run
8
+ against; NIP-77 frames come with the sync step.
9
+ """
10
+ from __future__ import annotations
11
+
12
+ import json
13
+ import os
14
+ import socket
15
+ import threading
16
+ import time
17
+ from typing import Any, Callable, cast, Mapping, Optional, Sequence, TypedDict
18
+ from fleetmesh_core.storage import negentropy, store # noqa: E402
19
+ FACE_FUTURE_MAX_SECONDS: int = 900
20
+ from node.interfaces import EventLike, NostrEvent, NostrFilter # noqa: E402
21
+ from node.transport import ws # noqa: E402
22
+ from fleetmesh_core.crypto import giftwrap # noqa: E402
23
+
24
+
25
+ class GossipTopicSummary(TypedDict):
26
+ topic: str
27
+ admin: str
28
+ members: int
29
+ mesh: int
30
+ expires: Any
31
+ roster_id: Any
32
+
33
+
34
+ class GossipViewReport(TypedDict):
35
+ enabled: bool
36
+ topics: list[GossipTopicSummary]
37
+
38
+
39
+ class RelayLimitation(TypedDict):
40
+ max_subscriptions: int
41
+ max_message_length: int
42
+ max_subid_length: int
43
+ max_limit: int
44
+ auth_required: bool
45
+ payment_required: bool
46
+ restricted_writes: bool
47
+ min_pow_difficulty: int
48
+ created_at_upper_limit: int
49
+
50
+
51
+ class NIP11RelayInformation(TypedDict, total=False):
52
+ name: str
53
+ software: str
54
+ version: str
55
+ supported_nips: list[int]
56
+ limitation: RelayLimitation
57
+ pubkey: str
58
+
59
+ # What one client may make the face hold or do. sub_max left the grant
60
+ # ceilings because a peer never opens a subscription; a client does, and
61
+ # twenty is the old member advisory. An event the reference would store
62
+ # without complaint can still be too big to be honest about.
63
+ SUB_MAX: int = 20
64
+ # A subscribed client sits silent for as long as nothing matches its filter.
65
+ # The socket timeout used to be the idle limit, so every quiet client was
66
+ # dropped after a minute. Now a quiet client is pinged at each timeout and
67
+ # dropped only when it has answered nothing for this long.
68
+ IDLE_DROP: int = 180
69
+ EVENT_MAX_BYTES: int = 256 * 1024
70
+ # How far ahead of this node's clock an event may be dated and still be stored.
71
+ #
72
+ # The face had no bound at all, and an open one collects what that implies: on
73
+ # the alpha mesh the newest event held was dated three weeks into the future.
74
+ # A future timestamp is not a NIP-59 randomisation - that runs BACKWARDS, up to
75
+ # two days, by construction - so anything ahead of now is a bad clock or a
76
+ # client buying itself the top of every `created_at DESC` listing forever.
77
+ #
78
+ # Fifteen minutes rather than the protocol's own max_clock_skew_seconds. That
79
+ # 60 is the tolerance for judging a peer this node has a grant with, on a box
80
+ # it can expect to run NTP; this is the door onto the public internet, where a
81
+ # phone two minutes out is ordinary and refusing it would be the relay's fault,
82
+ # not the client's.
83
+ #
84
+ # There is deliberately NO lower bound. A gift wrap is supposed to be backdated
85
+ # and NIP-40 already says when a thing expires; a floor here would refuse the
86
+ # exact traffic NIP-59 asks clients to send.
87
+ FUTURE_MAX_SECONDS: int = FACE_FUTURE_MAX_SECONDS # one number; node/config/records.py owns it
88
+ SID_MAX: int = 64
89
+ # NIP-42: how far a kind 22242's created_at may sit from this relay's clock
90
+ # either way. The challenge is fresh per connection, so replay is already
91
+ # refused; the window is defence against a challenge string that leaked
92
+ # and a client whose clock is a day wrong, and it is the value the NIP names.
93
+ AUTH_WINDOW_SECONDS: int = 600
94
+ # The most events one REQ may be answered with, whatever it asked for.
95
+ #
96
+ # A filter with no `limit` used to mean "all of them", which is a promise the
97
+ # face cannot keep once it holds more than it can afford to hold in memory at
98
+ # once - and an open face decides how much that is by collecting. NIP-11 has a
99
+ # field for exactly this, so the cap is declared rather than silently applied,
100
+ # and a client that wants more pages with `until`.
101
+ REQ_MAX: int = 500
102
+
103
+
104
+ def _filters_ok(filters: Sequence[Any]) -> Optional[str]:
105
+ if not filters or not all(isinstance(f, dict) for f in filters):
106
+ return 'filters must be objects'
107
+ for f in filters:
108
+ for k in ('since', 'until', 'limit'):
109
+ if k in f and (not isinstance(f[k], int) or isinstance(f[k], bool) or f[k] < 0):
110
+ return '%s must be a non-negative integer' % k
111
+ return None
112
+
113
+
114
+ class RelayServer:
115
+ def __init__(
116
+ self,
117
+ backend: Optional[Any] = None,
118
+ host: str = '127.0.0.1',
119
+ port: int = 0,
120
+ name: str = 'meshnode',
121
+ clock: Optional[Callable[[], int]] = None,
122
+ pubkey: Optional[str] = None,
123
+ require_auth: bool = False,
124
+ challenge: Optional[str] = None,
125
+ future_seconds: int = FUTURE_MAX_SECONDS,
126
+ blob_store: Optional[Any] = None,
127
+ metrics_enabled: bool = True,
128
+ metrics_provider: Optional[Callable[[], str]] = None,
129
+ node: Optional[Any] = None,
130
+ grants: Optional[Any] = None,
131
+ host_router: Optional[Any] = None,
132
+ ) -> None:
133
+ self.store: Any = backend if backend is not None else store.MemoryStore()
134
+ # The grant store the metrics page counts peers from; the node hands
135
+ # it in, a face started on its own has none.
136
+ self.grants: Optional[Any] = grants
137
+ self.blob_store: Optional[Any] = blob_store
138
+ self.metrics_enabled: bool = metrics_enabled
139
+ self.metrics_provider: Optional[Callable[[], str]] = metrics_provider
140
+ self.node: Optional[Any] = node
141
+ self.blossom: Optional[Any] = None
142
+ # Compartments that answer plain HTTP on this port (nsite) append a
143
+ # `(conn, req) -> bool` here in start(); the first True wins. Blossom
144
+ # is asked first because its paths are fixed; a hook routes by Host.
145
+ self.http_handlers: list[Callable[[socket.socket, ws.PlainHTTP], bool]] = []
146
+ if blob_store is not None:
147
+ from . import blossom
148
+ self.blossom = blossom.BlossomServer(blob_store, node_pk=pubkey, clock=clock)
149
+ self.name: str = name
150
+ self.pubkey: Optional[str] = pubkey # the node key: NIP-11's way of saying whose face this is
151
+ self.clock: Callable[[], int] = clock or (lambda: int(time.time()))
152
+ self.require_auth: bool = require_auth
153
+ self.future_seconds: int = int(future_seconds)
154
+ # One challenge per connection, made when the socket opens and gone
155
+ # when it closes. It used to be one string for the life of the
156
+ # process: a kind 22242 signed for it once was then good on every
157
+ # later connection, so an AUTH event that any relay operator had seen
158
+ # was a key to this one for as long as it ran. A `challenge` given to
159
+ # the constructor pins it for every connection, for tests only.
160
+ self._fixed_challenge: Optional[str] = challenge
161
+ self._challenges: dict[int, str] = {} # ws id -> challenge
162
+ self._subs: dict[int, dict[str, list[NostrFilter | Mapping[str, Any]]]] = {} # (ws id) -> {sid: filters}
163
+ self._conns: dict[int, ws.WebSocket] = {} # ws id -> ws
164
+ self._authed: dict[int, str] = {} # ws id -> authed pubkey
165
+ self._lock: threading.Lock = threading.Lock()
166
+ self.host_router: Optional[Any] = host_router
167
+ if host_router is not None:
168
+ self._server: Optional[ws.Server] = None
169
+ self._host: str = host
170
+ self._port: int = port
171
+ else:
172
+ self._server = ws.Server(self._handle, host, port, http_handler=self._http)
173
+ self._host = self._server.host
174
+ self._port = self._server.port
175
+ self._neg: dict[int, dict[str, negentropy.Negentropy]] = {} # ws id -> {subid: Negentropy}
176
+ self.accepted: int = 0
177
+ self.refused_future: int = 0
178
+ self.on_event_handlers: list[Callable[[NostrEvent], None]] = []
179
+
180
+ def add_event_handler(self, handler: Callable[[NostrEvent], None]) -> Callable[[], None]:
181
+ """Call `handler` with every event this face accepts; returns the call that removes it.
182
+
183
+ A handler that raises is skipped for that event, never the face."""
184
+ if handler not in self.on_event_handlers:
185
+ self.on_event_handlers.append(handler)
186
+
187
+ def remove() -> None:
188
+ if handler in self.on_event_handlers:
189
+ self.on_event_handlers.remove(handler)
190
+ return remove
191
+
192
+ @property
193
+ def server(self) -> Any:
194
+ if self._server is not None:
195
+ return self._server
196
+ if self.host_router is not None:
197
+ srv = getattr(self.host_router, 'server', None) or getattr(self.host_router, '_server', None)
198
+ if srv is not None:
199
+ return srv
200
+ class _StoppedServerProxy:
201
+ host = self.host
202
+ port = self.port
203
+ return _StoppedServerProxy()
204
+
205
+ @property
206
+ def host(self) -> str:
207
+ if self._server is not None:
208
+ return self._server.host
209
+ if self.host_router is not None:
210
+ return self.host_router.host
211
+ return self._host
212
+
213
+ @property
214
+ def port(self) -> int:
215
+ if self._server is not None:
216
+ return self._server.port
217
+ if self.host_router is not None:
218
+ return self.host_router.port
219
+ return self._port
220
+
221
+ @property
222
+ def url(self) -> str:
223
+ if self._server is not None:
224
+ return self._server.url
225
+ if self.host_router is not None:
226
+ return self.host_router.url
227
+ return 'ws://%s:%d' % (self.host, self.port)
228
+
229
+ def start(self) -> RelayServer:
230
+ if self._server is not None:
231
+ self._server.start()
232
+ return self
233
+
234
+ def stop(self) -> None:
235
+ if self._server is not None:
236
+ self._server.stop()
237
+
238
+ def handle_http_request(self, conn: socket.socket, req: ws.PlainHTTP) -> bool:
239
+ """Handle an HTTP request. Returns True if handled, False if unhandled."""
240
+ if self.blossom and self.blossom.handle_request(conn, req):
241
+ return True
242
+ for handler in self.http_handlers:
243
+ if handler(conn, req):
244
+ return True
245
+ clean_path = req.path.split('?', 1)[0].rstrip('/')
246
+ headers = req.headers or {}
247
+ if clean_path in ('', '/nip11') or headers.get(b'accept', b'').startswith(b'application/nostr+json'):
248
+ body = json.dumps(self.nip11()).encode()
249
+ ws.http_response(conn, '200 OK', body, 'application/nostr+json')
250
+ return True
251
+ elif clean_path == '/metrics' and getattr(self, 'metrics_enabled', True):
252
+ body = self.render_metrics().encode('utf-8')
253
+ ws.http_response(conn, '200 OK', body, 'text/plain; version=0.0.4; charset=utf-8')
254
+ return True
255
+ elif clean_path == '/gossip':
256
+ body = json.dumps(self.gossip_view()).encode('utf-8')
257
+ ws.http_response(conn, '200 OK', body, 'application/json')
258
+ return True
259
+ elif clean_path == '/didcomm':
260
+ ws.http_response(conn, '501 Not Implemented',
261
+ b'{"error":"transport https declared, not implemented; use iroh, libp2p or nostr"}',
262
+ 'application/json')
263
+ return True
264
+ return False
265
+
266
+ def mount_ingress(self, host: Any, owner: str = 'nostr') -> None:
267
+ """Mount Nostr relay WebSocket and HTTP endpoints onto ProtocolHostRouter."""
268
+ host.mount_websocket('/', self._handle, owner=owner)
269
+ host.mount_route('/', self.handle_http_request, methods=('*',), owner=owner)
270
+ host.mount_route('/nip11', self.handle_http_request, methods=('GET',), owner=owner)
271
+ host.mount_route('/metrics', self.handle_http_request, methods=('GET',), owner=owner)
272
+ host.mount_route('/gossip', self.handle_http_request, methods=('GET',), owner=owner)
273
+ host.mount_route('/didcomm', self.handle_http_request, methods=('GET', 'POST'), owner=owner)
274
+ if self.blossom is not None:
275
+ host.mount_route('/upload', self.blossom.handle_request, methods=('PUT', 'HEAD', 'OPTIONS'), owner=owner)
276
+ host.mount_route('/list', self.blossom.handle_request, methods=('GET', 'HEAD', 'OPTIONS'), owner=owner)
277
+ host.mount_route('/mirror', self.blossom.handle_request, methods=('PUT', 'OPTIONS'), owner=owner)
278
+
279
+ # ---- one connection --------------------------------------------------
280
+ def _handle(self, sock: ws.WebSocket, path: str) -> None:
281
+ key = id(sock)
282
+ challenge = self._fixed_challenge or ('challenge-' + os.urandom(16).hex())
283
+ with self._lock:
284
+ self._conns[key], self._subs[key] = sock, {}
285
+ self._challenges[key] = challenge
286
+ if self.require_auth:
287
+ sock.send_text(json.dumps(['AUTH', challenge]))
288
+ try:
289
+ while True:
290
+ try:
291
+ raw = sock.recv_text()
292
+ except socket.timeout:
293
+ if sock.idle() > IDLE_DROP:
294
+ raise
295
+ sock.ping()
296
+ continue
297
+ try:
298
+ msg = json.loads(raw)
299
+ except ValueError:
300
+ sock.send_text(json.dumps(['NOTICE', 'not JSON']))
301
+ continue
302
+ if not isinstance(msg, list) or not msg:
303
+ continue
304
+ verb = msg[0]
305
+ sid = msg[1] if len(msg) > 1 else None
306
+ if verb in ('REQ', 'CLOSE', 'NEG-OPEN', 'NEG-MSG', 'NEG-CLOSE') and not (
307
+ isinstance(sid, str) and 0 < len(sid) <= SID_MAX):
308
+ sock.send_text(json.dumps(['NOTICE', 'subscription id must be a short string']))
309
+ continue
310
+ # NIP-01 verb dispatch. The arity guards of the old elif chain
311
+ # are the sequence patterns: a verb with too few members falls
312
+ # through to the same NOTICE an unknown verb gets, as before.
313
+ match msg:
314
+ case ['EVENT', ev, *_]:
315
+ self._on_event(sock, ev, key)
316
+ case ['REQ', str(req_sid), *filters]:
317
+ if not filters:
318
+ sock.send_text(json.dumps(['CLOSED', req_sid, 'invalid: filters required']))
319
+ continue
320
+ why = _filters_ok(filters)
321
+ if why is not None:
322
+ sock.send_text(json.dumps(['CLOSED', req_sid, 'invalid: ' + why]))
323
+ continue
324
+ with self._lock:
325
+ open_subs = len(self._subs.get(key, {}))
326
+ if open_subs >= SUB_MAX and req_sid not in self._subs.get(key, {}):
327
+ sock.send_text(json.dumps(['CLOSED', req_sid, 'blocked: too many subscriptions']))
328
+ continue
329
+ self._on_req(sock, key, req_sid, filters)
330
+ case ['AUTH', auth_ev, *_]:
331
+ self._on_auth(sock, key, auth_ev)
332
+ case ['CLOSE', str(close_sid), *_]:
333
+ with self._lock:
334
+ self._subs[key].pop(close_sid, None)
335
+ case ['NEG-OPEN', str(neg_sid), neg_filter, neg_msg, *_]:
336
+ self._neg_open(sock, key, neg_sid, neg_filter, neg_msg)
337
+ case ['NEG-MSG', str(neg_sid), neg_msg, *_]:
338
+ self._neg_msg(sock, key, neg_sid, neg_msg)
339
+ case ['NEG-CLOSE', str(neg_sid), *_]:
340
+ self._neg.get(key, {}).pop(neg_sid, None)
341
+ case _:
342
+ sock.send_text(json.dumps(['NOTICE', 'unknown verb']))
343
+ finally:
344
+ with self._lock:
345
+ self._conns.pop(key, None)
346
+ self._subs.pop(key, None)
347
+ self._authed.pop(key, None)
348
+ self._challenges.pop(key, None)
349
+
350
+ def _on_auth(self, sock: ws.WebSocket, key: int, ev: Any) -> None:
351
+ if not isinstance(ev, dict) or not giftwrap.verify_event(ev):
352
+ sock.send_text(json.dumps(['OK', ev.get('id', '') if isinstance(ev, dict) and isinstance(ev.get('id'), str) else '',
353
+ False, 'invalid: signature or id does not verify']))
354
+ return
355
+ if ev.get('kind') != 22242:
356
+ sock.send_text(json.dumps(['OK', ev.get('id', ''), False, 'invalid: kind must be 22242']))
357
+ return
358
+ with self._lock:
359
+ expected = self._challenges.get(key)
360
+ # Compared for this connection only. id(ws) is reused once a socket
361
+ # is gone, which is why the entry is removed on disconnect rather
362
+ # than left to match a stranger's later connection.
363
+ has_challenge = expected is not None and any(
364
+ isinstance(t, list) and len(t) > 1 and t[0] == 'challenge' and t[1] == expected
365
+ for t in ev.get('tags', []))
366
+ if not has_challenge:
367
+ sock.send_text(json.dumps(['OK', ev['id'], False, 'invalid: challenge mismatch']))
368
+ return
369
+ created = ev.get('created_at')
370
+ if not isinstance(created, int) or abs(created - self.clock()) > AUTH_WINDOW_SECONDS:
371
+ sock.send_text(json.dumps(['OK', ev['id'], False,
372
+ 'invalid: created_at is outside the %d second window' % AUTH_WINDOW_SECONDS]))
373
+ return
374
+ with self._lock:
375
+ self._authed[key] = ev['pubkey']
376
+ sock.send_text(json.dumps(['OK', ev['id'], True, '']))
377
+
378
+ def _on_event(self, sock: ws.WebSocket, ev: Any, key: int = 0) -> None:
379
+ if self.require_auth and key not in self._authed:
380
+ sock.send_text(json.dumps(['OK', ev.get('id', '') if isinstance(ev, dict) and isinstance(ev.get('id'), str) else '',
381
+ False, 'auth-required: authentication required']))
382
+ return
383
+ if isinstance(ev, dict) and len(json.dumps(ev, separators=(',', ':'))) > EVENT_MAX_BYTES:
384
+ sock.send_text(json.dumps(['OK', ev.get('id', '') if isinstance(ev.get('id'), str) else '', False,
385
+ 'invalid: event is larger than %d bytes' % EVENT_MAX_BYTES]))
386
+ return
387
+ if not isinstance(ev, dict) or not giftwrap.verify_event(ev):
388
+ sock.send_text(json.dumps(['OK', ev.get('id', '') if isinstance(ev, dict) and isinstance(ev.get('id'), str) else '',
389
+ False, 'invalid: signature or id does not verify']))
390
+ return
391
+ # A verified event has id, pubkey, created_at, kind and sig of the right
392
+ # types - the id is a hash over them and the check would have failed
393
+ # otherwise. verify_event tolerates an omitted tags/content as empty,
394
+ # and this face has always accepted such events, so the type is
395
+ # asserted here rather than checked again and made stricter.
396
+ event = cast(NostrEvent, ev)
397
+ now = self.clock()
398
+ # Checked after the signature, so a rejection is about an event that is
399
+ # genuinely someone's, and before the store, so nothing dated past the
400
+ # bound is ever written down.
401
+ created = ev.get('created_at')
402
+ if self.future_seconds and isinstance(created, int) and created > now + self.future_seconds:
403
+ # Counted before the answer goes out, the way `accepted` is. The
404
+ # other order means a client holding its OK can read the counter
405
+ # before this thread has reached it, so a caller that refuses two
406
+ # events and then asks how many were refused is told one. Rare
407
+ # enough to pass every local run and turn up on a loaded CI runner.
408
+ self.refused_future += 1
409
+ sock.send_text(json.dumps(['OK', ev['id'], False,
410
+ 'invalid: created_at is %d seconds in the future, and this relay '
411
+ 'accepts at most %d' % (created - now, self.future_seconds)]))
412
+ return
413
+ if store.is_ephemeral(ev['kind']):
414
+ result = 'ephemeral'
415
+ else:
416
+ result = self.store.insert_event(event, now)
417
+ # A client handed this to us directly, which is the only provenance
418
+ # that lets a coverage claim assert completeness.
419
+ if result == 'stored' and hasattr(self.store, 'note_origin'):
420
+ self.store.note_origin(ev['id'], self.store.LOCAL)
421
+ self.accepted += 1
422
+ sock.send_text(json.dumps(['OK', ev['id'], True, '' if result != 'duplicate' else 'duplicate: already have it']))
423
+ self._fanout(event)
424
+ for handler in list(self.on_event_handlers):
425
+ try:
426
+ handler(event)
427
+ except Exception:
428
+ pass
429
+
430
+ def _fanout(self, ev: EventLike) -> None:
431
+ with self._lock:
432
+ targets = [(w, sid) for k, w in self._conns.items()
433
+ for sid, filters in self._subs.get(k, {}).items()
434
+ if any(store.matches(ev, f) for f in filters)]
435
+ if not targets:
436
+ return
437
+ # The event is serialised once and the subscription id spliced in per
438
+ # target; json.dumps(['EVENT', sid, ev]) with these separators is
439
+ # exactly this concatenation, so the frame bytes are unchanged.
440
+ # Measured 9-10x faster at 50 subscribers on a typical kind-1 event.
441
+ body = ',' + json.dumps(ev, separators=(',', ':')) + ']'
442
+ for w, sid in targets:
443
+ try:
444
+ w.send_text('["EVENT",' + json.dumps(sid) + body)
445
+ except OSError:
446
+ pass
447
+
448
+ def _on_req(self, sock: ws.WebSocket, key: int, sid: str, filters: Sequence[NostrFilter | Mapping[str, Any]]) -> None:
449
+ if self.require_auth and key not in self._authed:
450
+ sock.send_text(json.dumps(['CLOSED', sid, 'auth-required: authentication required']))
451
+ return
452
+ now = self.clock()
453
+ # How many can leave, decided before anything is read. The filter used
454
+ # to be dropped here - items_range({}) - so every event in the time
455
+ # range was parsed to build the id list and parsed again to be matched,
456
+ # and the limit was applied to what survived. On a face holding 8,923
457
+ # events that is 200 MB of JSON per REQ, which is what was OOM-killing
458
+ # the public node every three hours.
459
+ asked = [f['limit'] for f in filters if isinstance(f.get('limit'), int)]
460
+ limit = min(min(asked) if asked else REQ_MAX, REQ_MAX)
461
+ # Registered before anything is replayed, and so before the EOSE that
462
+ # tells the client the subscription exists. The other order had the
463
+ # face send stored events, send the EOSE, and only then put the filters
464
+ # where `_fanout` looks: a client that waits for its EOSE and then
465
+ # causes an event - which is exactly what `relay.subscribe(ready=True)`
466
+ # documents as the way to know it is registered - could have that
467
+ # event fanned out to nobody in the window between the two lines. An
468
+ # ephemeral kind is then gone for good, because no relay stores one,
469
+ # and no timeout on the reader can recover it.
470
+ #
471
+ # The cost of this order is that an event arriving during the replay
472
+ # can go out live and appear in the replay too. That is a duplicate id
473
+ # on a subscription, which NIP-01 requires a client to tolerate and
474
+ # every reader here already does - a lost ephemeral event is not
475
+ # recoverable by anyone.
476
+ with self._lock:
477
+ self._subs[key][sid] = [
478
+ {**store.compiled(f), **{k: f[k] for k in ('since', 'until') if k in f}}
479
+ for f in filters
480
+ ]
481
+ found: dict[str, NostrEvent] = {}
482
+ for f in filters:
483
+ since, until = f.get('since', 0), f.get('until', 2 ** 31)
484
+ for ev in self.store.newest_matching(f, since, until, now, limit):
485
+ found[ev['id']] = ev
486
+ # The newest `limit` of the union is a subset of each filter's newest
487
+ # `limit`, so merging the bounded answers and cutting again is the same
488
+ # set the unbounded version sent.
489
+ out = sorted(found.values(), key=lambda e: (-e['created_at'], e['id']))
490
+ for ev in out[:limit]:
491
+ sock.send_text(json.dumps(['EVENT', sid, ev], separators=(',', ':')))
492
+ sock.send_text(json.dumps(['EOSE', sid]))
493
+
494
+ # ---- NIP-77 ------------------------------------------------------------
495
+ def _neg_open(self, sock: ws.WebSocket, key: int, sid: str, filt: Any, initial_hex: Any) -> None:
496
+ """NEG-OPEN: reconcile the filter's range with the client's first frame."""
497
+ now = self.clock()
498
+ if not isinstance(filt, dict) or _filters_ok([filt]) is not None or not isinstance(initial_hex, str):
499
+ sock.send_text(json.dumps(['NEG-ERR', sid, 'blocked: filter must be an object and the frame hex']))
500
+ return
501
+ try:
502
+ items = self.store.items_range(filt, filt.get('since', 0), filt.get('until', 2 ** 31), now)
503
+ neg = negentropy.Negentropy(items, is_client=False)
504
+ reply, _, _ = neg.reconcile(bytes.fromhex(initial_hex))
505
+ except negentropy.UnsupportedVersion:
506
+ sock.send_text(json.dumps(['NEG-MSG', sid, '%02x' % negentropy.VERSION]))
507
+ return
508
+ except (ValueError, TypeError, negentropy.Malformed) as e:
509
+ sock.send_text(json.dumps(['NEG-ERR', sid, 'blocked: %s' % e]))
510
+ return
511
+ self._neg.setdefault(key, {})[sid] = neg
512
+ sock.send_text(json.dumps(['NEG-MSG', sid, (reply or b'').hex()]))
513
+
514
+ def _neg_msg(self, sock: ws.WebSocket, key: int, sid: str, frame_hex: str) -> None:
515
+ neg = self._neg.get(key, {}).get(sid)
516
+ if neg is None:
517
+ sock.send_text(json.dumps(['NEG-ERR', sid, 'closed: no such negentropy subscription']))
518
+ return
519
+ try:
520
+ reply, _, _ = neg.reconcile(bytes.fromhex(frame_hex))
521
+ except (ValueError, TypeError, negentropy.Malformed) as e:
522
+ sock.send_text(json.dumps(['NEG-ERR', sid, 'blocked: %s' % e]))
523
+ return
524
+ sock.send_text(json.dumps(['NEG-MSG', sid, (reply or b'').hex()]))
525
+
526
+ # ---- HTTP, NIP-11, Blossom & Prometheus Metrics --------------------------
527
+ def render_metrics(self) -> str:
528
+ """Render Prometheus / OpenMetrics exposition format."""
529
+ if self.metrics_provider is not None:
530
+ try:
531
+ return self.metrics_provider()
532
+ except Exception:
533
+ pass
534
+
535
+ lines = [
536
+ '# HELP fleetmesh_events_stored_total Total events stored in local store.',
537
+ '# TYPE fleetmesh_events_stored_total counter',
538
+ ]
539
+ stored_count = 0
540
+ if hasattr(self.store, 'count'):
541
+ try:
542
+ stored_count = self.store.count()
543
+ except Exception:
544
+ pass
545
+ lines.append(f'fleetmesh_events_stored_total {stored_count}')
546
+
547
+ active_subs = 0
548
+ with self._lock:
549
+ active_conns = len(self._conns)
550
+ active_subs = sum(len(s) for s in self._subs.values())
551
+
552
+ lines.extend([
553
+ '# HELP fleetmesh_active_connections Current active WebSocket connections.',
554
+ '# TYPE fleetmesh_active_connections gauge',
555
+ f'fleetmesh_active_connections {active_conns}',
556
+ '# HELP fleetmesh_active_subscriptions Current active event subscriptions.',
557
+ '# TYPE fleetmesh_active_subscriptions gauge',
558
+ f'fleetmesh_active_subscriptions {active_subs}',
559
+ '# HELP fleetmesh_events_accepted_total Total valid events accepted through relay face.',
560
+ '# TYPE fleetmesh_events_accepted_total counter',
561
+ f'fleetmesh_events_accepted_total {self.accepted}',
562
+ '# HELP fleetmesh_events_refused_future_total Total events refused due to future timestamps.',
563
+ '# TYPE fleetmesh_events_refused_future_total counter',
564
+ f'fleetmesh_events_refused_future_total {self.refused_future}',
565
+ ])
566
+
567
+ if self.blob_store is not None:
568
+ blob_count = 0
569
+ blob_bytes = 0
570
+ if hasattr(self.blob_store, 'count'):
571
+ try:
572
+ blob_count = self.blob_store.count()
573
+ except Exception:
574
+ pass
575
+ if hasattr(self.blob_store, 'total_bytes'):
576
+ try:
577
+ blob_bytes = self.blob_store.total_bytes()
578
+ except Exception:
579
+ pass
580
+ lines.extend([
581
+ '# HELP fleetmesh_blobs_total Total content-addressed blobs in Blossom CAS.',
582
+ '# TYPE fleetmesh_blobs_total gauge',
583
+ f'fleetmesh_blobs_total {blob_count}',
584
+ '# HELP fleetmesh_blobs_bytes_total Total size in bytes of blobs in Blossom CAS.',
585
+ '# TYPE fleetmesh_blobs_bytes_total gauge',
586
+ f'fleetmesh_blobs_bytes_total {blob_bytes}',
587
+ ])
588
+
589
+ if self.grants is not None and hasattr(self.grants, 'list'):
590
+ # Export peers categorized by trust tier
591
+ try:
592
+ by_tier: dict[str, int] = {}
593
+ for g in self.grants.list():
594
+ payload = g.get('payload')
595
+ t = payload.get('tier') if isinstance(payload, dict) else g.get('tier', 'unknown')
596
+ by_tier[str(t)] = by_tier.get(str(t), 0) + 1
597
+ lines.append('# HELP fleetmesh_peers_by_tier Number of peers categorized by trust tier.')
598
+ lines.append('# TYPE fleetmesh_peers_by_tier gauge')
599
+ for t, cnt in sorted(by_tier.items()):
600
+ lines.append(f'fleetmesh_peers_by_tier{{tier="{t}"}} {cnt}')
601
+ except Exception:
602
+ pass
603
+
604
+ return '\n'.join(lines) + '\n'
605
+
606
+ def _http(self, conn: socket.socket, req_or_path: Any, headers: Optional[dict[bytes, bytes]] = None) -> None:
607
+ if isinstance(req_or_path, ws.PlainHTTP):
608
+ req = req_or_path
609
+ else:
610
+ path = str(req_or_path)
611
+ hdrs = headers or {}
612
+ req = ws.PlainHTTP(path=path, headers=hdrs, method='GET', sock=conn)
613
+
614
+ if not self.handle_http_request(conn, req):
615
+ ws.http_response(conn, '404 Not Found', b'{"error":"not found"}')
616
+
617
+ def gossip_view(self) -> GossipViewReport:
618
+ # gossip is an optional compartment: looked up on the node by name
619
+ # the way ctx.optional() does, absent when the node has not got one.
620
+ gossip = getattr(self.node, 'gossip', None) if self.node is not None else None
621
+ if gossip is None:
622
+ return {'enabled': False, 'topics': []}
623
+ rows = gossip.command('topics', {})
624
+ return {'enabled': True,
625
+ 'topics': [{'topic': r['topic'], 'admin': r['admin'], 'members': len(r['members']),
626
+ 'mesh': len(r['mesh']), 'expires': r['expires'], 'roster_id': r['roster_id']}
627
+ for r in rows]}
628
+
629
+ def nip11(self) -> NIP11RelayInformation:
630
+ from fleetmesh_core.identity import identity
631
+ doc: NIP11RelayInformation = {
632
+ 'name': self.name,
633
+ 'software': identity.FLEETMESH.rstrip('/'),
634
+ 'version': '0.0.1',
635
+ 'supported_nips': [1, 11, 40, 59, 77],
636
+ # What the face enforces, in NIP-11's own words, so a NIP-66
637
+ # event about it can carry the requirement tags honestly.
638
+ 'limitation': {
639
+ 'max_subscriptions': SUB_MAX,
640
+ 'max_message_length': EVENT_MAX_BYTES,
641
+ 'max_subid_length': SID_MAX,
642
+ 'max_limit': REQ_MAX,
643
+ 'auth_required': self.require_auth,
644
+ 'payment_required': False,
645
+ 'restricted_writes': False,
646
+ 'min_pow_difficulty': 0,
647
+ # NIP-11 states these as absolute timestamps, so it is computed
648
+ # rather than declared: a client reads the door this node has
649
+ # open right now. No lower limit is published because none is
650
+ # enforced - see FUTURE_MAX_SECONDS.
651
+ 'created_at_upper_limit': self.clock() + self.future_seconds,
652
+ },
653
+ }
654
+ if self.pubkey:
655
+ doc['pubkey'] = self.pubkey
656
+ return doc
@@ -0,0 +1,22 @@
1
+ """p2panda protocol suite for Fleetmesh.
2
+
3
+ Native implementation of the p2panda peer-to-peer data protocol:
4
+ - Canonical CBOR deterministic encoding (RFC 8949)
5
+ - Ed25519 author identity & cryptographic verification
6
+ - Append-only multi-writer operation log persistence
7
+ - Transport rung integration with TransportRouter ladder
8
+ """
9
+ from __future__ import annotations
10
+
11
+ from .identity import P2PandaIdentityAdapter
12
+ from .package import P2PandaProtocolPackage
13
+ from .rung import P2PandaRung
14
+ from .store import P2PandaLogStore, P2PandaStoreError
15
+
16
+ __all__ = [
17
+ 'P2PandaIdentityAdapter',
18
+ 'P2PandaLogStore',
19
+ 'P2PandaProtocolPackage',
20
+ 'P2PandaRung',
21
+ 'P2PandaStoreError',
22
+ ]