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,627 @@
1
+ """Blossom Media Protocol (BUD-01..BUD-04) server and client implementation.
2
+
3
+ Spec reference: spec.html section "Content Addressing & CIDs" and "Blobs" (lines 3768-3776).
4
+ Provides HTTP endpoints on the node relay face:
5
+ - GET /<sha256>[.<ext>]: Download blob with SHA-256 integrity verification.
6
+ - HEAD /<sha256>[.<ext>]: Check blob presence and size (BUD-02).
7
+ - GET /cid/<cid>: Download blob by standard IPFS CIDv1 (raw-sha2-256).
8
+ - HEAD /cid/<cid>: Check blob by CIDv1.
9
+ - PUT /upload: Upload blob with BUD-01 Nostr Kind 24242 authorization.
10
+ - PUT /mirror: Mirror blob from remote HTTP source with BUD-04 hash verification.
11
+ - GET /list/<pubkey>: List blobs uploaded by a pubkey.
12
+ - DELETE /<sha256>: Delete blob (requires uploader authorization).
13
+ """
14
+ from __future__ import annotations
15
+
16
+ import hashlib
17
+ import json
18
+ import re
19
+ import socket
20
+ import time
21
+ from typing import Any, Optional, TypedDict
22
+
23
+ from fleetmesh_core.wire.interfaces import NostrEvent
24
+ from . import blobstore
25
+ from .blobstore import BlobDescriptor
26
+ from node.transport import netfetch, ws
27
+ from fleetmesh_core.storage import blossom_auth
28
+ from fleetmesh_core.accounting import paidfetch
29
+ from fleetmesh_core.crypto.bech32 import bech32_decode
30
+
31
+ BLOSSOM_KIND: int = 24242
32
+ HEX64_RE = re.compile(r'^[0-9a-f]{64}$')
33
+
34
+
35
+ class BlossomBlobMetadata(TypedDict, total=False):
36
+ """Metadata returned by HEAD /<sha256> (BUD-02)."""
37
+ sha256: str
38
+ size: int
39
+ type: str
40
+ cid: Optional[str]
41
+
42
+
43
+ class BlossomUploadDescriptor(TypedDict, total=False):
44
+ """Descriptor returned by PUT /upload (BUD-01)."""
45
+ url: str
46
+ sha256: str
47
+ size: int
48
+ type: str
49
+ uploaded: int
50
+
51
+
52
+ class BlossomMirrorDescriptor(TypedDict, total=False):
53
+ """Descriptor returned by PUT /mirror (BUD-04)."""
54
+ url: str
55
+ sha256: str
56
+ size: int
57
+ type: str
58
+
59
+
60
+ def _account_of(segment: str) -> Optional[str]:
61
+ """`npub1…` or 64 hex → hex pubkey, else None."""
62
+ seg = segment.lower()
63
+ if seg.startswith('npub1'):
64
+ raw = bech32_decode('npub', seg)
65
+ return raw.hex() if raw is not None and len(raw) == 32 else None
66
+ return seg if HEX64_RE.match(seg) else None
67
+
68
+
69
+ def _cashu_tokens(req: Optional[ws.PlainHTTP]) -> list[str]:
70
+ """Every cashuB token in the request's X-Cashu header(s), in order. NUT-24
71
+ puts one token in the header; an itemised 402 may ask for two (operator,
72
+ account), sent comma-separated in one header or as repeated headers."""
73
+ if req is None:
74
+ return []
75
+ raw = req.headers.get(b'x-cashu')
76
+ if not raw:
77
+ return []
78
+ text = raw.decode('latin1') if isinstance(raw, (bytes, bytearray)) else str(raw)
79
+ return [t.strip() for t in text.split(',') if t.strip()]
80
+
81
+
82
+ def create_auth_event(signer_sk: str, action: str, sha256_hex: str, now: Optional[int] = None,
83
+ expiration_seconds: int = 300, content: str = '') -> NostrEvent:
84
+ """A signed BUD-01/BUD-04 authorization (kind 24242); the bytes are fleetmesh_core/blossom_auth.py's."""
85
+ return blossom_auth.auth_event(signer_sk, action, sha256_hex, int(now if now is not None else time.time()),
86
+ expiration_seconds, content)
87
+
88
+
89
+ auth_header = blossom_auth.auth_header
90
+ parse_auth_header = blossom_auth.parse_auth_header
91
+
92
+
93
+ class BlossomServer:
94
+ """Blossom HTTP service provider (BUD-01 to BUD-04)."""
95
+
96
+ def __init__(
97
+ self,
98
+ store: blobstore.BlobStore,
99
+ node_pk: Optional[str] = None,
100
+ operator_pk: Optional[str] = None,
101
+ require_auth: bool = False,
102
+ max_upload_bytes: int = 64 * 1024 * 1024,
103
+ clock: Optional[Any] = None,
104
+ allow_private_mirror: Any = False,
105
+ pricing: Optional[Any] = None,
106
+ ) -> None:
107
+ self.store: blobstore.BlobStore = store
108
+ # node/fetch_pricing.PaidFetch or None. None means every fetch is
109
+ # free and no price header is ever written: the pre-payment server.
110
+ self.pricing: Optional[Any] = pricing
111
+ self.node_pk: Optional[str] = node_pk
112
+ self.operator_pk: Optional[str] = operator_pk
113
+ self.require_auth: bool = require_auth
114
+ self.max_upload_bytes: int = max_upload_bytes
115
+ self.clock = clock or (lambda: int(time.time()))
116
+ # /mirror source URLs are caller-chosen, so by default only public
117
+ # addresses may be fetched (see netfetch). An operator who mirrors
118
+ # from LAN peers can pass True or a list of exempt hosts.
119
+ self.allow_private_mirror: Any = allow_private_mirror
120
+
121
+ def handle_request(self, conn: socket.socket, req: ws.PlainHTTP) -> bool:
122
+ """Route PlainHTTP request to appropriate Blossom handler.
123
+
124
+ Returns True if request was handled, False if not a Blossom path.
125
+ """
126
+ path = req.path.split('?', 1)[0].rstrip('/')
127
+ method = req.method.upper()
128
+
129
+ # CORS preflight
130
+ if method == 'OPTIONS':
131
+ ws.http_response(conn, '204 No Content', b'')
132
+ return True
133
+
134
+ # 1. /upload
135
+ if path == '/upload' and method == 'PUT':
136
+ self._handle_upload(conn, req)
137
+ return True
138
+
139
+ # 2. /mirror
140
+ if path == '/mirror' and method == 'PUT':
141
+ self._handle_mirror(conn, req)
142
+ return True
143
+
144
+ # 2b. /sidecar-session/<name>/<subject>: not a Blossom path, but the
145
+ # same quote/settle machinery - Caddy's forward_auth calls this in
146
+ # front of a sidecar whose manifest says pricing.mode == session to
147
+ # gate the whole session (a git clone/fetch) the way Blossom gates a
148
+ # blob fetch. 200 = proceed (free, partner, or paid); 402 = quote,
149
+ # same body shape as a blob 402. /grasp-session/<subject> is the
150
+ # older spelling of /sidecar-session/grasp/<subject>. See
151
+ # docs/sidecars.md and PaidFetch.quote_session.
152
+ session = None
153
+ if path.startswith('/sidecar-session/') and method in ('GET', 'HEAD'):
154
+ rest = path[len('/sidecar-session/'):]
155
+ if '/' in rest:
156
+ name, subject = rest.split('/', 1)
157
+ if name and subject:
158
+ session = '%s:%s' % (name, subject)
159
+ elif path.startswith('/grasp-session/') and method in ('GET', 'HEAD'):
160
+ subject = path[len('/grasp-session/'):]
161
+ if subject:
162
+ session = 'grasp:' + subject
163
+ if session is not None:
164
+ if self.pricing is not None:
165
+ self._handle_sidecar_session(conn, req, session)
166
+ else:
167
+ ws.http_response(conn, '200 OK', b'')
168
+ return True
169
+
170
+ # 3. /list/<pubkey>
171
+ if path.startswith('/list/') and method == 'GET':
172
+ pubkey = path[len('/list/'):]
173
+ self._handle_list(conn, pubkey)
174
+ return True
175
+
176
+ # 4. /cid/<cid>
177
+ if path.startswith('/cid/'):
178
+ cid = path[len('/cid/'):]
179
+ try:
180
+ sha256_hex = blobstore.cid_to_sha256(cid)
181
+ except Exception:
182
+ ws.http_response(conn, '400 Bad Request', b'{"error":"invalid CID"}')
183
+ return True
184
+ if self._dispatch_blob(conn, req, method, sha256_hex):
185
+ return True
186
+
187
+ # 5. /<npub>/<sha256>[.<ext>]: a fetch that names the account whose
188
+ # upload it is, so that account's fee (if the operator allows one)
189
+ # applies. The blob is the same blob; only the price differs.
190
+ parts = path.lstrip('/').split('/')
191
+ if len(parts) == 2 and method in ('GET', 'HEAD'):
192
+ account = _account_of(parts[0])
193
+ digest = parts[1].split('.', 1)[0].lower()
194
+ if account and HEX64_RE.match(digest):
195
+ if self._dispatch_blob(conn, req, method, digest, account=account):
196
+ return True
197
+
198
+ # 6. /<cid> or /<sha256>[.<ext>]
199
+ clean_path = path.lstrip('/')
200
+ if '.' in clean_path:
201
+ clean_path = clean_path.split('.', 1)[0]
202
+
203
+ if clean_path.startswith('bafkrei') or (clean_path.startswith('b') and len(clean_path) >= 50):
204
+ cid_hex: Optional[str]
205
+ try:
206
+ cid_hex = blobstore.cid_to_sha256(clean_path)
207
+ except Exception:
208
+ # Not a decodable CID; it may still be a bare hex digest below.
209
+ cid_hex = None
210
+ if cid_hex is not None and self._dispatch_blob(conn, req, method, cid_hex):
211
+ return True
212
+
213
+ if HEX64_RE.match(clean_path.lower()):
214
+ if self._dispatch_blob(conn, req, method, clean_path.lower()):
215
+ return True
216
+
217
+ return False
218
+
219
+ def _dispatch_blob(self, conn: socket.socket, req: ws.PlainHTTP, method: str, sha256_hex: str,
220
+ account: Optional[str] = None) -> bool:
221
+ """Route a blob-addressed request by HTTP method (BUD-01/02).
222
+
223
+ Returns False for a method Blossom does not define on a blob so the
224
+ caller can fall through to its own 404/405 handling.
225
+ """
226
+ match method:
227
+ case 'GET':
228
+ self._serve_blob(conn, sha256_hex, req=req, account=account)
229
+ case 'HEAD':
230
+ self._head_blob(conn, sha256_hex, req=req, account=account)
231
+ case 'DELETE':
232
+ self._handle_delete(conn, req, sha256_hex)
233
+ case _:
234
+ return False
235
+ return True
236
+
237
+ def _handle_sidecar_session(self, conn: socket.socket, req: ws.PlainHTTP, session: str) -> None:
238
+ """Answer Caddy's forward_auth check in front of a session-priced
239
+ sidecar: 200 to let the session through, 402 (same body shape as a
240
+ blob fetch) with a quote to pay. `session` is `<name>:<subject>`,
241
+ the subject being whatever Caddy is told to pass - a repo identifier
242
+ is enough; coarse, one quote per session, not per object (see
243
+ docs/sidecars.md)."""
244
+ assert self.pricing is not None
245
+ quote = self.pricing.quote_session(session, self._fetcher(req))
246
+ if quote is None:
247
+ ws.http_response(conn, '200 OK', b'')
248
+ return
249
+ tokens = _cashu_tokens(req)
250
+ if not tokens:
251
+ self._payment_required(conn, quote)
252
+ return
253
+ res = self.pricing.settle(quote, tokens)
254
+ if not hasattr(res, 'amount_msat'):
255
+ self._payment_required(conn, quote, why=res.reason)
256
+ return
257
+ ws.http_response(conn, '200 OK', b'', headers=self._price_headers(quote))
258
+
259
+ def _verify_auth(self, req: ws.PlainHTTP, action: str, expected_sha256: Optional[str] = None) -> Optional[str]:
260
+ """Verify Kind 24242 authorization event. Returns uploader pubkey."""
261
+ auth_hdr = req.headers.get(b'authorization', b'').decode('latin1')
262
+ if not auth_hdr:
263
+ if not self.require_auth:
264
+ return None
265
+ raise ValueError('Authorization required')
266
+
267
+ ev = parse_auth_header(auth_hdr)
268
+ if not ev:
269
+ raise ValueError('invalid Nostr Authorization header')
270
+ try:
271
+ return blossom_auth.check_auth(ev, action, int(self.clock()), expected_sha256)
272
+ except blossom_auth.AuthError as e:
273
+ raise ValueError(str(e)) from e
274
+
275
+ @staticmethod
276
+ def _auth_x_tag(req: ws.PlainHTTP) -> Optional[str]:
277
+ """Return the lowercase ``x`` (sha256) tag of the request's auth event, if any.
278
+
279
+ Used by /mirror, which must know the digest the client committed to
280
+ *before* it downloads anything, so the auth check cannot depend on
281
+ the fetched bytes. Signature validity is _verify_auth's job; this
282
+ only reads the tag from an already-verified header.
283
+ """
284
+ auth_hdr = req.headers.get(b'authorization', b'').decode('latin1')
285
+ if not auth_hdr:
286
+ return None
287
+ ev = parse_auth_header(auth_hdr)
288
+ if not ev or not isinstance(ev, dict):
289
+ return None
290
+ for t in ev.get('tags', []):
291
+ if len(t) >= 2 and t[0] == 'x' and isinstance(t[1], str) and t[1]:
292
+ return t[1].lower()
293
+ return None
294
+
295
+ # -- paid fetch (plan §3.3, NUT-24) ------------------------------------------
296
+
297
+ def _fetcher(self, req: Optional[ws.PlainHTTP]) -> Optional[str]:
298
+ """Who is fetching, for the partner rule and the free allowance: the
299
+ signer of a kind 24242 `get` Authorization when one is sent, else None
300
+ (anonymous, pooled). Never the IP."""
301
+ if req is None or not req.headers.get(b'authorization'):
302
+ return None
303
+ try:
304
+ return self._verify_auth(req, 'get')
305
+ except ValueError:
306
+ return None
307
+
308
+ def _quote(self, stat: BlobDescriptor, req: Optional[ws.PlainHTTP], account: Optional[str]) -> Any:
309
+ if self.pricing is None:
310
+ return None
311
+ return self.pricing.quote(stat, self._fetcher(req), account)
312
+
313
+ @staticmethod
314
+ def _price_headers(quote: paidfetch.Quote) -> dict[str, str]:
315
+ return quote.headers()
316
+
317
+ def _payment_required(self, conn: socket.socket, quote: Any, why: Optional[str] = None) -> None:
318
+ body = quote.body(why)
319
+ ws.http_response(conn, '402 Payment Required', json.dumps(body).encode(), 'application/json',
320
+ headers=self._price_headers(quote))
321
+
322
+ def _serve_blob(self, conn: socket.socket, sha256_hex: str, req: Optional[ws.PlainHTTP] = None,
323
+ account: Optional[str] = None) -> None:
324
+ stat = self.store.stat(sha256_hex)
325
+ if stat is None:
326
+ ws.http_response(conn, '404 Not Found', b'{"error":"blob not found"}')
327
+ return
328
+ quote = self._quote(stat, req, account)
329
+ if quote is not None and self.pricing is not None:
330
+ tokens = _cashu_tokens(req)
331
+ if not tokens:
332
+ self._payment_required(conn, quote)
333
+ return
334
+ res = self.pricing.settle(quote, tokens)
335
+ if not hasattr(res, 'amount_msat'):
336
+ self._payment_required(conn, quote, why=res.reason)
337
+ return
338
+ data = self.store.get(sha256_hex)
339
+ if data is None:
340
+ ws.http_response(conn, '404 Not Found', b'{"error":"blob not found"}')
341
+ return
342
+ if self.pricing is not None:
343
+ self.pricing.note_served(self._fetcher(req), len(data))
344
+
345
+ ctype = stat.get('type') or 'application/octet-stream'
346
+ cid = stat.get('cid') or blobstore.sha256_to_cid(sha256_hex)
347
+ headers = {
348
+ 'X-SHA-256': sha256_hex,
349
+ 'X-CID': cid,
350
+ 'ETag': f'"{sha256_hex}"',
351
+ 'Cache-Control': 'public, max-age=31536000, immutable',
352
+ }
353
+ ws.http_response(conn, '200 OK', data, ctype, headers=headers)
354
+
355
+ def _head_blob(self, conn: socket.socket, sha256_hex: str, req: Optional[ws.PlainHTTP] = None,
356
+ account: Optional[str] = None) -> None:
357
+ stat = self.store.stat(sha256_hex)
358
+ if stat is None:
359
+ ws.http_response(conn, '404 Not Found', b'')
360
+ return
361
+
362
+ cid = stat.get('cid') or blobstore.sha256_to_cid(sha256_hex)
363
+ headers = {
364
+ 'X-SHA-256': sha256_hex,
365
+ 'X-CID': cid,
366
+ 'Content-Length': str(stat['size']),
367
+ 'ETag': f'"{sha256_hex}"',
368
+ 'Cache-Control': 'public, max-age=31536000, immutable',
369
+ }
370
+ # HEAD prices without transferring: 200 with the price and the
371
+ # payment request, so a client learns the cost the cheap way. It is
372
+ # a 200 because the blob exists and nothing was refused.
373
+ quote = self._quote(stat, req, account)
374
+ if quote is not None:
375
+ headers.update(self._price_headers(quote))
376
+ headers['Cache-Control'] = 'no-store'
377
+ ws.http_response(conn, '200 OK', b'', stat.get('type', 'application/octet-stream'), headers=headers)
378
+
379
+ def _handle_upload(self, conn: socket.socket, req: ws.PlainHTTP) -> None:
380
+ try:
381
+ body = ws.read_body(conn, req, max_bytes=self.max_upload_bytes)
382
+ except Exception as e:
383
+ ws.http_response(conn, '413 Payload Too Large', json.dumps({'error': str(e)}).encode())
384
+ return
385
+
386
+ if not body:
387
+ ws.http_response(conn, '400 Bad Request', b'{"error":"empty upload body"}')
388
+ return
389
+
390
+ actual_sha256 = hashlib.sha256(body).hexdigest().lower()
391
+
392
+ try:
393
+ uploader = self._verify_auth(req, 'upload', expected_sha256=actual_sha256)
394
+ except ValueError as e:
395
+ ws.http_response(conn, '401 Unauthorized', json.dumps({'error': str(e)}).encode())
396
+ return
397
+
398
+ ctype = req.headers.get(b'content-type', b'application/octet-stream').decode('latin1').split(';')[0].strip()
399
+ descriptor = self.store.put(body, content_type=ctype, uploaded_by=uploader, now=self.clock())
400
+
401
+ resp_body = json.dumps({
402
+ 'url': f"/{descriptor['sha256']}",
403
+ 'sha256': descriptor['sha256'],
404
+ 'size': descriptor['size'],
405
+ 'type': descriptor['type'],
406
+ 'uploaded': descriptor['uploaded'],
407
+ 'cid': descriptor['cid'],
408
+ }).encode('utf-8')
409
+ ws.http_response(conn, '200 OK', resp_body, 'application/json', headers={'X-CID': descriptor['cid']})
410
+
411
+ def _handle_mirror(self, conn: socket.socket, req: ws.PlainHTTP) -> None:
412
+ """BUD-04 HTTP Mirroring.
413
+
414
+ Order matters: the authorization event is verified *before* anything
415
+ is fetched. The previous order (fetch, then check auth) let anyone
416
+ with no credentials at all use the node as a free HTTP proxy -- and
417
+ an SSRF probe, since the URL was theirs. Now an unauthenticated
418
+ request costs the node nothing but a signature check, and the fetch
419
+ is bounded by netfetch's address policy and the upload cap. The
420
+ digest the auth event commits to (its ``x`` tag) is then compared
421
+ to what was actually downloaded.
422
+ """
423
+ try:
424
+ body = ws.read_body(conn, req, max_bytes=65536)
425
+ data = json.loads(body.decode('utf-8'))
426
+ source_url = data.get('url')
427
+ if not source_url:
428
+ raise ValueError("missing 'url' in mirror body")
429
+ except Exception as e:
430
+ ws.http_response(conn, '400 Bad Request', json.dumps({'error': str(e)}).encode())
431
+ return
432
+
433
+ # 1. Authorization first. The x tag names the digest the client
434
+ # expects; we hold it and compare after the download.
435
+ try:
436
+ uploader = self._verify_auth(req, 'mirror')
437
+ except ValueError as e:
438
+ ws.http_response(conn, '401 Unauthorized', json.dumps({'error': str(e)}).encode())
439
+ return
440
+ expected_sha256 = self._auth_x_tag(req)
441
+ if self.require_auth and not expected_sha256:
442
+ ws.http_response(conn, '401 Unauthorized', b'{"error":"Authorization event must name the blob sha256 in an x tag"}')
443
+ return
444
+
445
+ # 2. Fetch, bounded. The 502 body is deliberately generic: echoing
446
+ # the fetch error would tell a caller which internal hosts answer
447
+ # and which do not, which is exactly what an SSRF probe wants.
448
+ try:
449
+ remote_data, remote_headers = netfetch.fetch(
450
+ str(source_url),
451
+ max_bytes=self.max_upload_bytes,
452
+ timeout=10.0,
453
+ allow_private=self.allow_private_mirror,
454
+ headers={'User-Agent': 'fleetmesh-blossom/1.0'},
455
+ )
456
+ except netfetch.FetchTooLarge:
457
+ ws.http_response(conn, '413 Payload Too Large', b'{"error":"remote payload too large"}')
458
+ return
459
+ except netfetch.FetchError:
460
+ ws.http_response(conn, '502 Bad Gateway', b'{"error":"failed to fetch remote blob"}')
461
+ return
462
+ remote_ctype = (remote_headers.get('Content-Type') or 'application/octet-stream').split(';', 1)[0].strip()
463
+
464
+ # 3. Re-check the digest against what the auth event committed to.
465
+ actual_sha256 = hashlib.sha256(remote_data).hexdigest().lower()
466
+ if expected_sha256 and expected_sha256 != actual_sha256:
467
+ ws.http_response(conn, '401 Unauthorized', json.dumps({
468
+ 'error': f"Authorization hash mismatch: expected {expected_sha256[:16]}, got {actual_sha256[:16]}",
469
+ }).encode())
470
+ return
471
+
472
+ descriptor = self.store.put(remote_data, content_type=remote_ctype, uploaded_by=uploader, now=self.clock())
473
+ resp_body = json.dumps({
474
+ 'url': f"/{descriptor['sha256']}",
475
+ 'sha256': descriptor['sha256'],
476
+ 'size': descriptor['size'],
477
+ 'type': descriptor['type'],
478
+ 'uploaded': descriptor['uploaded'],
479
+ 'cid': descriptor['cid'],
480
+ }).encode('utf-8')
481
+ ws.http_response(conn, '200 OK', resp_body, 'application/json', headers={'X-CID': descriptor['cid']})
482
+
483
+ def _handle_list(self, conn: socket.socket, pubkey: str) -> None:
484
+ pk = pubkey.strip().lower()
485
+ if not HEX64_RE.match(pk):
486
+ ws.http_response(conn, '400 Bad Request', b'{"error":"invalid pubkey"}')
487
+ return
488
+
489
+ items = self.store.list_by(uploaded_by=pk)
490
+ ws.http_response(conn, '200 OK', json.dumps(items).encode('utf-8'), 'application/json')
491
+
492
+ def _handle_delete(self, conn: socket.socket, req: ws.PlainHTTP, sha256_hex: str) -> None:
493
+ stat = self.store.stat(sha256_hex)
494
+ if stat is None:
495
+ ws.http_response(conn, '404 Not Found', b'{"error":"blob not found"}')
496
+ return
497
+
498
+ try:
499
+ signer_pk = self._verify_auth(req, 'delete', expected_sha256=sha256_hex)
500
+ except ValueError as e:
501
+ ws.http_response(conn, '401 Unauthorized', json.dumps({'error': str(e)}).encode())
502
+ return
503
+
504
+ # Check permissions: must be uploader or operator
505
+ if stat.get('uploaded_by') and signer_pk != stat.get('uploaded_by'):
506
+ if signer_pk != self.operator_pk and signer_pk != self.node_pk:
507
+ ws.http_response(conn, '403 Forbidden', b'{"error":"only original uploader or operator may delete"}')
508
+ return
509
+
510
+ self.store.delete(sha256_hex)
511
+ ws.http_response(conn, '200 OK', b'{"status":"deleted"}')
512
+
513
+
514
+ class BlossomClient:
515
+ """Standard-library HTTP client for Blossom servers."""
516
+
517
+ # Peer server URLs reach the client two ways: operator config (partner
518
+ # servers) and the ``blossom_servers`` list inside a compute job payload,
519
+ # which the *requester* wrote. The second path is why every method below
520
+ # goes through netfetch with the public-address policy by default and a
521
+ # mandatory size cap; callers that know the server is a LAN peer opt in
522
+ # with ``allow_private``.
523
+ DEFAULT_MAX_BYTES: int = 64 * 1024 * 1024
524
+
525
+ @staticmethod
526
+ def get_blob(
527
+ server_url: str,
528
+ sha256_hex: str,
529
+ timeout: float = 10.0,
530
+ *,
531
+ max_bytes: Optional[int] = None,
532
+ allow_private: Any = False,
533
+ ) -> bytes:
534
+ """Download and verify blob from Blossom server.
535
+
536
+ ``max_bytes`` caps the body (default DEFAULT_MAX_BYTES); a server that
537
+ sends more raises netfetch.FetchTooLarge rather than filling memory.
538
+ """
539
+ base = server_url.rstrip('/')
540
+ url = f"{base}/{sha256_hex.lower()}"
541
+ data, _ = netfetch.fetch(
542
+ url,
543
+ max_bytes=int(max_bytes if max_bytes is not None else BlossomClient.DEFAULT_MAX_BYTES),
544
+ timeout=timeout,
545
+ allow_private=allow_private,
546
+ headers={'Accept': '*/*'},
547
+ )
548
+ if hashlib.sha256(data).hexdigest().lower() != sha256_hex.lower():
549
+ raise ValueError("downloaded blob SHA-256 hash mismatch")
550
+ return data
551
+
552
+ @staticmethod
553
+ def head_blob(
554
+ server_url: str,
555
+ sha256_hex: str,
556
+ timeout: float = 5.0,
557
+ *,
558
+ allow_private: Any = False,
559
+ ) -> Optional[BlossomBlobMetadata]:
560
+ """Query blob metadata via HEAD request."""
561
+ base = server_url.rstrip('/')
562
+ url = f"{base}/{sha256_hex.lower()}"
563
+ try:
564
+ _, hdrs = netfetch.fetch(url, max_bytes=0, timeout=timeout, method='HEAD',
565
+ allow_private=allow_private)
566
+ except netfetch.FetchHTTPError as e:
567
+ if e.code == 404:
568
+ return None
569
+ raise
570
+ # HEAD legitimately declares the size without a body, so read the
571
+ # header ourselves rather than letting netfetch's Content-Length
572
+ # pre-check (which assumes a body follows) refuse it.
573
+ return {
574
+ 'sha256': sha256_hex.lower(),
575
+ 'size': int(hdrs.get('Content-Length', 0) or 0),
576
+ 'type': hdrs.get('Content-Type', 'application/octet-stream'),
577
+ 'cid': hdrs.get('X-CID'),
578
+ }
579
+
580
+ @staticmethod
581
+ def upload_blob(
582
+ server_url: str,
583
+ data: bytes,
584
+ content_type: str = 'application/octet-stream',
585
+ signer_sk: Optional[str] = None,
586
+ timeout: float = 30.0,
587
+ *,
588
+ allow_private: Any = False,
589
+ ) -> BlossomUploadDescriptor:
590
+ """Upload blob to Blossom server (BUD-01)."""
591
+ base = server_url.rstrip('/')
592
+ url = f"{base}/upload"
593
+ h = hashlib.sha256(data).hexdigest().lower()
594
+ headers = {'Content-Type': content_type}
595
+
596
+ if signer_sk:
597
+ ev = create_auth_event(signer_sk, 'upload', h)
598
+ headers['Authorization'] = auth_header(ev)
599
+
600
+ res_body, _ = netfetch.fetch(url, max_bytes=1 << 20, timeout=timeout, method='PUT',
601
+ data=data, headers=headers, allow_private=allow_private)
602
+ return json.loads(res_body.decode('utf-8'))
603
+
604
+ @staticmethod
605
+ def mirror_blob(
606
+ server_url: str,
607
+ source_url: str,
608
+ sha256_hex: str,
609
+ signer_sk: Optional[str] = None,
610
+ timeout: float = 30.0,
611
+ *,
612
+ allow_private: Any = False,
613
+ ) -> BlossomMirrorDescriptor:
614
+ """Mirror remote blob to Blossom server (BUD-04)."""
615
+ base = server_url.rstrip('/')
616
+ url = f"{base}/mirror"
617
+ h = sha256_hex.lower()
618
+ headers = {'Content-Type': 'application/json'}
619
+
620
+ if signer_sk:
621
+ ev = create_auth_event(signer_sk, 'mirror', h)
622
+ headers['Authorization'] = auth_header(ev)
623
+
624
+ req_body = json.dumps({'url': source_url}).encode('utf-8')
625
+ res_body, _ = netfetch.fetch(url, max_bytes=1 << 20, timeout=timeout, method='PUT',
626
+ data=req_body, headers=headers, allow_private=allow_private)
627
+ return json.loads(res_body.decode('utf-8'))