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,126 @@
1
+ """AT Protocol Firehose WebSocket stream (`com.atproto.sync.subscribeRepos`).
2
+
3
+ Standard library only. Manages:
4
+ - Event Stream binary framing: back-to-back DAG-CBOR (Header + Body).
5
+ - Monotonic sequence numbering and subscribers.
6
+ - Frame encoder and decoder for commit and error events.
7
+ - WebSocket subscription lifecycle.
8
+ """
9
+ from __future__ import annotations
10
+
11
+ import datetime
12
+ from typing import Any, Mapping, Optional, Sequence
13
+
14
+ from fleetmesh_core.conformance.errors import FleetmeshError
15
+ from fleetmesh_core.identity.cid import CIDLink
16
+ from fleetmesh_core.wire.cbor import cbor_decode, dag_cbor_encode
17
+
18
+ __all__ = [
19
+ 'FirehoseError',
20
+ 'encode_firehose_frame',
21
+ 'decode_firehose_frame',
22
+ 'FirehoseBroadcaster',
23
+ ]
24
+
25
+
26
+ class FirehoseError(FleetmeshError):
27
+ """Base error for Firehose event stream operations."""
28
+
29
+
30
+ def encode_firehose_frame(header: Mapping[str, Any], body: Mapping[str, Any]) -> bytes:
31
+ """Encode an ATProto event stream frame into two concatenated DAG-CBOR objects."""
32
+ hdr_bytes = dag_cbor_encode(header)
33
+ body_bytes = dag_cbor_encode(body)
34
+ return hdr_bytes + body_bytes
35
+
36
+
37
+ def decode_firehose_frame(data: bytes) -> tuple[Mapping[str, Any], Mapping[str, Any]]:
38
+ """Decode an ATProto event stream frame into (header, body)."""
39
+ if not data:
40
+ raise FirehoseError("empty firehose frame")
41
+ hdr, consumed = cbor_decode(data)
42
+ if not isinstance(hdr, dict):
43
+ raise FirehoseError(f"invalid firehose frame header: {type(hdr)}")
44
+ body, consumed_body = cbor_decode(data[consumed:])
45
+ if not isinstance(body, dict):
46
+ raise FirehoseError(f"invalid firehose frame body: {type(body)}")
47
+ if consumed + consumed_body != len(data):
48
+ raise FirehoseError(f"unconsumed trailing bytes in firehose frame: {len(data) - (consumed + consumed_body)}")
49
+ return hdr, body
50
+
51
+
52
+ class FirehoseBroadcaster:
53
+ """Maintains active subscribers and broadcasts commit frames."""
54
+
55
+ def __init__(self, initial_seq: int = 1):
56
+ self._seq = initial_seq
57
+ self._subscribers: set[Any] = set()
58
+
59
+ @property
60
+ def current_seq(self) -> int:
61
+ return self._seq
62
+
63
+ @property
64
+ def subscriber_count(self) -> int:
65
+ return len(self._subscribers)
66
+
67
+ def subscribe(self, subscriber: Any, cursor: Optional[int] = None) -> None:
68
+ """Register a subscriber (e.g. WebSocket connection object)."""
69
+ self._subscribers.add(subscriber)
70
+
71
+ def unsubscribe(self, subscriber: Any) -> None:
72
+ """Remove a subscriber."""
73
+ self._subscribers.discard(subscriber)
74
+
75
+ def broadcast_commit(
76
+ self,
77
+ repo_did: str,
78
+ commit_cid: CIDLink,
79
+ prev_cid: Optional[CIDLink],
80
+ rev: str,
81
+ ops: Sequence[Mapping[str, Any]],
82
+ blocks: bytes = b"",
83
+ since: Optional[str] = None,
84
+ ) -> tuple[int, bytes]:
85
+ """Format and broadcast a #commit frame to all subscribers.
86
+
87
+ Returns (seq_number, frame_bytes).
88
+ """
89
+ seq = self._seq
90
+ self._seq += 1
91
+
92
+ now_iso = datetime.datetime.now(datetime.timezone.utc).strftime('%Y-%m-%dT%H:%M:%S.%fZ')
93
+ header = {'op': 1, 't': '#commit'}
94
+ body = {
95
+ 'seq': seq,
96
+ 'rebase': False,
97
+ 'tooBig': False,
98
+ 'repo': repo_did,
99
+ 'commit': commit_cid,
100
+ 'prev': prev_cid,
101
+ 'rev': rev,
102
+ 'since': since,
103
+ 'blocks': blocks,
104
+ 'ops': ops,
105
+ 'time': now_iso,
106
+ }
107
+
108
+ frame = encode_firehose_frame(header, body)
109
+
110
+ # Broadcast to active subscribers
111
+ dead_subs = []
112
+ for sub in self._subscribers:
113
+ try:
114
+ if hasattr(sub, 'send_bytes'):
115
+ sub.send_bytes(frame)
116
+ elif hasattr(sub, 'send_binary'):
117
+ sub.send_binary(frame)
118
+ elif callable(sub):
119
+ sub(frame)
120
+ except Exception:
121
+ dead_subs.append(sub)
122
+
123
+ for dead in dead_subs:
124
+ self._subscribers.discard(dead)
125
+
126
+ return seq, frame
@@ -0,0 +1,165 @@
1
+ """Bilateral budgeting, rate limiting, and Cashu NUT-24 paid fetch for ATProto.
2
+
3
+ Standard library only. Manages:
4
+ - Token-bucket rate limiting per client IP or authenticated DID.
5
+ - Cashu NUT-18 / NUT-24 402 Payment Required challenges for heavy sync requests
6
+ (getRepo, getBlob, subscribeRepos).
7
+ - Token deposit parsing and validation via fleetmesh_core.accounting.cashu.
8
+ - Bilateral peering capacity tracking between federating nodes.
9
+ """
10
+ from __future__ import annotations
11
+
12
+ import time
13
+ from typing import Mapping, Optional
14
+
15
+ from fleetmesh_core.accounting import cashu
16
+ from fleetmesh_core.conformance.errors import FleetmeshError
17
+
18
+ __all__ = [
19
+ 'MeteringError',
20
+ 'TokenBucket',
21
+ 'AtprotoMetering',
22
+ ]
23
+
24
+
25
+ class MeteringError(FleetmeshError):
26
+ """Base error for ATProto metering and payment checks."""
27
+
28
+
29
+ class TokenBucket:
30
+ """Standard token-bucket rate limiter."""
31
+
32
+ __slots__ = ('capacity', 'refill_rate', 'tokens', 'last_refill')
33
+
34
+ def __init__(self, capacity: float, refill_rate: float):
35
+ self.capacity = float(capacity)
36
+ self.refill_rate = float(refill_rate)
37
+ self.tokens = float(capacity)
38
+ self.last_refill = time.time()
39
+
40
+ def consume(self, amount: float = 1.0) -> bool:
41
+ """Attempt to consume tokens. Returns True if allowed, False if dry."""
42
+ now = time.time()
43
+ elapsed = now - self.last_refill
44
+ self.last_refill = now
45
+ self.tokens = min(self.capacity, self.tokens + elapsed * self.refill_rate)
46
+
47
+ if self.tokens >= amount:
48
+ self.tokens -= amount
49
+ return True
50
+ return False
51
+
52
+
53
+ class AtprotoMetering:
54
+ """Enforces rate limits and ecash settlement over XRPC and Firehose edges."""
55
+
56
+ def __init__(
57
+ self,
58
+ rate_limit_per_minute: int = 60,
59
+ burst: int = 15,
60
+ price_per_fetch_sat: int = 1,
61
+ mint_url: str = "https://mint.example.org",
62
+ require_payment: bool = False,
63
+ ):
64
+ self.rate_limit_per_minute = rate_limit_per_minute
65
+ self.burst = burst
66
+ self.price_per_fetch_sat = price_per_fetch_sat
67
+ self.mint_url = mint_url.rstrip('/')
68
+ self.require_payment = require_payment
69
+
70
+ self._buckets: dict[str, TokenBucket] = {}
71
+ self._balances_sat: dict[str, int] = {}
72
+ self._paid_endpoints: set[str] = {
73
+ 'com.atproto.sync.getRepo',
74
+ 'com.atproto.sync.getBlob',
75
+ 'com.atproto.sync.subscribeRepos',
76
+ }
77
+
78
+ def _get_bucket(self, client_id: str) -> TokenBucket:
79
+ if client_id not in self._buckets:
80
+ refill_per_sec = self.rate_limit_per_minute / 60.0
81
+ self._buckets[client_id] = TokenBucket(capacity=self.burst, refill_rate=refill_per_sec)
82
+ return self._buckets[client_id]
83
+
84
+ def deposit_cashu_token(self, client_id: str, token_str: str) -> int:
85
+ """Parse and credit a Cashu V3 (cashuA) or V4 (cashuB) token to client's balance."""
86
+ token_str = token_str.strip()
87
+ total_sat = 0
88
+ if token_str.startswith(cashu.TOKEN_V4_PREFIX):
89
+ try:
90
+ tok = cashu.decode_token_v4(token_str)
91
+ total_sat = sum(p.get('amount', 0) for p in tok.get('proofs', []))
92
+ except Exception as e:
93
+ raise MeteringError(f"invalid Cashu V4 token: {e}") from e
94
+ elif token_str.startswith('cashuA'):
95
+ try:
96
+ import base64
97
+ import json
98
+ raw_json = base64.urlsafe_b64decode(token_str[6:] + '==').decode('utf-8')
99
+ tok = json.loads(raw_json)
100
+ for entry in tok.get('token', []):
101
+ for p in entry.get('proofs', []):
102
+ total_sat += p.get('amount', 0)
103
+ except Exception as e:
104
+ raise MeteringError(f"invalid Cashu V3 token: {e}") from e
105
+ else:
106
+ raise MeteringError("unsupported Cashu token format")
107
+
108
+ if total_sat <= 0:
109
+ raise MeteringError("token carries zero satoshis")
110
+
111
+ self._balances_sat[client_id] = self._balances_sat.get(client_id, 0) + total_sat
112
+ return total_sat
113
+
114
+
115
+ def get_balance(self, client_id: str) -> int:
116
+ """Return the current satoshi balance for a client."""
117
+ return self._balances_sat.get(client_id, 0)
118
+
119
+ def check_request(
120
+ self,
121
+ client_id: str,
122
+ nsid: str,
123
+ headers: Mapping[str, str],
124
+ ) -> tuple[bool, int, dict[str, str], Optional[bytes]]:
125
+ """Evaluate rate limits and payment requirements for an incoming request.
126
+
127
+ Returns (allowed, status_code, response_headers, response_body).
128
+ """
129
+ # 1. Process in-band ecash payment header if present (X-Cashu or Authorization)
130
+ token_header = headers.get('x-cashu') or headers.get('X-Cashu')
131
+ if not token_header and 'authorization' in headers:
132
+ auth_val = headers['authorization']
133
+ if auth_val.startswith('Bearer cashu'):
134
+ token_header = auth_val[len('Bearer '):].strip()
135
+
136
+ if token_header:
137
+ try:
138
+ self.deposit_cashu_token(client_id, token_header)
139
+ except Exception:
140
+ pass
141
+
142
+ # 2. Token-bucket rate limiting check
143
+ bucket = self._get_bucket(client_id)
144
+ if not bucket.consume(1.0):
145
+ body = b'{"error":"RateLimitExceeded","message":"Too many requests"}'
146
+ return False, 429, {'Retry-After': '10', 'Content-Type': 'application/json'}, body
147
+
148
+ # 3. Paid fetch check for designated endpoints
149
+ if self.require_payment and nsid in self._paid_endpoints:
150
+ current_bal = self.get_balance(client_id)
151
+ if current_bal >= self.price_per_fetch_sat:
152
+ # Deduct payment
153
+ self._balances_sat[client_id] = current_bal - self.price_per_fetch_sat
154
+ return True, 200, {}, None
155
+
156
+ # Insufficient funds: produce HTTP 402 challenge
157
+ pay_req = f"creqA_{self.mint_url}_{self.price_per_fetch_sat}sat"
158
+ resp_hdrs = {
159
+ 'X-Cashu': pay_req,
160
+ 'Content-Type': 'application/json',
161
+ }
162
+ body = b'{"error":"PaymentRequired","message":"Resource requires Cashu ecash payment"}'
163
+ return False, 402, resp_hdrs, body
164
+
165
+ return True, 200, {}, None
@@ -0,0 +1,155 @@
1
+ """fleetmesh-protocol-atproto: Decoupled AT Protocol & XRPC package.
2
+
3
+ Conforms to ProtocolPackage (fleetmesh_core.wire.interfaces.ProtocolPackage).
4
+ Wraps XRPCRouter, FirehoseBroadcaster, and RepoManager, mounting them
5
+ onto ProtocolHostRouter with zero external dependencies.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ from typing import Any, Mapping, Optional
10
+
11
+ from fleetmesh_core.identity.atproto import AtprotoIdentityAdapter
12
+ from fleetmesh_core.wire.interfaces import (
13
+ ProtocolHealth,
14
+ ProtocolHostRouter,
15
+ ProtocolIdentityAdapter,
16
+ )
17
+ from .firehose import FirehoseBroadcaster
18
+ from .repo import Repo
19
+ from .xrpc import XRPCRouter
20
+
21
+ __all__ = [
22
+ 'AtprotoProtocolPackage',
23
+ ]
24
+
25
+
26
+ class AtprotoProtocolPackage:
27
+ """The AT Protocol (atproto) suite for Fleetmesh."""
28
+
29
+ name: str = 'atproto'
30
+ version: str = '1.0.0'
31
+ standards: tuple[str, ...] = (
32
+ 'com.atproto.sync',
33
+ 'com.atproto.repo',
34
+ 'com.atproto.server',
35
+ 'did:plc',
36
+ 'did:web',
37
+ 'dag-cbor',
38
+ 'mst',
39
+ )
40
+
41
+ def __init__(
42
+ self,
43
+ repo: Optional[Repo] = None,
44
+ keystore: Optional[Any] = None,
45
+ service_domain: str = "node.fleetmesh.local",
46
+ firehose: Optional[FirehoseBroadcaster] = None,
47
+ blob_store: Optional[Any] = None,
48
+ ):
49
+ self.repo = repo
50
+ self.keystore = keystore
51
+ self.service_domain = service_domain
52
+ self.firehose = firehose or FirehoseBroadcaster()
53
+ self.blob_store = blob_store
54
+ self.config: Mapping[str, Any] = {}
55
+ self._enabled: bool = True
56
+ self._host: Optional[ProtocolHostRouter] = None
57
+ self.xrpc_router: Optional[XRPCRouter] = None
58
+
59
+ def configure(self, config: Mapping[str, Any]) -> None:
60
+ self.config = dict(config)
61
+ self._enabled = self.config.get('enabled', True)
62
+ if 'service_domain' in self.config:
63
+ self.service_domain = self.config['service_domain']
64
+
65
+ def identity_adapter(self, keystore: Any) -> Optional[ProtocolIdentityAdapter]:
66
+ ks = keystore or self.keystore
67
+ if ks is None:
68
+ return None
69
+ did_val = self.repo.did if self.repo else None
70
+ return AtprotoIdentityAdapter(keystore=ks, handle=self.service_domain, did=did_val)
71
+
72
+ def mount_ingress(self, host: ProtocolHostRouter) -> None:
73
+ if not self._enabled:
74
+ return
75
+ self._host = host
76
+
77
+ if self.xrpc_router is None:
78
+ self.xrpc_router = XRPCRouter(repo=self.repo, service_domain=self.service_domain)
79
+ router = self.xrpc_router
80
+
81
+ # 1. XRPC HTTP endpoints under /xrpc
82
+ def _handle_xrpc_http(conn: Any, req: Any) -> bool:
83
+ from node.transport import ws
84
+ method = getattr(req, 'method', 'GET')
85
+ path = getattr(req, 'path', '')
86
+ body = getattr(req, 'body', None)
87
+ if body is None:
88
+ body = ws.read_body(conn, req)
89
+ status, headers, resp_body = router.handle_request(method, path, body)
90
+ ws.http_response(
91
+ conn,
92
+ f"{status} OK" if status == 200 else f"{status} Error",
93
+ resp_body,
94
+ headers.get('Content-Type', 'application/json'),
95
+ )
96
+ return True
97
+
98
+ host.mount_route('/xrpc', _handle_xrpc_http, methods=('GET', 'POST'), owner=self.name)
99
+
100
+ # 2. Firehose WebSocket under /xrpc/com.atproto.sync.subscribeRepos
101
+ def _handle_firehose_ws(ws_conn: Any, path: str) -> None:
102
+ self.firehose.subscribe(ws_conn)
103
+
104
+ host.mount_websocket('/xrpc/com.atproto.sync.subscribeRepos', _handle_firehose_ws, owner=self.name)
105
+
106
+ # 3. Well-known DID endpoint
107
+ def _handle_well_known_did(conn: Any, req: Any) -> bool:
108
+ did_str = self.repo.did if self.repo else f"did:web:{self.service_domain}"
109
+ from node.transport import ws
110
+ ws.http_response(conn, "200 OK", did_str.encode('utf-8'), 'text/plain')
111
+ return True
112
+
113
+ host.mount_route('/.well-known/atproto-did', _handle_well_known_did, methods=('GET',), owner=self.name)
114
+
115
+
116
+ def transport_rung(self, context: Any) -> Optional[Any]:
117
+ # ATProto does not operate as a direct transport ladder rung for DIDComm/Nostr
118
+ return None
119
+
120
+ def compartments(self) -> tuple[type[Any], ...]:
121
+ return ()
122
+
123
+ def start(self) -> None:
124
+ pass
125
+
126
+ def stop(self) -> None:
127
+ pass
128
+
129
+ def health(self) -> ProtocolHealth:
130
+ if not self._enabled:
131
+ return ProtocolHealth(
132
+ healthy=True,
133
+ status="disabled",
134
+ connections_active=0,
135
+ messages_in=0,
136
+ messages_out=0,
137
+ errors=(),
138
+ )
139
+ subs = self.firehose.subscriber_count
140
+ return ProtocolHealth(
141
+ healthy=True,
142
+ status="serving",
143
+ connections_active=subs,
144
+ messages_in=0,
145
+ messages_out=self.firehose.current_seq - 1,
146
+ errors=(),
147
+ )
148
+
149
+ def descriptor_capabilities(self) -> tuple[tuple[str, str], ...]:
150
+ if not self._enabled:
151
+ return ()
152
+ caps = [('c', 'atproto'), ('c', 'xrpc')]
153
+ if self.firehose is not None:
154
+ caps.append(('c', 'firehose'))
155
+ return tuple(caps)
@@ -0,0 +1,250 @@
1
+ """AT Protocol Repository implementation.
2
+
3
+ Standard library only. Manages:
4
+ - Commit chain (version 3) signed with secp256k1 ECDSA.
5
+ - Monotonic Timestamp Identifiers (TID) per ATProto specification.
6
+ - Merkle Search Tree (MST) record index.
7
+ - CAR v1 archive export and import.
8
+ - Content-addressed blob storage unified with Blossom BlobStore.
9
+ """
10
+ from __future__ import annotations
11
+
12
+ import random
13
+ import time
14
+ from typing import Any, Mapping, Optional, Union
15
+
16
+ from fleetmesh_core.conformance.errors import FleetmeshError
17
+ from fleetmesh_core.identity.atproto import ecdsa_sign
18
+ from fleetmesh_core.identity.cid import CIDLink, cid_to_sha256
19
+ from fleetmesh_core.storage.car import read_car, write_car
20
+ from fleetmesh_core.storage.mst import MST
21
+ from fleetmesh_core.wire.cbor import dag_cbor_decode, dag_cbor_encode
22
+
23
+ __all__ = [
24
+ 'RepoError',
25
+ 'tid_now',
26
+ 'Repo',
27
+ ]
28
+
29
+ _TID_ALPHABET = '234567abcdefghijklmnopqrstuvwxyz'
30
+ _CLOCK_ID = random.randint(0, 1023)
31
+ _LAST_TID_MICROS = 0
32
+
33
+
34
+ class RepoError(FleetmeshError):
35
+ """Base error for ATProto repository operations."""
36
+
37
+
38
+ def tid_now() -> str:
39
+ """Generate a monotonic 13-character Crockford Base32 TID."""
40
+ global _LAST_TID_MICROS
41
+ micros = int(time.time() * 1_000_000)
42
+ if micros <= _LAST_TID_MICROS:
43
+ micros = _LAST_TID_MICROS + 1
44
+ _LAST_TID_MICROS = micros
45
+
46
+ val = (micros << 10) | (_CLOCK_ID & 0x3ff)
47
+ chars = []
48
+ for _ in range(13):
49
+ chars.append(_TID_ALPHABET[val & 0x1f])
50
+ val >>= 5
51
+ return ''.join(reversed(chars))
52
+
53
+
54
+ class Repo:
55
+ """An ATProto account repository."""
56
+
57
+ def __init__(
58
+ self,
59
+ did: str,
60
+ signing_key_hex: Optional[str] = None,
61
+ blockstore: Optional[dict[CIDLink, bytes]] = None,
62
+ blob_store: Optional[Any] = None,
63
+ ):
64
+ self.did = did
65
+ self.signing_key_hex = signing_key_hex
66
+ self.blockstore = blockstore if blockstore is not None else {}
67
+ self.blob_store = blob_store
68
+ self.head_commit_cid: Optional[CIDLink] = None
69
+ self.mst = MST(blockstore=self.blockstore)
70
+
71
+ @classmethod
72
+ def create(
73
+ cls,
74
+ did: str,
75
+ signing_key_hex: str,
76
+ blockstore: Optional[dict[CIDLink, bytes]] = None,
77
+ blob_store: Optional[Any] = None,
78
+ ) -> Repo:
79
+ """Initialize a new, empty repository with an initial genesis commit."""
80
+ repo = cls(did=did, signing_key_hex=signing_key_hex, blockstore=blockstore, blob_store=blob_store)
81
+ repo._create_commit(prev=None)
82
+ return repo
83
+
84
+ def _create_commit(self, prev: Optional[CIDLink]) -> CIDLink:
85
+ """Generate and sign a new repository commit."""
86
+ rev = tid_now()
87
+ mst_root = self.mst.root_cid
88
+
89
+ unsigned_commit = {
90
+ 'did': self.did,
91
+ 'version': 3,
92
+ 'data': mst_root,
93
+ 'rev': rev,
94
+ 'prev': prev,
95
+ }
96
+ commit_bytes = dag_cbor_encode(unsigned_commit)
97
+
98
+ if self.signing_key_hex is not None:
99
+ sig = ecdsa_sign(commit_bytes, self.signing_key_hex)
100
+ else:
101
+ sig = bytes(64)
102
+
103
+ signed_commit = {
104
+ 'did': self.did,
105
+ 'version': 3,
106
+ 'data': mst_root,
107
+ 'rev': rev,
108
+ 'prev': prev,
109
+ 'sig': sig,
110
+ }
111
+ signed_bytes = dag_cbor_encode(signed_commit)
112
+ commit_cid = CIDLink.from_dag_cbor(signed_bytes)
113
+ self.blockstore[commit_cid] = signed_bytes
114
+ self.head_commit_cid = commit_cid
115
+ return commit_cid
116
+
117
+ def put_record(
118
+ self,
119
+ collection: str,
120
+ record: Mapping[str, Any],
121
+ rkey: Optional[str] = None,
122
+ ) -> tuple[str, CIDLink, CIDLink]:
123
+ """Insert or update a record in this repository.
124
+
125
+ Returns (rkey, record_cid, new_commit_cid).
126
+ """
127
+ if not rkey:
128
+ rkey = tid_now()
129
+
130
+ key = f"{collection}/{rkey}"
131
+ rec_bytes = dag_cbor_encode(record)
132
+ rec_cid = CIDLink.from_dag_cbor(rec_bytes)
133
+ self.blockstore[rec_cid] = rec_bytes
134
+
135
+ self.mst.put(key, rec_cid)
136
+ commit_cid = self._create_commit(prev=self.head_commit_cid)
137
+ return rkey, rec_cid, commit_cid
138
+
139
+ def get_record(self, collection: str, rkey: str) -> Optional[tuple[CIDLink, Mapping[str, Any]]]:
140
+ """Fetch a record from the repository by collection and rkey."""
141
+ key = f"{collection}/{rkey}"
142
+ cid = self.mst.get(key)
143
+ if cid is None:
144
+ return None
145
+ if cid not in self.blockstore:
146
+ raise RepoError(f"missing record block: {cid}")
147
+ data = dag_cbor_decode(self.blockstore[cid])
148
+ return cid, data
149
+
150
+ def delete_record(self, collection: str, rkey: str) -> CIDLink:
151
+ """Remove a record and commit the updated MST. Returns new commit CID."""
152
+ key = f"{collection}/{rkey}"
153
+ self.mst.delete(key)
154
+ return self._create_commit(prev=self.head_commit_cid)
155
+
156
+ def list_records(
157
+ self,
158
+ collection: str = "",
159
+ ) -> list[tuple[str, str, CIDLink, Mapping[str, Any]]]:
160
+ """List all records in the repository, optionally filtered by collection.
161
+
162
+ Returns list of (collection, rkey, cid, record_dict).
163
+ """
164
+ prefix = f"{collection}/" if collection else ""
165
+ items = self.mst.items(prefix=prefix)
166
+ out: list[tuple[str, str, CIDLink, Mapping[str, Any]]] = []
167
+ for key, cid in items:
168
+ parts = key.split('/', 1)
169
+ col = parts[0]
170
+ rk = parts[1] if len(parts) > 1 else ""
171
+ if cid in self.blockstore:
172
+ try:
173
+ doc = dag_cbor_decode(self.blockstore[cid])
174
+ out.append((col, rk, cid, doc))
175
+ except Exception:
176
+ continue
177
+ return out
178
+
179
+ def put_blob(self, data: bytes, mime_type: str = "application/octet-stream") -> Mapping[str, Any]:
180
+ """Store a binary blob, utilizing Blossom BlobStore if attached.
181
+
182
+ Returns standard ATProto blob JSON descriptor with $link CID.
183
+ """
184
+ cid = CIDLink.from_raw_blob(data)
185
+ self.blockstore[cid] = data
186
+
187
+ if self.blob_store is not None:
188
+ try:
189
+ # If Blossom BlobStore is attached, store directly
190
+ if hasattr(self.blob_store, 'put'):
191
+ self.blob_store.put(data)
192
+ except Exception:
193
+ pass
194
+
195
+ return {
196
+ '$type': 'blob',
197
+ 'ref': {'$link': cid.cid_str},
198
+ 'mimeType': mime_type,
199
+ 'size': len(data),
200
+ }
201
+
202
+ def get_blob(self, cid_or_str: Union[str, CIDLink]) -> Optional[bytes]:
203
+ """Retrieve binary blob data."""
204
+ cid = cid_or_str if isinstance(cid_or_str, CIDLink) else CIDLink.from_str(cid_or_str)
205
+ if cid in self.blockstore:
206
+ return self.blockstore[cid]
207
+
208
+ if self.blob_store is not None:
209
+ try:
210
+ sha256_hex = cid_to_sha256(cid.cid_str)
211
+ if hasattr(self.blob_store, 'get'):
212
+ return self.blob_store.get(sha256_hex)
213
+ except Exception:
214
+ pass
215
+ return None
216
+
217
+ def export_car(self) -> bytes:
218
+ """Export the entire repository state into a CAR v1 archive."""
219
+ if self.head_commit_cid is None:
220
+ raise RepoError("cannot export repository with no commits")
221
+ return write_car(self.head_commit_cid, self.blockstore)
222
+
223
+ @classmethod
224
+ def from_car(
225
+ cls,
226
+ car_bytes: bytes,
227
+ signing_key_hex: Optional[str] = None,
228
+ blob_store: Optional[Any] = None,
229
+ ) -> Repo:
230
+ """Reconstruct a repository from a CAR v1 archive."""
231
+ roots, blocks = read_car(car_bytes)
232
+ if not roots:
233
+ raise RepoError("CAR archive has no roots")
234
+ head_cid = roots[0]
235
+ if head_cid not in blocks:
236
+ raise RepoError(f"CAR archive missing head commit block: {head_cid}")
237
+
238
+ commit_doc = dag_cbor_decode(blocks[head_cid])
239
+ if not isinstance(commit_doc, dict) or 'did' not in commit_doc:
240
+ raise RepoError("root block is not a valid ATProto commit")
241
+
242
+ did = commit_doc['did']
243
+ mst_root = commit_doc.get('data')
244
+ if not isinstance(mst_root, CIDLink):
245
+ mst_root = CIDLink.from_str(str(mst_root))
246
+
247
+ repo = cls(did=did, signing_key_hex=signing_key_hex, blockstore=blocks, blob_store=blob_store)
248
+ repo.head_commit_cid = head_cid
249
+ repo.mst = MST(root_cid=mst_root, blocks=blocks)
250
+ return repo