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,1144 @@
1
+ """One config block per compartment.
2
+
3
+ Each block is owned by the compartment that reads it and declared here so
4
+ NodeConfig can compose them and `Registry.assemble` can hand each compartment
5
+ its own, already checked. The fields are exactly the keys the compartment
6
+ reads today, with the defaults it already applied; nothing an operator has
7
+ written changes meaning. Seven of these blocks (announce, capacity, compute,
8
+ dvm, lan, transport, and most of bridge) had no default and no check before:
9
+ a misspelt key in them was silent.
10
+
11
+ Every block defaults to off or to zero: a node does nothing it was not asked
12
+ to, and charges nothing until a price is set.
13
+ """
14
+ from __future__ import annotations
15
+
16
+ import dataclasses
17
+ import re
18
+ from typing import Any, Mapping, MutableMapping, Optional, Union
19
+
20
+ from fleetmesh_core.services import dvm as _ref_dvm
21
+ from .base import (Block, Problems, all_int, all_str, is_hex64, is_http_url, is_relay_url, non_negative_int, opt,
22
+ positive_int)
23
+ from . import parameter as par
24
+ from .parameter import Parameter, ParamBlock
25
+
26
+ # --- bridge --------------------------------------------------------------
27
+
28
+ DEFAULT_INGRESS_KINDS: list[int] = [
29
+ 0, 1, 3, 6, 7, 1059, 1984, 1985,
30
+ 11801, 11802, 11803, 21801, 21802,
31
+ 30166, 30385, 30801, 30802, 30803, 30810, 30811,
32
+ ]
33
+ DEFAULT_EGRESS_KINDS: list[int] = list(DEFAULT_INGRESS_KINDS)
34
+ DEFAULT_INGRESS_FILTERS: list[Mapping[str, Any]] = [{'kinds': DEFAULT_INGRESS_KINDS}]
35
+ DEFAULT_RATE_LIMIT_PER_MIN: int = 1200
36
+ DEFAULT_MAX_FUTURE_SECONDS: int = 900
37
+ DEFAULT_MAX_EVENT_BYTES: int = 256 * 1024
38
+
39
+
40
+ @dataclasses.dataclass(frozen=True)
41
+ class BridgeIngress(Block):
42
+ enabled: bool = True
43
+ filters: tuple[Mapping[str, Any], ...] = tuple(DEFAULT_INGRESS_FILTERS)
44
+ rate_limit: int = DEFAULT_RATE_LIMIT_PER_MIN
45
+ max_future_seconds: int = DEFAULT_MAX_FUTURE_SECONDS
46
+ max_bytes: int = DEFAULT_MAX_EVENT_BYTES
47
+
48
+ @classmethod
49
+ def check(cls, raw: Any, p: Problems) -> None:
50
+ if not isinstance(raw, Mapping):
51
+ p.add('bridge.ingress must be an object'); return
52
+ opt(raw, 'enabled', lambda v: isinstance(v, bool), p, 'bridge.ingress.enabled must be a boolean')
53
+ opt(raw, 'filters', lambda v: isinstance(v, list) and all(isinstance(f, dict) for f in v), p,
54
+ 'bridge.ingress.filters must be a list of filter objects')
55
+ opt(raw, 'rate_limit', positive_int, p, 'bridge.ingress.rate_limit must be a positive integer')
56
+ opt(raw, 'max_future_seconds', non_negative_int, p, 'bridge.ingress.max_future_seconds must be a non-negative integer')
57
+ opt(raw, 'max_bytes', positive_int, p, 'bridge.ingress.max_bytes must be a positive integer')
58
+
59
+ @classmethod
60
+ def from_dict(cls, raw: Any) -> 'BridgeIngress':
61
+ return cls(enabled=bool(raw.get('enabled', True)), filters=tuple(raw.get('filters', DEFAULT_INGRESS_FILTERS)),
62
+ rate_limit=int(raw.get('rate_limit', DEFAULT_RATE_LIMIT_PER_MIN)),
63
+ max_future_seconds=int(raw.get('max_future_seconds', DEFAULT_MAX_FUTURE_SECONDS)),
64
+ max_bytes=int(raw.get('max_bytes', DEFAULT_MAX_EVENT_BYTES)))
65
+
66
+
67
+ @dataclasses.dataclass(frozen=True)
68
+ class BridgeEgress(Block):
69
+ enabled: bool = True
70
+ kinds: tuple[int, ...] = tuple(DEFAULT_EGRESS_KINDS)
71
+ auto: bool = True
72
+
73
+ @classmethod
74
+ def check(cls, raw: Any, p: Problems) -> None:
75
+ if not isinstance(raw, Mapping):
76
+ p.add('bridge.egress must be an object'); return
77
+ opt(raw, 'enabled', lambda v: isinstance(v, bool), p, 'bridge.egress.enabled must be a boolean')
78
+ opt(raw, 'kinds', all_int, p, 'bridge.egress.kinds must be a list of kind numbers')
79
+ opt(raw, 'auto', lambda v: isinstance(v, bool), p, 'bridge.egress.auto must be a boolean')
80
+
81
+ @classmethod
82
+ def from_dict(cls, raw: Any) -> 'BridgeEgress':
83
+ return cls(enabled=bool(raw.get('enabled', True)), kinds=tuple(raw.get('kinds', DEFAULT_EGRESS_KINDS)),
84
+ auto=bool(raw.get('auto', True)))
85
+
86
+
87
+ STRFRY_PLUGIN_REF = 'fleetmesh_strfry.plugin:StrfryBridgePlugin'
88
+ STRFRY_MOVED = ('bridge.strfry is no longer a part of the bridge: the strfry relay bridge is the '
89
+ 'fleetmesh-strfry plugin. Remove the block and add %r to "plugins" (its url and '
90
+ 'policy go under extensions.strfry), or run `python -m fleetmesh_strfry run` beside '
91
+ 'the node.' % STRFRY_PLUGIN_REF)
92
+
93
+
94
+ @dataclasses.dataclass(frozen=True)
95
+ class BridgeConfig(ParamBlock):
96
+ """The public-Nostr bridge: which relays, what comes in, what goes out."""
97
+ key = 'bridge'
98
+ DERIVED = False
99
+ enabled: bool = True
100
+ relays: Optional[tuple[str, ...]] = None # None: the node's relays
101
+ ingress: BridgeIngress = BridgeIngress()
102
+ egress: BridgeEgress = BridgeEgress()
103
+
104
+ PARAMS = (
105
+ Parameter[bool](
106
+ key='enabled', name='Public Nostr Relay Bridge',
107
+ description='Bridge external standard Nostr relays with the local fleetmesh node. On by default.',
108
+ type=bool, default=True, must='be a boolean', live=True,
109
+ tags=('bridge', 'relay'), sources=(par.ARG, par.ENV, par.FILE, par.MANIFEST)),
110
+ Parameter[Optional[list]](
111
+ key='relays', name='External Relay URLs',
112
+ description='Designated external standard Nostr relays (ws:// or wss://) to bridge with.',
113
+ type=list, default=None, must='be a list of ws:// or wss:// URLs', live=True,
114
+ tags=('bridge', 'relay'), sources=(par.ARG, par.ENV, par.FILE)),
115
+ )
116
+
117
+ @classmethod
118
+ def check(cls, raw: Any, p: Problems) -> None:
119
+ if not switch_or_object('bridge', raw, p):
120
+ return
121
+ opt(raw, 'enabled', lambda v: isinstance(v, bool), p, 'bridge.enabled must be a boolean')
122
+ opt(raw, 'relays', lambda v: isinstance(v, list) and all(is_relay_url(r) for r in v), p,
123
+ 'bridge.relays must be a list of ws:// or wss:// URLs')
124
+ for sub, blk in (('ingress', BridgeIngress), ('egress', BridgeEgress)):
125
+ if sub in raw:
126
+ blk.check(raw[sub], p)
127
+ if 'strfry' in raw:
128
+ p.add(STRFRY_MOVED)
129
+
130
+ @classmethod
131
+ def from_dict(cls, raw: Any) -> 'BridgeConfig':
132
+ if isinstance(raw, bool):
133
+ return cls(enabled=raw)
134
+ return cls(enabled=bool(raw.get('enabled', True)),
135
+ relays=tuple(raw['relays']) if raw.get('relays') is not None else None,
136
+ ingress=BridgeIngress.from_dict(raw.get('ingress') or {}),
137
+ egress=BridgeEgress.from_dict(raw.get('egress') or {}))
138
+
139
+
140
+ # --- the sidecar compartments -------------------------------------------
141
+
142
+ @dataclasses.dataclass(frozen=True)
143
+ class AnnounceConfig(ParamBlock):
144
+ """The libp2p gossip announce sidecar: off unless enabled."""
145
+ key = 'announce'
146
+ enabled: bool = False
147
+ socket: Optional[str] = None
148
+ pow_bits: int = 0
149
+
150
+ PARAMS = (
151
+ Parameter[bool](
152
+ key='enabled', name='Gossipsub announce',
153
+ description='Republish the descriptor on the gossipsub announce topic through the sidecar. '
154
+ 'Off unless enabled.',
155
+ type=bool, default=False, must='be a boolean', live=True,
156
+ tags=('announce', 'discovery'), sources=(par.ARG, par.ENV, par.FILE, par.MANIFEST)),
157
+ Parameter[Optional[str]](
158
+ key='socket', name='Sidecar socket path',
159
+ description='Path to the unix domain socket the announce sidecar listens on. '
160
+ 'Defaults to announce.sock in the node directory.',
161
+ type=str, default=None, must='be a path', live=True,
162
+ coerce=lambda v: str(v) if v is not None else None,
163
+ tags=('announce', 'sidecar'), sources=(par.ARG, par.ENV, par.FILE)),
164
+ Parameter[int](
165
+ key='pow_bits', name='Proof of work floor',
166
+ description='Minimum proof-of-work bits required for descriptors heard on the announce topic.',
167
+ type=int, default=0, minimum=0, maximum=40,
168
+ must='be an integer from 0 to 40', live=True,
169
+ coerce=lambda v: int(v) if isinstance(v, (int, str)) and str(v).lstrip('-').isdigit() else 0,
170
+ tags=('announce', 'pow'), sources=(par.ARG, par.ENV, par.FILE)),
171
+ )
172
+
173
+
174
+ @dataclasses.dataclass(frozen=True)
175
+ class LanConfig(ParamBlock):
176
+ """mDNS discovery on the local network: off unless enabled.
177
+
178
+ Note what this block does NOT do: presence does not turn it on. `p2p`
179
+ below looks identical and does. See ParamBlock.PRESENCE_ENABLES.
180
+ """
181
+ key = 'lan'
182
+ enabled: bool = False
183
+
184
+ PARAMS = (
185
+ Parameter[bool](
186
+ key='enabled', name='mDNS discovery',
187
+ description='Announce and discover peers over mDNS on the local network. Off '
188
+ 'unless set, and unlike p2p the presence of the block alone does '
189
+ 'not turn it on.',
190
+ type=bool, default=False, must='be a boolean', live=True,
191
+ tags=('lan', 'discovery'), sources=(par.ARG, par.ENV, par.FILE, par.MANIFEST)),
192
+ )
193
+
194
+
195
+ @dataclasses.dataclass(frozen=True)
196
+ class P2PConfig(ParamBlock):
197
+ """The libp2p transport sidecar. The key's presence turns it on; the
198
+ block may be `true` or an object.
199
+
200
+ `PRESENCE_ENABLES` is what that sentence means, and it is load-bearing:
201
+ the field default is False but `from_dict({})` yields True, because a
202
+ node that mentions p2p at all wants it. Before this the difference lived
203
+ only in the body of from_dict, where it looked like a typo.
204
+ """
205
+ key = 'p2p'
206
+ SWITCHABLE = True
207
+ PRESENCE_ENABLES = 'enabled'
208
+ enabled: bool = False
209
+
210
+ PARAMS = (
211
+ Parameter[bool](
212
+ key='enabled', name='libp2p transport',
213
+ description='Run the libp2p transport sidecar. True as soon as the block is '
214
+ 'present; write false to name the block and leave it off.',
215
+ type=bool, default=False, must='be a boolean',
216
+ tags=('p2p', 'transport'), sources=(par.ARG, par.ENV, par.FILE, par.MANIFEST)),
217
+ )
218
+
219
+
220
+ # --- capacity -------------------------------------------------------------
221
+
222
+ @dataclasses.dataclass(frozen=True)
223
+ class CapacityConfig(ParamBlock):
224
+ """What this node has to give, {resource: amount}; only positive numbers count.
225
+
226
+ `DERIVED = False`: the raw form is the mapping itself, with no field names
227
+ in the file at all, so `check` and `from_dict` stay hand-written. The
228
+ parameter below is declared anyway so the census is complete - a setting
229
+ `every_parameter()` cannot show is a setting an operator cannot find.
230
+ """
231
+ key = 'capacity'
232
+ DERIVED = False
233
+ total: dict[str, float] = dataclasses.field(default_factory=dict)
234
+
235
+ PARAMS = (
236
+ Parameter[dict](
237
+ key='total', name='Offered capacity',
238
+ description='What this node has to give, as {resource: amount}. The block is '
239
+ 'the mapping itself rather than an object containing one. Only '
240
+ 'positive numbers are kept.',
241
+ type=dict, default=dict, must='be an object of {resource: amount}',
242
+ # live: the Capacity compartment re-reads this on every tick, which
243
+ # is why it declares `live = ('capacity',)`. The derivation in
244
+ # Compartment.__init_subclass__ caught this parameter saying
245
+ # otherwise on the first run of the cross-check.
246
+ live=True, tags=('capacity', 'offer'),
247
+ sources=(par.ARG, par.ENV, par.FILE, par.MANIFEST)),
248
+ )
249
+
250
+ @classmethod
251
+ def check(cls, raw: Any, p: Problems) -> None:
252
+ if not isinstance(raw, Mapping):
253
+ p.add('capacity must be an object of {resource: amount}'); return
254
+ for k, v in raw.items():
255
+ if not isinstance(k, str) or isinstance(v, bool) or not isinstance(v, (int, float)):
256
+ p.add('capacity[%s] must be a number' % str(k)[:24])
257
+
258
+ @classmethod
259
+ def from_dict(cls, raw: Any) -> 'CapacityConfig':
260
+ return cls(total={k: v for k, v in (raw or {}).items() if isinstance(v, (int, float)) and v > 0})
261
+
262
+
263
+ @dataclasses.dataclass(frozen=True)
264
+ class GossipConfig(ParamBlock):
265
+ """Group-topic epidemic gossip (graft/prune/publish mesh). The key's
266
+ presence turns it on; the block may be `true` or an object.
267
+
268
+ All four defaults were written twice before this - once in the fields and
269
+ once in `from_dict` - which is the duplication `Parameter` exists to end.
270
+ """
271
+ key = 'gossip'
272
+ SWITCHABLE = True
273
+ PRESENCE_ENABLES = 'enabled'
274
+ enabled: bool = False
275
+ mesh_degree: int = 6
276
+ seen_ttl_seconds: int = 24 * 3600
277
+ max_topics: int = 64
278
+
279
+ # The ceilings the two settings below may be raised to, and no further.
280
+ # GossipMesh sizes its stores from these (node/stores: a bound is fixed
281
+ # on the class), so a config that asked for more topics than the store
282
+ # holds would be refused at the store with a message about the store,
283
+ # not the setting. Refused here instead, naming the setting.
284
+ TOPIC_CEILING = 1024
285
+ MESH_DEGREE_CEILING = 64
286
+
287
+ PARAMS = (
288
+ Parameter[bool](
289
+ key='enabled', name='Epidemic gossip',
290
+ description='Run the group-topic gossip mesh. True as soon as the block is '
291
+ 'present; write false to name the block and leave it off.',
292
+ type=bool, default=False, must='be a boolean',
293
+ tags=('gossip',), sources=(par.ARG, par.ENV, par.FILE, par.MANIFEST)),
294
+ Parameter[int](
295
+ key='mesh_degree', name='Mesh degree',
296
+ description='How many peers a topic mesh keeps grafted.',
297
+ type=int, default=6, minimum=1, maximum=MESH_DEGREE_CEILING,
298
+ must='be a positive integer, at most %d' % MESH_DEGREE_CEILING,
299
+ tags=('gossip', 'topology'), sources=(par.ARG, par.ENV, par.FILE, par.MANIFEST)),
300
+ Parameter[int](
301
+ key='seen_ttl_seconds', name='Seen TTL',
302
+ description='How long a seen event id is remembered, to suppress a re-flood.',
303
+ type=int, default=24 * 3600, minimum=1, must='be a positive integer',
304
+ # Not live: the value is handed to SeenStore at construction
305
+ # (gossip_wire/core.py), so a reload does not reach it. Claiming
306
+ # otherwise would be the report-what-you-do-not-do defect again.
307
+ tags=('gossip', 'retention'), sources=(par.ARG, par.ENV, par.FILE, par.MANIFEST)),
308
+ Parameter[int](
309
+ key='max_topics', name='Topic ceiling',
310
+ description='How many topics this node will carry at once.',
311
+ type=int, default=64, minimum=1, maximum=TOPIC_CEILING,
312
+ must='be a positive integer, at most %d' % TOPIC_CEILING,
313
+ tags=('gossip', 'capacity'), sources=(par.ARG, par.ENV, par.FILE, par.MANIFEST)),
314
+ )
315
+
316
+
317
+ # --- compute, dvm, settlement, mediation ----------------------------------
318
+
319
+ def switch_or_object(name: str, raw: Any, p: Problems) -> bool:
320
+ """An optional compartment is turned on by its key's presence, so the
321
+ block may be plain `true` (on, every default) as well as an object.
322
+ Returns whether `raw` is an object the caller should go on checking."""
323
+ if isinstance(raw, bool):
324
+ return False
325
+ if not isinstance(raw, Mapping):
326
+ p.add('%s must be true, false or an object' % name)
327
+ return False
328
+ return True
329
+
330
+
331
+ @dataclasses.dataclass(frozen=True)
332
+ class ComputeConfig(ParamBlock):
333
+ """The compute market. `mock_mode` runs the executors as stubs."""
334
+ key = 'compute'
335
+ SWITCHABLE = True
336
+ mock_mode: bool = False
337
+ zk_pinned_vks: tuple[str, ...] = () # Groth16 verification-key digests the operator pins
338
+
339
+ PARAMS = (
340
+ Parameter[bool](
341
+ key='mock_mode', name='Mock executors',
342
+ description='Run the compute executors as stubs instead of executing anything. '
343
+ 'A node in this state sells work it does not do, so it is a host '
344
+ 'decision and never a remote one.',
345
+ type=bool, default=False, must='be a boolean',
346
+ tags=('compute', 'testing'), sources=(par.ARG, par.ENV, par.FILE)),
347
+ Parameter[tuple](
348
+ key='zk_pinned_vks', name='Pinned verification keys',
349
+ description='Groth16 verification-key digests this operator pins. A proof '
350
+ 'verifying against an unpinned key is not accepted.',
351
+ type=tuple, item=str, default=(), must='be a list of 64-hex digests',
352
+ verify_regex=r'[0-9a-fA-F]{64}',
353
+ coerce=lambda v: tuple(str(x).lower() for x in v),
354
+ # A trust anchor: what this node will believe a proof against. Not
355
+ # from a manifest, for the reason updates.release_key is not.
356
+ tags=('compute', 'zk', 'trust'), sources=(par.ARG, par.ENV, par.FILE)),
357
+ )
358
+
359
+
360
+ DEFAULT_DVM_KINDS: list[int] = sorted({*_ref_dvm.DEFAULT_KINDS, _ref_dvm.TRANSCODE_KIND}) # fleetmesh_core/dvm.py owns the default + NIP-90 5201 transcode
361
+
362
+
363
+ @dataclasses.dataclass(frozen=True)
364
+ class DVMConfig(ParamBlock):
365
+ """NIP-90 data-vending: which kinds, what a mega-fuel costs, the fuel cap."""
366
+ key = 'dvm'
367
+ DERIVED = False
368
+ enabled: bool = True
369
+ supported_kinds: tuple[int, ...] = tuple(DEFAULT_DVM_KINDS)
370
+ rate_msat_per_mfuel: int = 45
371
+ max_fuel: int = 50_000_000_000
372
+ blossom_servers: tuple[str, ...] = ()
373
+ delegate_to_mesh: bool = False
374
+
375
+ PARAMS = (
376
+ Parameter[bool](
377
+ key='enabled', name='DVM Gateway',
378
+ description='Enable the NIP-90 Data Vending Machine gateway.',
379
+ type=bool, default=True, must='be a boolean', live=True,
380
+ tags=('dvm', 'gateway'), sources=(par.ARG, par.ENV, par.FILE, par.MANIFEST)),
381
+ Parameter[tuple](
382
+ key='supported_kinds', name='Supported DVM Kinds',
383
+ description='NIP-90 job request kinds this DVM node handles.',
384
+ type=tuple, default=tuple, must='be a list of integer kind numbers', live=True,
385
+ tags=('dvm', 'kinds'), sources=(par.ARG, par.ENV, par.FILE)),
386
+ Parameter[int](
387
+ key='rate_msat_per_mfuel', name='Rate msat per Mfuel',
388
+ description='Pricing in millisatoshis per mega-fuel unit.',
389
+ type=int, default=45, must='be a non-negative integer', live=True,
390
+ tags=('dvm', 'pricing'), sources=(par.ARG, par.ENV, par.FILE)),
391
+ Parameter[int](
392
+ key='max_fuel', name='Maximum Fuel Limit',
393
+ description='Upper bound on fuel consumption per job.',
394
+ type=int, default=50_000_000_000, must='be a positive integer', live=True,
395
+ tags=('dvm', 'limits'), sources=(par.ARG, par.ENV, par.FILE)),
396
+ Parameter[tuple](
397
+ key='blossom_servers', name='Blossom Servers',
398
+ description='Blossom servers used for DVM input/output blob transfers.',
399
+ type=tuple, default=tuple, must='be a list of http(s) URLs', live=True,
400
+ tags=('dvm', 'blossom'), sources=(par.ARG, par.ENV, par.FILE)),
401
+ Parameter[bool](
402
+ key='delegate_to_mesh', name='Delegate to Mesh',
403
+ description='Whether to delegate requests to mesh compute order book.',
404
+ type=bool, default=False, must='be a boolean', live=True,
405
+ tags=('dvm', 'delegation'), sources=(par.ARG, par.ENV, par.FILE)),
406
+ )
407
+
408
+ @classmethod
409
+ def check(cls, raw: Any, p: Problems) -> None:
410
+ if not switch_or_object('dvm', raw, p):
411
+ return
412
+ opt(raw, 'enabled', lambda v: isinstance(v, bool), p, 'dvm.enabled must be a boolean')
413
+ opt(raw, 'delegate_to_mesh', lambda v: isinstance(v, bool), p, 'dvm.delegate_to_mesh must be a boolean')
414
+ opt(raw, 'supported_kinds', all_int, p, 'dvm.supported_kinds must be a list of kind numbers')
415
+ opt(raw, 'rate_msat_per_mfuel', non_negative_int, p, 'dvm.rate_msat_per_mfuel must be a non-negative integer')
416
+ opt(raw, 'max_fuel', positive_int, p, 'dvm.max_fuel must be a positive integer')
417
+ opt(raw, 'blossom_servers', lambda v: isinstance(v, list) and all(is_http_url(s) for s in v), p,
418
+ 'dvm.blossom_servers must be a list of http(s) URLs')
419
+
420
+ @classmethod
421
+ def from_dict(cls, raw: Any) -> 'DVMConfig':
422
+ if isinstance(raw, bool):
423
+ return cls()
424
+ return cls(enabled=bool(raw.get('enabled', True)), supported_kinds=tuple(raw.get('supported_kinds', DEFAULT_DVM_KINDS)),
425
+ rate_msat_per_mfuel=int(raw.get('rate_msat_per_mfuel', 45)), max_fuel=int(raw.get('max_fuel', 50_000_000_000)),
426
+ blossom_servers=tuple(raw.get('blossom_servers') or ()),
427
+ delegate_to_mesh=bool(raw.get('delegate_to_mesh', False)))
428
+
429
+
430
+ @dataclasses.dataclass(frozen=True)
431
+ class NsiteConfig(ParamBlock):
432
+ """NIP-5A static-site gateway: which hostnames are sites, where blobs
433
+ come from when the author's servers are silent, how much to cache, and
434
+ whether a custom domain must be claimed in the manifest.
435
+
436
+ `suffixes`: hostnames under which the left-most label is a site
437
+ (`npub1….nsite.example.org`). `custom_domains`: serve a domain whose
438
+ `_nsite.<domain>` TXT names a site. `require_domain_tag`: that site's
439
+ manifest must also carry `["domain", "<domain>"]` - on by default so a
440
+ DNS record alone cannot front someone else's site. `allow_domains` turns
441
+ the gateway closed (only these), `deny_domains` blocks named ones.
442
+ Quotas are per cached blob, per site and for the whole cache.
443
+ """
444
+ key = 'nsite'
445
+ DERIVED = False
446
+ enabled: bool = True
447
+ suffixes: tuple[str, ...] = ()
448
+ custom_domains: bool = True
449
+ require_domain_tag: bool = True
450
+ allow_domains: tuple[str, ...] = ()
451
+ deny_domains: tuple[str, ...] = ()
452
+ fallback_servers: tuple[str, ...] = ()
453
+ relays: tuple[str, ...] = ()
454
+ allow_legacy_kind: bool = False
455
+ frame_ancestors: tuple[str, ...] = ()
456
+ cross_origin_isolation: bool = False
457
+ max_blob_bytes: int = 16 * 1024 * 1024
458
+ max_site_bytes: int = 256 * 1024 * 1024
459
+ max_cache_bytes: int = 4 * 1024 * 1024 * 1024
460
+ manifest_ttl_seconds: int = 300
461
+ domain_ttl_seconds: int = 6 * 3600
462
+ fetch_timeout: float = 10.0
463
+
464
+ PARAMS = (
465
+ Parameter[bool](
466
+ key='enabled', name='Nsite Gateway',
467
+ description='Enable NIP-5A static-site hosting gateway.',
468
+ type=bool, default=True, must='be a boolean', live=True,
469
+ tags=('nsite', 'gateway'), sources=(par.ARG, par.ENV, par.FILE, par.MANIFEST)),
470
+ Parameter[tuple](
471
+ key='suffixes', name='Gateway Hostname Suffixes',
472
+ description='Host suffixes under which subdomains are routed to nsite static sites.',
473
+ type=tuple, default=tuple, must='be a list of host names', live=True,
474
+ tags=('nsite', 'routing'), sources=(par.ARG, par.ENV, par.FILE)),
475
+ Parameter[bool](
476
+ key='custom_domains', name='Custom Domains Support',
477
+ description='Allow custom domains via CNAME and _nsite DNS TXT records.',
478
+ type=bool, default=True, must='be a boolean', live=True,
479
+ tags=('nsite', 'dns'), sources=(par.ARG, par.ENV, par.FILE)),
480
+ Parameter[bool](
481
+ key='require_domain_tag', name='Require Domain Tag in Manifest',
482
+ description='Require site manifest to carry a matching domain tag to bind.',
483
+ type=bool, default=True, must='be a boolean', live=True,
484
+ tags=('nsite', 'security'), sources=(par.ARG, par.ENV, par.FILE)),
485
+ )
486
+
487
+ @classmethod
488
+ def check(cls, raw: Any, p: Problems) -> None:
489
+ if not switch_or_object('nsite', raw, p):
490
+ return
491
+ for k in ('enabled', 'custom_domains', 'require_domain_tag', 'allow_legacy_kind', 'cross_origin_isolation'):
492
+ opt(raw, k, lambda v: isinstance(v, bool), p, 'nsite.%s must be a boolean' % k)
493
+ for k in ('suffixes', 'allow_domains', 'deny_domains', 'frame_ancestors'):
494
+ opt(raw, k, all_str, p, 'nsite.%s must be a list of host names' % k)
495
+ opt(raw, 'fallback_servers', lambda v: isinstance(v, list) and all(is_http_url(s) for s in v), p,
496
+ 'nsite.fallback_servers must be a list of http(s) URLs')
497
+ opt(raw, 'relays', lambda v: isinstance(v, list) and all(is_relay_url(s) for s in v), p,
498
+ 'nsite.relays must be a list of ws(s) URLs')
499
+ for k in ('max_blob_bytes', 'max_site_bytes', 'max_cache_bytes', 'manifest_ttl_seconds', 'domain_ttl_seconds'):
500
+ opt(raw, k, positive_int, p, 'nsite.%s must be a positive integer' % k)
501
+ opt(raw, 'fetch_timeout', lambda v: isinstance(v, (int, float)) and not isinstance(v, bool) and v > 0, p,
502
+ 'nsite.fetch_timeout must be a positive number of seconds')
503
+ if isinstance(raw, dict) and raw.get('enabled', True) and not raw.get('suffixes') and not raw.get('custom_domains', True):
504
+ p.add('nsite: with no suffixes and custom_domains off the gateway can serve nothing')
505
+
506
+ @classmethod
507
+ def from_dict(cls, raw: Any) -> 'NsiteConfig':
508
+ if isinstance(raw, bool):
509
+ return cls()
510
+ g = lambda k, d: raw.get(k, d) # noqa: E731
511
+ return cls(enabled=bool(g('enabled', True)), suffixes=tuple(g('suffixes', ()) or ()),
512
+ custom_domains=bool(g('custom_domains', True)), require_domain_tag=bool(g('require_domain_tag', True)),
513
+ allow_domains=tuple(g('allow_domains', ()) or ()), deny_domains=tuple(g('deny_domains', ()) or ()),
514
+ fallback_servers=tuple(g('fallback_servers', ()) or ()), relays=tuple(g('relays', ()) or ()),
515
+ allow_legacy_kind=bool(g('allow_legacy_kind', False)),
516
+ frame_ancestors=tuple(g('frame_ancestors', ()) or ()),
517
+ cross_origin_isolation=bool(g('cross_origin_isolation', False)),
518
+ max_blob_bytes=int(g('max_blob_bytes', cls.max_blob_bytes)),
519
+ max_site_bytes=int(g('max_site_bytes', cls.max_site_bytes)),
520
+ max_cache_bytes=int(g('max_cache_bytes', cls.max_cache_bytes)),
521
+ manifest_ttl_seconds=int(g('manifest_ttl_seconds', cls.manifest_ttl_seconds)),
522
+ domain_ttl_seconds=int(g('domain_ttl_seconds', cls.domain_ttl_seconds)),
523
+ fetch_timeout=float(g('fetch_timeout', cls.fetch_timeout)))
524
+
525
+
526
+ @dataclasses.dataclass(frozen=True)
527
+ class SettlementConfig(ParamBlock):
528
+ """How compute is paid for: a NWC wallet, or a live Cashu mint."""
529
+ key = 'settlement'
530
+ nwc_uri: Optional[str] = None
531
+ live_cashu: bool = False
532
+ mint_url: Optional[str] = None # None: payment.DEFAULT_MINT_URL
533
+
534
+ PARAMS = (
535
+ Parameter[Optional[str]](
536
+ key='nwc_uri', name='Wallet connection',
537
+ description='A Nostr Wallet Connect URI this node pays and is paid through.',
538
+ type=str, default=None, must='be a nostr+walletconnect:// URI',
539
+ verify_regex=r'nostr\+walletconnect://.+',
540
+ # A credential. `secret` keeps it out of every report and every
541
+ # audit row; host-only sources keep it out of anything that could
542
+ # set it remotely.
543
+ secret=True, tags=('settlement', 'money', 'credential'), sources=(par.ARG, par.ENV, par.FILE)),
544
+ Parameter[bool](
545
+ key='live_cashu', name='Live Cashu mint',
546
+ description='Use a real Cashu mint rather than the offline token path. Real '
547
+ 'money moves when this is on.',
548
+ type=bool, default=False, must='be a boolean',
549
+ tags=('settlement', 'money'), sources=(par.ARG, par.ENV, par.FILE)),
550
+ Parameter[Optional[str]](
551
+ key='mint_url', name='Mint URL',
552
+ description='The Cashu mint to use. Unset means payment.DEFAULT_MINT_URL.',
553
+ type=str, default=None, must='be an http(s) URL',
554
+ verify_regex=r'https?://.+',
555
+ tags=('settlement', 'money'), sources=(par.ARG, par.ENV, par.FILE)),
556
+ )
557
+
558
+
559
+ @dataclasses.dataclass(frozen=True)
560
+ class MediationConfig(ParamBlock):
561
+ """The mediator / leaf-phone compartment. Reserved: no operator keys yet.
562
+
563
+ An empty PARAMS is the correct and final answer here, not work left
564
+ over: `converted()` asks whether a block states its settings as
565
+ parameters, not whether it has any.
566
+ """
567
+ key = 'mediation'
568
+
569
+
570
+ @dataclasses.dataclass(frozen=True)
571
+ class TransportConfig(ParamBlock):
572
+ """compute-wire transport preferences. Reserved: no operator keys yet."""
573
+ key = 'transport'
574
+
575
+
576
+ # --- blobs ------------------------------------------------------------------
577
+
578
+ BLOB_WIRE_CAPS: tuple[str, ...] = ('bytes_per_day', 'max_concurrent', 'max_blob_bytes', 'max_total_bytes')
579
+
580
+
581
+ @dataclasses.dataclass(frozen=True)
582
+ class BlobWireConfig(ParamBlock):
583
+ """blob/1.0: who this node accepts offers from, the chunk size, the caps.
584
+
585
+ `accept_from` is `none`, `grantees`, or a list of pubkeys. The caps are
586
+ per peer and default to the window constants (node/blob_wire.py reads
587
+ them from window.C, so they are None here and resolved there): zero is
588
+ "no bytes", not "unlimited".
589
+ """
590
+ key = 'blob_wire'
591
+ accept_from: Union[str, tuple[str, ...]] = 'none'
592
+ chunk_bytes: Optional[int] = None
593
+ bytes_per_day: Optional[int] = None
594
+ max_concurrent: Optional[int] = None
595
+ max_blob_bytes: Optional[int] = None
596
+ max_total_bytes: Optional[int] = None
597
+ DERIVED: bool = False
598
+ PARAMS = (
599
+ Parameter(key='accept_from', name='Accept From', description='Whom to accept offers from', type=str, default='none', tags=('blob', 'wire')),
600
+ Parameter(key='chunk_bytes', name='Chunk Bytes', description='Transfer chunk size', type=int, default=None, tags=('blob', 'wire')),
601
+ )
602
+
603
+ @classmethod
604
+ def check(cls, raw: Any, p: Problems) -> None:
605
+ if not isinstance(raw, Mapping):
606
+ p.add('blob_wire must be an object'); return
607
+ opt(raw, 'accept_from', lambda v: v in ('none', 'grantees') or (isinstance(v, list) and all(is_hex64(x) for x in v)),
608
+ p, 'blob_wire.accept_from must be none, grantees, or a list of 64-hex pubkeys')
609
+ opt(raw, 'chunk_bytes', positive_int, p, 'blob_wire.chunk_bytes must be a positive integer')
610
+ for k in BLOB_WIRE_CAPS:
611
+ opt(raw, k, non_negative_int, p, 'blob_wire.%s must be a non-negative integer' % k)
612
+
613
+ @classmethod
614
+ def from_dict(cls, raw: Any) -> 'BlobWireConfig':
615
+ af = raw.get('accept_from', 'none')
616
+ return cls(accept_from=tuple(str(x).lower() for x in af) if isinstance(af, list) else af,
617
+ chunk_bytes=raw.get('chunk_bytes'), **{k: raw.get(k) for k in BLOB_WIRE_CAPS})
618
+
619
+
620
+ @dataclasses.dataclass(frozen=True)
621
+ class BlobReplicationConfig(ParamBlock):
622
+ """How many copies of each blob to keep across partners, and where from."""
623
+ key = 'blob_replication'
624
+ copies: int = 2
625
+ allow_private: Union[bool, tuple[str, ...]] = False # true, or the hosts a LAN cluster allows
626
+ source_url: Optional[str] = None
627
+ servers: tuple[str, ...] = ()
628
+ DERIVED: bool = False
629
+ PARAMS = (
630
+ Parameter(key='copies', name='Target Copies', description='Target copy count across network', type=int, default=2, tags=('blob', 'replication')),
631
+ Parameter(key='allow_private', name='Allow Private', description='Allow private LAN addresses', type=bool, default=False, tags=('blob', 'replication')),
632
+ )
633
+
634
+ @classmethod
635
+ def check(cls, raw: Any, p: Problems) -> None:
636
+ if not isinstance(raw, Mapping):
637
+ p.add('blob_replication must be an object'); return
638
+ opt(raw, 'copies', non_negative_int, p, 'blob_replication.copies must be a non-negative integer')
639
+ opt(raw, 'allow_private', lambda v: isinstance(v, bool) or all_str(v), p,
640
+ 'blob_replication.allow_private must be a boolean or a list of hosts')
641
+ opt(raw, 'source_url', is_http_url, p, 'blob_replication.source_url must be an http(s) URL')
642
+ opt(raw, 'servers', all_str, p, 'blob_replication.servers must be a list of URLs')
643
+
644
+ @classmethod
645
+ def from_dict(cls, raw: Any) -> 'BlobReplicationConfig':
646
+ ap = raw.get('allow_private', False)
647
+ return cls(copies=int(raw.get('copies', 2)), allow_private=tuple(ap) if isinstance(ap, list) else bool(ap),
648
+ source_url=raw.get('source_url'), servers=tuple(raw.get('servers') or ()))
649
+
650
+
651
+ @dataclasses.dataclass(frozen=True)
652
+ class StorageConfig(Block):
653
+ """The MeshNode manifest's storage block, as the node reads it."""
654
+ key = 'storage'
655
+ max_bytes: Optional[int] = None
656
+ allow_private_mirror: Union[bool, tuple[str, ...]] = False # true, or the LAN hosts a /mirror may read from
657
+
658
+ @classmethod
659
+ def check(cls, raw: Any, p: Problems) -> None:
660
+ if not isinstance(raw, Mapping):
661
+ p.add('storage must be an object'); return
662
+ if 'maxBytes' in raw and raw['maxBytes'] not in (None, ''):
663
+ try:
664
+ int(raw['maxBytes'])
665
+ except (TypeError, ValueError):
666
+ p.add('blob storage cap is not an integer: %r' % (raw['maxBytes'],))
667
+ opt(raw, 'allowPrivateMirror', lambda v: isinstance(v, bool) or all_str(v), p,
668
+ 'storage.allowPrivateMirror must be a boolean or a list of hosts')
669
+
670
+ @classmethod
671
+ def from_dict(cls, raw: Any) -> 'StorageConfig':
672
+ mb = raw.get('maxBytes')
673
+ cap = int(mb) if mb not in (None, '') else 0
674
+ ap = raw.get('allowPrivateMirror', False)
675
+ return cls(max_bytes=cap if cap > 0 else None,
676
+ allow_private_mirror=tuple(ap) if isinstance(ap, list) else bool(ap))
677
+
678
+
679
+ @dataclasses.dataclass(frozen=True)
680
+ class BlobsConfig(Block):
681
+ """The node's own blob store: cap (wins over storage.maxBytes) and on-disk layout.
682
+
683
+ ``fanout`` (0..8, default 1) and ``data_dir`` (default ``<node>/blobs/data``)
684
+ let an operator share a sha256 tree with another CAS laid out the same way
685
+ (magpie: its ``objects/`` at fanout 2). Both are one-way doors: the store
686
+ records them on first open and refuses a later change.
687
+ """
688
+ key = 'blobs'
689
+ max_storage_bytes: Optional[int] = None
690
+ fanout: int = 1
691
+ data_dir: Optional[str] = None
692
+
693
+ @classmethod
694
+ def check(cls, raw: Any, p: Problems) -> None:
695
+ if not isinstance(raw, Mapping):
696
+ p.add('blobs must be an object'); return
697
+ if 'max_storage_bytes' in raw and raw['max_storage_bytes'] not in (None, ''):
698
+ try:
699
+ int(raw['max_storage_bytes'])
700
+ except (TypeError, ValueError):
701
+ p.add('blob storage cap is not an integer: %r' % (raw['max_storage_bytes'],))
702
+ if 'fanout' in raw and raw['fanout'] not in (None, ''):
703
+ f = raw['fanout']
704
+ if isinstance(f, bool) or not isinstance(f, int) or not 0 <= f <= 8:
705
+ p.add('blobs.fanout must be an integer 0..8: %r' % (f,))
706
+ if 'data_dir' in raw and raw['data_dir'] not in (None, '') and not isinstance(raw['data_dir'], str):
707
+ p.add('blobs.data_dir must be a path string: %r' % (raw['data_dir'],))
708
+
709
+ @classmethod
710
+ def from_dict(cls, raw: Any) -> 'BlobsConfig':
711
+ v = raw.get('max_storage_bytes')
712
+ cap = int(v) if v not in (None, '') else 0
713
+ f = raw.get('fanout')
714
+ d = raw.get('data_dir')
715
+ return cls(max_storage_bytes=cap if cap > 0 else None,
716
+ fanout=int(f) if f not in (None, '') else 1,
717
+ data_dir=str(d) if d not in (None, '') else None)
718
+
719
+
720
+ # --- the external engines ------------------------------------------------
721
+
722
+ _PLUGIN_REF = re.compile(r'^[A-Za-z_][A-Za-z0-9_]*(\.[A-Za-z_][A-Za-z0-9_]*)*:[A-Za-z_][A-Za-z0-9_]*$')
723
+
724
+
725
+ @dataclasses.dataclass(frozen=True)
726
+ class PluginSpec:
727
+ """One out-of-tree compartment: `ref` is `package.module:ClassName`;
728
+ `after` names the manifest compartment it ticks right after (default: last)."""
729
+ ref: str
730
+ after: Optional[str] = None
731
+
732
+
733
+ @dataclasses.dataclass(frozen=True)
734
+ class PluginsConfig(Block):
735
+ """Out-of-tree compartments loaded into the manifest at startup.
736
+
737
+ "plugins": [{"ref": "fleetmesh_foo.node:Foo", "after": "budget"}]
738
+
739
+ A plugin is operator-trusted code: it is imported into the node's
740
+ process and sees everything a core compartment sees, keys included.
741
+ Installing one is the same act of trust as editing node/app/core.py;
742
+ the config block exists so that act is declared in one file an operator
743
+ reads, not hidden in a site-packages directory. Untrusted or
744
+ third-party extensions belong out of process (a sidecar) - see
745
+ docs/extending.md. Each plugin reads its own config under
746
+ `extensions.<name>`.
747
+ """
748
+ key = 'plugins'
749
+ specs: tuple[PluginSpec, ...] = ()
750
+
751
+ @classmethod
752
+ def check(cls, raw: Any, p: Problems) -> None:
753
+ if not isinstance(raw, list):
754
+ p.add('plugins must be a list of {ref, after?}'); return
755
+ for i, e in enumerate(raw):
756
+ if isinstance(e, str):
757
+ e = {'ref': e}
758
+ if not isinstance(e, Mapping) or not isinstance(e.get('ref'), str):
759
+ p.add('plugins[%d] needs a string ref "package.module:Class"' % i); continue
760
+ if not _PLUGIN_REF.match(e['ref']):
761
+ p.add('plugins[%d].ref is not package.module:Class: %r' % (i, e['ref']))
762
+ if 'after' in e and e['after'] is not None and not isinstance(e['after'], str):
763
+ p.add('plugins[%d].after must be a compartment name' % i)
764
+
765
+ @classmethod
766
+ def from_dict(cls, raw: Any) -> 'PluginsConfig':
767
+ specs = []
768
+ for e in raw or ():
769
+ if isinstance(e, str):
770
+ e = {'ref': e}
771
+ specs.append(PluginSpec(ref=e['ref'], after=e.get('after')))
772
+ return cls(specs=tuple(specs))
773
+
774
+
775
+ @dataclasses.dataclass(frozen=True)
776
+ class SidecarEntry:
777
+ """One sidecar an operator runs: on only with an endpoint. `manifest` is
778
+ where its description comes from - absent for a built-in (grasp,
779
+ ncontainer, magpie: the node ships those), `@/path/file.json` or an
780
+ inline object for anyone else's."""
781
+ enabled: bool = False
782
+ endpoint: Optional[str] = None
783
+ manifest: Any = None # None | '@/path.json' | dict
784
+
785
+ @property
786
+ def on(self) -> bool:
787
+ return bool(self.enabled and self.endpoint)
788
+
789
+
790
+ # What `grasp:`/`ncontainer:`/`magpie:` were before they moved under `sidecars`.
791
+ SIDECAR_KEYS_MOVED = ('grasp', 'ncontainer', 'magpie')
792
+ SIDECAR_MOVED = ('%s is now an entry of the "sidecars" block: '
793
+ '"sidecars": {"%s": {"enabled": true, "endpoint": "..."}}. The old top-level key is read for '
794
+ 'this release and refused in the next.')
795
+ _SIDECAR_NAME = re.compile(r'^[a-z][a-z0-9_-]{0,31}$')
796
+
797
+
798
+ @dataclasses.dataclass(frozen=True)
799
+ class SidecarsConfig(ParamBlock):
800
+ """The services this node fronts, by name. Each is declared on the
801
+ descriptor only after its manifest's self-tests pass (node/sidecars.py).
802
+
803
+ "sidecars": {
804
+ "grasp": {"enabled": true, "endpoint": "http://127.0.0.1:9000"},
805
+ "myhost": {"enabled": true, "endpoint": "http://127.0.0.1:9300",
806
+ "manifest": "@/etc/fleetmesh/myhost.sidecar.json"}
807
+ }
808
+ """
809
+ key = 'sidecars'
810
+ entries: Mapping[str, SidecarEntry] = dataclasses.field(default_factory=dict)
811
+ DERIVED: bool = False
812
+ PARAMS = (
813
+ Parameter(key='entries', name='Sidecar Entries', description='Configured sidecar services', type=dict, default={}, tags=('sidecars', 'services')),
814
+ )
815
+
816
+ @classmethod
817
+ def check(cls, raw: Any, p: Problems) -> None:
818
+ if not isinstance(raw, Mapping):
819
+ p.add('sidecars must be an object of {name: {enabled, endpoint[, manifest]}}'); return
820
+ for name, e in raw.items():
821
+ if not isinstance(name, str) or not _SIDECAR_NAME.match(name):
822
+ p.add('sidecars: %r is not a sidecar name (short lowercase identifier)' % (name,)); continue
823
+ if not isinstance(e, Mapping):
824
+ p.add('sidecars.%s must be an object' % name); continue
825
+ opt(e, 'enabled', lambda v: isinstance(v, bool), p, 'sidecars.%s.enabled must be a boolean' % name)
826
+ opt(e, 'endpoint', is_http_url, p, 'sidecars.%s.endpoint must be an http(s) URL' % name)
827
+ if e.get('enabled') and not e.get('endpoint'):
828
+ p.add('sidecars.%s.enabled needs sidecars.%s.endpoint' % (name, name))
829
+ m = e.get('manifest')
830
+ if m is not None and not (isinstance(m, Mapping) or (isinstance(m, str) and m.startswith('@'))):
831
+ p.add('sidecars.%s.manifest must be an inline object or "@/path/to/manifest.json"' % name)
832
+ extra = set(e) - {'enabled', 'endpoint', 'manifest'}
833
+ if extra:
834
+ p.add('sidecars.%s has unknown keys: %s' % (name, ', '.join(sorted(extra))))
835
+
836
+ @classmethod
837
+ def from_dict(cls, raw: Any) -> 'SidecarsConfig':
838
+ out = {}
839
+ for name, e in (raw or {}).items():
840
+ e = e or {}
841
+ out[name] = SidecarEntry(enabled=bool(e.get('enabled', False)), endpoint=e.get('endpoint'),
842
+ manifest=e.get('manifest'))
843
+ return cls(entries=out)
844
+
845
+ def to_dict(self) -> Mapping[str, Any]:
846
+ d: dict[str, Mapping[str, Any]] = {}
847
+ for name, e in self.entries.items():
848
+ row: MutableMapping[str, Any] = {'enabled': e.enabled}
849
+ if e.endpoint is not None:
850
+ row['endpoint'] = e.endpoint
851
+ if e.manifest is not None:
852
+ row['manifest'] = e.manifest
853
+ d[name] = row
854
+ return d
855
+
856
+
857
+ def sidecars_of(cfg: Mapping[str, Any]) -> dict[str, Mapping[str, Any]]:
858
+ """The raw `sidecars` entries, with the moved top-level keys folded in
859
+ for this release. One place reads both shapes; everything else sees
860
+ the new one."""
861
+ out: dict[str, Mapping[str, Any]] = dict(cfg.get('sidecars') or {})
862
+ for k in SIDECAR_KEYS_MOVED:
863
+ if k in cfg and k not in out and isinstance(cfg[k], Mapping):
864
+ out[k] = cfg[k]
865
+ return out
866
+
867
+
868
+ # --- updates: check-and-report ------------------------------------------------
869
+
870
+ # The project's release signer: npub1dxlsl0y...4xs7uy, the identity that signs
871
+ # everything fleetmesh publishes on nostr (OpenBao fleetmesh/publisher; release.sh
872
+ # stamps it into every manifest it signs). The committed manifests once carried
873
+ # the FIXTURE key from fixtures/npack/ instead, and a node that trusted that
874
+ # would have refused every genuine release - test_updates pins this to the
875
+ # packaged manifests so the three cannot drift apart again.
876
+ #
877
+ # This is the fallback, not the default. The default `updates.release_key` is
878
+ # "the key that made me": the publisher stamped into the tree by release.sh
879
+ # (node/PUBLISHER) or, in a checkout, the one in packages/fleetmesh-node's
880
+ # manifest - see default_release_key(). A fork's build therefore listens to the
881
+ # fork's signer without anyone editing a config. The operator can always write
882
+ # another key; it is a plain config value and state() shows which is in effect.
883
+ PROJECT_RELEASE_KEY: str = '69bf0fbc8211488fc321bee5bfa07bae184a881b0b16c3fb6e13a4b1dda28124'
884
+
885
+
886
+ def default_release_key() -> str:
887
+ from ..release import running
888
+ return running().publisher or PROJECT_RELEASE_KEY
889
+ DEFAULT_CHECK_EVERY_SECONDS: int = 21600
890
+ UPDATE_POLICIES: tuple[str, ...] = ('pinned',) # 'track:patch' / 'track:minor' arrive with the apply tier
891
+ TRACK_NOT_YET: str = ("updates.policy: %r is not available yet; this release checks and reports. "
892
+ "Use 'pinned'")
893
+
894
+
895
+ @dataclasses.dataclass(frozen=True)
896
+ class UpdatesConfig(ParamBlock):
897
+ """Check-and-report against an operator-pinned release key. See docs/updates.md.
898
+
899
+ The first compartment converted to `Parameter` (node/config/parameter.py).
900
+ Each setting is declared once in `PARAMS`; `check` and `from_dict` are
901
+ derived from it, and the four sentences an operator can be told are the
902
+ four `must` clauses below. Nothing here states a default twice -
903
+ `check_every` used to state `DEFAULT_CHECK_EVERY_SECONDS` in the field and
904
+ again in `from_dict`.
905
+ """
906
+ key = 'updates'
907
+ SWITCHABLE = True
908
+
909
+ release_key: str = dataclasses.field(default_factory=default_release_key)
910
+ channel: tuple[str, ...] = ()
911
+ check_every: int = DEFAULT_CHECK_EVERY_SECONDS
912
+ policy: Mapping[str, str] = dataclasses.field(default_factory=lambda: {'*': 'pinned'})
913
+ pinned: Optional[str] = None
914
+
915
+ PARAMS = (
916
+ Parameter[str](
917
+ key='release_key', name='Release signing key',
918
+ description='Who may sign a release this node will mention. Defaults to the key '
919
+ 'that signed the release this tree came from.',
920
+ type=str, default=default_release_key, must='be a 64-hex pubkey',
921
+ verify_regex=r'[0-9a-fA-F]{64}', coerce=lambda v: str(v).lower(),
922
+ tags=('updates', 'trust'), env='FLEETMESH_RELEASE_KEY',
923
+ # The key that decides which releases this node will mention, so
924
+ # the host and nobody else. No ability is named, which means no
925
+ # delegation can ever carry it; and no manifest, because a fleet
926
+ # declaration reaching the release-signing key is a supply chain
927
+ # with one more link in it than anyone intended.
928
+ sources=(par.ARG, par.ENV, par.FILE)),
929
+ Parameter[tuple](
930
+ key='channel', name='Release channel relays',
931
+ description='Relays the kind 9900 release events live on. Empty means the '
932
+ "node's own relays.",
933
+ type=tuple, item=str, default=(), must='be a list of ws(s) relay URLs',
934
+ verify_regex=r'wss?://.+', coerce=lambda v: tuple(str(u) for u in v),
935
+ tags=('updates', 'relays'), sources=(par.ARG, par.ENV, par.FILE, par.MANIFEST)),
936
+ Parameter[int](
937
+ key='check_every', name='Check interval',
938
+ description='Seconds between checks. 0 means only on `meshnode updates.check`.',
939
+ type=int, default=DEFAULT_CHECK_EVERY_SECONDS, minimum=0,
940
+ must='be a non-negative integer of seconds',
941
+ tags=('updates', 'cadence'), live=True,
942
+ # The only setting in this block that is safe to hand out: it
943
+ # paces a check and cannot change what the node trusts. Named as
944
+ # a UCAN ability rather than a role, and declared only - nothing
945
+ # authenticates a principal yet (see ACCESS_DECLARED_ONLY).
946
+ requires='config/updates.cadence'),
947
+ Parameter[dict](
948
+ key='policy', name='Per-compartment update policy',
949
+ description="Policy per compartment or '*'. Only 'pinned' exists in this tier; a "
950
+ 'track is refused by name so an operator who writes one learns it does '
951
+ 'nothing instead of believing it does.',
952
+ type=dict, default=lambda: {'*': 'pinned'}, choices=UPDATE_POLICIES,
953
+ must='be an object of compartment -> policy', tags=('updates', 'policy'),
954
+ coerce=dict, validate=lambda label, raw, p: _check_update_policy(label, raw, p),
955
+ sources=(par.ARG, par.ENV, par.FILE)),
956
+ Parameter[Optional[str]](
957
+ key='pinned', name='Pinned release',
958
+ description='What the operator says they run. Unset means Release.running().',
959
+ type=str, default=None, must='be a semver string (e.g. 0.1.0)',
960
+ validate=lambda label, raw, p: _check_pinned(label, raw, p),
961
+ tags=('updates', 'release'), sources=(par.ARG, par.ENV, par.FILE)),
962
+ )
963
+
964
+
965
+ def _check_update_policy(label: str, raw: Any, p: Problems) -> None:
966
+ """`updates.policy`: a mapping whose values are an enum, plus one sentence
967
+ for a policy that does not exist yet.
968
+
969
+ A parameter's own fields cannot say this - the enum applies to the values
970
+ of a mapping, and `track:` has its own message - so the parameter carries
971
+ this instead, and is still the single place the setting is declared.
972
+ """
973
+ pol = raw.get('policy')
974
+ if pol is None:
975
+ return
976
+ if not isinstance(pol, Mapping) or not all(isinstance(k, str) for k in pol):
977
+ p.add('%s.policy must be an object of compartment -> policy' % label)
978
+ return
979
+ for k, v in pol.items():
980
+ if isinstance(v, str) and v.startswith('track:'):
981
+ p.add(TRACK_NOT_YET % v)
982
+ elif v not in UPDATE_POLICIES:
983
+ p.add('%s.policy.%s must be one of %s' % (label, k, ', '.join(UPDATE_POLICIES)))
984
+
985
+
986
+ def _check_pinned(label: str, raw: Any, p: Problems) -> None:
987
+ """`updates.pinned`: a semver, parsed by the release module rather than
988
+ matched by a pattern, so one definition of a version decides."""
989
+ pinned = raw.get('pinned')
990
+ if pinned is None:
991
+ return
992
+ from ..release import parse_semver
993
+ if not isinstance(pinned, str) or parse_semver(pinned) is None:
994
+ p.add('%s.pinned must be a semver string (e.g. 0.1.0)' % label)
995
+
996
+
997
+ # --- fetch pricing -------------------------------------------------------------
998
+
999
+ @dataclasses.dataclass(frozen=True)
1000
+ class CashuRail(Block):
1001
+ mints: tuple[str, ...] = ()
1002
+ unit: str = 'sat'
1003
+ sweep_above_sat: int = 0
1004
+ sweep_to: Optional[str] = None
1005
+
1006
+ @classmethod
1007
+ def check(cls, raw: Any, p: Problems) -> None:
1008
+ if not isinstance(raw, Mapping):
1009
+ p.add('fetch_pricing.rails.cashu must be an object'); return
1010
+ opt(raw, 'mints', lambda v: isinstance(v, list) and all(is_http_url(m) for m in v), p,
1011
+ 'fetch_pricing.rails.cashu.mints must be a list of http(s) URLs')
1012
+ opt(raw, 'unit', lambda v: isinstance(v, str), p, 'fetch_pricing.rails.cashu.unit must be a string')
1013
+ opt(raw, 'sweep_above_sat', non_negative_int, p, 'fetch_pricing.rails.cashu.sweep_above_sat must be a non-negative integer')
1014
+ opt(raw, 'sweep_to', lambda v: v is None or isinstance(v, str), p, 'fetch_pricing.rails.cashu.sweep_to must be a string or null')
1015
+
1016
+ @classmethod
1017
+ def from_dict(cls, raw: Any) -> 'CashuRail':
1018
+ return cls(mints=tuple(raw.get('mints') or ()), unit=str(raw.get('unit') or 'sat'),
1019
+ sweep_above_sat=int(raw.get('sweep_above_sat') or 0), sweep_to=raw.get('sweep_to'))
1020
+
1021
+
1022
+ PRICING_RULE_KEYS: tuple[str, ...] = ('min_bytes', 'max_bytes', 'content_type', 'uploader', 'msat_per_mb', 'msat')
1023
+
1024
+
1025
+ @dataclasses.dataclass(frozen=True)
1026
+ class PricingRule(Block):
1027
+ """One match-and-price rule; first match wins. See node/fetch_pricing.py."""
1028
+ min_bytes: Optional[int] = None
1029
+ max_bytes: Optional[int] = None
1030
+ content_type: Optional[str] = None
1031
+ uploader: Optional[str] = None
1032
+ msat_per_mb: Optional[int] = None
1033
+ msat: int = 0
1034
+
1035
+ @staticmethod
1036
+ def check_one(i: int, e: Any, p: Problems) -> None:
1037
+ if not isinstance(e, Mapping):
1038
+ p.add('fetch_pricing.rules[%d] must be an object' % i); return
1039
+ for k in ('min_bytes', 'max_bytes', 'msat_per_mb', 'msat'):
1040
+ if k in e and not non_negative_int(e[k]):
1041
+ p.add('fetch_pricing.rules[%d].%s must be a non-negative integer' % (i, k))
1042
+ for k in ('content_type', 'uploader'):
1043
+ if k in e and not isinstance(e[k], str):
1044
+ p.add('fetch_pricing.rules[%d].%s must be a string' % (i, k))
1045
+ unknown = sorted(set(e) - set(PRICING_RULE_KEYS))
1046
+ if unknown:
1047
+ p.add('fetch_pricing.rules[%d] has keys no rule reads: %s' % (i, ', '.join(unknown)))
1048
+
1049
+ @classmethod
1050
+ def from_dict(cls, raw: Any) -> 'PricingRule':
1051
+ return cls(min_bytes=raw.get('min_bytes'), max_bytes=raw.get('max_bytes'), content_type=raw.get('content_type'),
1052
+ uploader=raw.get('uploader'), msat_per_mb=raw.get('msat_per_mb'), msat=int(raw.get('msat', 0)))
1053
+
1054
+
1055
+ @dataclasses.dataclass(frozen=True)
1056
+ class FetchPricingConfig(ParamBlock):
1057
+ """What a fetch costs. Everything defaults to zero or off."""
1058
+ key = 'fetch_pricing'
1059
+ default_msat: int = 0
1060
+ free_for_partners: bool = True
1061
+ free_bytes_per_day: Optional[int] = None
1062
+ max_account_fee_msat: int = 0
1063
+ grasp_session_msat: int = 0
1064
+ sidecar_session_msat: Mapping[str, int] = dataclasses.field(default_factory=dict)
1065
+ rules: tuple[PricingRule, ...] = ()
1066
+ cashu: CashuRail = CashuRail()
1067
+ DERIVED: bool = False
1068
+ PARAMS = (
1069
+ Parameter(key='default_msat', name='Default Price (msat)', description='Default fetch price in millisatoshis', type=int, default=0, tags=('fetch', 'pricing')),
1070
+ Parameter(key='free_for_partners', name='Free for Partners', description='Waive fetch fee for peering partners', type=bool, default=True, tags=('fetch', 'pricing')),
1071
+ )
1072
+
1073
+ @classmethod
1074
+ def check(cls, raw: Any, p: Problems) -> None:
1075
+ if not isinstance(raw, Mapping):
1076
+ p.add('fetch_pricing must be an object'); return
1077
+ opt(raw, 'default_msat', non_negative_int, p, 'fetch_pricing.default_msat must be a non-negative integer')
1078
+ opt(raw, 'free_for_partners', lambda v: isinstance(v, bool), p, 'fetch_pricing.free_for_partners must be a boolean')
1079
+ opt(raw, 'free_bytes_per_day', lambda v: v is None or non_negative_int(v), p,
1080
+ 'fetch_pricing.free_bytes_per_day must be a non-negative integer or null')
1081
+ opt(raw, 'max_account_fee_msat', non_negative_int, p, 'fetch_pricing.max_account_fee_msat must be a non-negative integer')
1082
+ opt(raw, 'grasp_session_msat', non_negative_int, p, 'fetch_pricing.grasp_session_msat must be a non-negative integer')
1083
+ ssm = raw.get('sidecar_session_msat', {})
1084
+ if not isinstance(ssm, Mapping) or not all(isinstance(k, str) and _SIDECAR_NAME.match(k) and non_negative_int(v)
1085
+ for k, v in ssm.items()):
1086
+ p.add('fetch_pricing.sidecar_session_msat must be {sidecar name: non-negative msat}')
1087
+ rules = raw.get('rules', [])
1088
+ if not isinstance(rules, list):
1089
+ p.add('fetch_pricing.rules must be a list')
1090
+ else:
1091
+ for i, e in enumerate(rules):
1092
+ PricingRule.check_one(i, e, p)
1093
+ rails = raw.get('rails', {})
1094
+ if not isinstance(rails, Mapping):
1095
+ p.add('fetch_pricing.rails must be an object')
1096
+ elif 'cashu' in rails:
1097
+ CashuRail.check(rails['cashu'], p)
1098
+
1099
+ @classmethod
1100
+ def from_dict(cls, raw: Any) -> 'FetchPricingConfig':
1101
+ rails = raw.get('rails') or {}
1102
+ return cls(default_msat=int(raw.get('default_msat') or 0), free_for_partners=bool(raw.get('free_for_partners', True)),
1103
+ free_bytes_per_day=raw.get('free_bytes_per_day'), max_account_fee_msat=int(raw.get('max_account_fee_msat') or 0),
1104
+ grasp_session_msat=int(raw.get('grasp_session_msat') or 0),
1105
+ sidecar_session_msat={k: int(v) for k, v in (raw.get('sidecar_session_msat') or {}).items()},
1106
+ rules=tuple(PricingRule.from_dict(r) for r in raw.get('rules') or []),
1107
+ cashu=CashuRail.from_dict(rails.get('cashu') or {}))
1108
+
1109
+ def to_dict(self) -> Mapping[str, Any]:
1110
+ """The file shape keeps `rails: {cashu: ...}`, so the default stays byte-identical."""
1111
+ d = dict(super().to_dict())
1112
+ d['rails'] = {'cashu': d.pop('cashu')}
1113
+ return d
1114
+
1115
+
1116
+ # --- protocol suites ------------------------------------------------------
1117
+
1118
+ from .protocols import (
1119
+ ActivityPubProtocolConfig,
1120
+ ATProtoProtocolConfig,
1121
+ HostMultiplexerConfig,
1122
+ NostrProtocolConfig,
1123
+ P2PandaProtocolConfig,
1124
+ ProtocolSuitesConfig,
1125
+ RhioProtocolConfig,
1126
+ SSBProtocolConfig,
1127
+ )
1128
+
1129
+
1130
+ COMPARTMENT_BLOCKS: tuple[type[Block], ...] = (
1131
+ BridgeConfig, AnnounceConfig, LanConfig, P2PConfig, GossipConfig, CapacityConfig, ComputeConfig, DVMConfig,
1132
+ SettlementConfig, MediationConfig, TransportConfig, BlobWireConfig, BlobReplicationConfig,
1133
+ StorageConfig, BlobsConfig, SidecarsConfig, FetchPricingConfig, UpdatesConfig,
1134
+ PluginsConfig,
1135
+ )
1136
+
1137
+ __all__ = [c.__name__ for c in COMPARTMENT_BLOCKS] + [
1138
+ 'BridgeIngress', 'BridgeEgress', 'STRFRY_PLUGIN_REF', 'STRFRY_MOVED', 'SidecarEntry', 'SIDECAR_KEYS_MOVED', 'SIDECAR_MOVED', 'sidecars_of', 'CashuRail', 'PricingRule',
1139
+ 'HostMultiplexerConfig', 'NostrProtocolConfig', 'ATProtoProtocolConfig', 'ActivityPubProtocolConfig', 'SSBProtocolConfig',
1140
+ 'P2PandaProtocolConfig', 'RhioProtocolConfig', 'ProtocolSuitesConfig',
1141
+ 'COMPARTMENT_BLOCKS', 'PRICING_RULE_KEYS', 'BLOB_WIRE_CAPS', 'switch_or_object', 'DEFAULT_INGRESS_KINDS', 'DEFAULT_EGRESS_KINDS',
1142
+ 'DEFAULT_INGRESS_FILTERS', 'DEFAULT_RATE_LIMIT_PER_MIN', 'DEFAULT_MAX_FUTURE_SECONDS',
1143
+ 'DEFAULT_MAX_EVENT_BYTES', 'DEFAULT_DVM_KINDS', 'PROJECT_RELEASE_KEY', 'default_release_key', 'UPDATE_POLICIES', 'TRACK_NOT_YET']
1144
+