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,464 @@
1
+ """The Peering compartment: construction, the grant writers (`issue`,
2
+ `decide`), the reconciler that is its tick, and the compartment contract.
3
+ The handshake itself is in handshake.py; the envelope in envelope.py."""
4
+ from __future__ import annotations
5
+
6
+ import secrets
7
+ from typing import Any, Callable, cast, Mapping, Optional, Sequence
8
+
9
+ from fleetmesh_core.accounting import window
10
+ from fleetmesh_core.identity import identity
11
+ from fleetmesh_core.peering import peering
12
+ from fleetmesh_core.services import discover
13
+ from fleetmesh_core.wire.interfaces import GrantPayload, NostrEvent
14
+ from fleetmesh_core.wire.wire_types import AmendCause, PeeringAmendBody
15
+ from node import channels as ch_mod
16
+ from node.channels import bus as eventbus_mod
17
+ from fleetmesh_core.identity.did import did_nostr
18
+ from node.storage import grants as grants_mod
19
+ from node import metrics as mx_mod
20
+ from node.compartment import stores as st
21
+ from node.protocols.nostr import client as relay
22
+ from node.config.records import PolicyConfig
23
+ from node.compartment import Command, Handler, Need, Outbound, Refusal, WireProtocol
24
+ from .envelope import CAUSES, GRANT_KIND, GrantUnpublished, LOG_KEEP, PeeringError, RECONCILE_EVERY, WireNode, open_envelope
25
+ from .handshake import HandshakeMixin
26
+ from .shape import PeeringHost
27
+
28
+
29
+ from .types import (
30
+ Issued,
31
+ PeeringDecision,
32
+ PeeringStateReport,
33
+ )
34
+
35
+
36
+ class Peering(HandshakeMixin, WireProtocol):
37
+ """The handshake and the grants, as a compartment.
38
+
39
+ Its tick is the reconciler: an amend is a notification and the addressable
40
+ kind 30801 is what the grant IS, so a subject reads the coordinate rather
41
+ than waiting to be told. Its refusals are every amend it would not act on
42
+ and every peering it turned away - the path that recorded nothing at all on
43
+ the night every amend on the mesh was lost.
44
+ """
45
+
46
+ name: str = 'peering'
47
+ reads: tuple[str, ...] = ('policy',)
48
+ config_cls = PolicyConfig
49
+ # Nothing here reaches link, trust or strain: the handshake runs on the
50
+ # context's keystore, descriptor, peers, transport, policy and grant
51
+ # store. Capacity is a back-edge (bind), not a requirement.
52
+ requires: tuple[str, ...] = ()
53
+ piuris: tuple[str, ...] = (peering.PIURI,)
54
+ exact_types: tuple[str, ...] = (discover.QUERIES,)
55
+ HANDLERS = (
56
+ Handler(discover.QUERIES, run='handle_discover', exact=True, doc='answer discover-features/2.0 query'),
57
+ Handler('propose', run='handle_propose', writes=('grants',), doc='answer peering proposal'),
58
+ Handler('accept', run='handle_accept', writes=('grants',), doc='take peering acceptance'),
59
+ Handler('amend', run='handle_amend', writes=('grants',), doc='take amended grant'),
60
+ Handler('terminate', run='handle_terminate', writes=('grants',), doc='tear down peering'),
61
+ )
62
+ OUTBOUND = (
63
+ Outbound('propose', reply=('offer', 'decline'), doc='send peering proposal to peer'),
64
+ Outbound('offer', reply='accept', doc='offer peering terms to proposer'),
65
+ Outbound('accept', doc='accept offered peering terms'),
66
+ Outbound('decline', doc='decline peering proposal with code and reason'),
67
+ Outbound('amend', doc='notify peer of amended grant'),
68
+ Outbound('terminate', doc='terminate active peering relationship'),
69
+ )
70
+ COMMANDS = (
71
+ Command(
72
+ key='show',
73
+ name='Peering',
74
+ description='the peerings this node holds, and when each grant was last reconciled',
75
+ tags=('peering', 'status'),
76
+ permission='peering/show',
77
+ run='state',
78
+ reply='{peers, reconciled, refused}',
79
+ ),
80
+ )
81
+
82
+ # What it keeps. The log was a list appended once per inbound peering
83
+ # message - including the ones it ignored or failed on, so a stranger
84
+ # could grow it - and never trimmed. The newest LOG_KEEP, the same as the
85
+ # node's own log beside it.
86
+ STORES = (
87
+ st.Store(key='log', description='what the handshake did with each message, newest last',
88
+ kind=st.RING, shape=tuple, bound=LOG_KEEP),
89
+ st.Store(key='_reconciled', description='when each peer grant coordinate was last read',
90
+ kind=st.TABLE, shape=int, bound=4096, keyed_by='peer', evict=st.LRU),
91
+ )
92
+ CHANNELS = (
93
+ ch_mod.Channel('grant_issued', 'A peering grant was issued or amended for a peer',
94
+ level=ch_mod.INFO, wire=eventbus_mod._wire_grant,
95
+ say='peering grant issued to {peer} (cause: {cause}, tier: {tier})',
96
+ tags=('peering', 'grant')),
97
+ ch_mod.Channel('peer_connected', 'A peer completed the peering handshake and connected',
98
+ level=ch_mod.INFO, wire=eventbus_mod._wire_peer,
99
+ say='peer connected: {peer} as {role} via {transport}',
100
+ tags=('peering', 'handshake')),
101
+ ch_mod.Channel('peering_declined', 'Peering proposal was declined',
102
+ level=ch_mod.TROUBLE, say='peering proposal declined from {peer}: {reason}',
103
+ tags=('peering', 'decline')),
104
+ ch_mod.Channel('peering_terminated', 'Peering relationship was terminated',
105
+ level=ch_mod.NOTE, say='peering terminated with {peer}: {reason}',
106
+ tags=('peering', 'terminate')),
107
+ )
108
+ METRICS = (
109
+ mx_mod.Metric(name='peering_grants_issued', description='Grants issued or amended.',
110
+ kind=mx_mod.COUNTER, counts='grant_issued', tags=('peering', 'grant')),
111
+ mx_mod.Metric(name='peering_peers_connected', description='Peers connected.',
112
+ kind=mx_mod.COUNTER, counts='peer_connected', tags=('peering', 'handshake')),
113
+ mx_mod.Metric(name='peering_proposals_declined', description='Peering proposals declined.',
114
+ kind=mx_mod.COUNTER, counts='peering_declined', tags=('peering', 'decline')),
115
+ mx_mod.Metric(name='peering_relationships_terminated', description='Peering relationships terminated.',
116
+ kind=mx_mod.COUNTER, counts='peering_terminated', tags=('peering', 'terminate')),
117
+ )
118
+ NEEDS = (
119
+ Need('keystore', 'ks'),
120
+ Need('my_descriptor', 'descriptor'),
121
+ Need('peers'),
122
+ Need('transport'),
123
+ Need('relay_urls', 'relays'),
124
+ Need('grant_store', 'stores.grants'),
125
+ Need('policy'),
126
+ Need('node_class'),
127
+ Need('timeout'),
128
+ Need('events'),
129
+ Need('capacity', late=True),
130
+ )
131
+
132
+ def bind(self, capacity: Any) -> None:
133
+ """The node's own ceiling, through which a stranger is refused.
134
+ Capacity ticks after budget, which ticks after peering, so it is
135
+ bound once all three exist."""
136
+ self.capacity = capacity
137
+
138
+ def start(self) -> None:
139
+ if self.events is not None:
140
+ events = self.events
141
+ self.on_grant_issued.append(lambda *a: events.emit(eventbus_mod.TOPIC_GRANT_ISSUED, *a))
142
+ self.on_peered.append(lambda *a: events.emit(eventbus_mod.TOPIC_PEER_CONNECTED, *a))
143
+
144
+ def __init__(
145
+ self,
146
+ keystore: Any,
147
+ my_descriptor: Any,
148
+ peers: Any,
149
+ transport: Any,
150
+ relay_urls: Sequence[str],
151
+ grant_store: Optional[grants_mod.GrantStore] = None,
152
+ policy: Optional[Any] = None,
153
+ node_class: str = 'edge',
154
+ clock: Optional[Callable[[], int]] = None,
155
+ timeout: float = 15.0,
156
+ capacity: Optional[Any] = None,
157
+ node: Optional[Any] = None,
158
+ publisher: Optional[Callable[[Sequence[str], Any, float], dict[str, tuple[bool, str]]]] = None,
159
+ host: Optional[PeeringHost] = None,
160
+ events: Optional[Any] = None,
161
+ ) -> None:
162
+ super().__init__(node, clock)
163
+ self.capacity = capacity # the node's own ceiling, or None
164
+ self.events = events
165
+ # How a grant envelope reaches the relays; the control-plane publish,
166
+ # not the peer transport. Given so a test can make every relay refuse.
167
+ self.publisher: Callable[[Sequence[str], Any, float], dict[str, tuple[bool, str]]] = publisher or relay.publish_all
168
+ self._reconciled: st.Table[str, int] # peer -> when its grant coordinate was last read
169
+ self.ks: Any = keystore
170
+ desc = my_descriptor() if callable(my_descriptor) else my_descriptor
171
+ self.descriptor: Any = desc
172
+ self.peers: Any = peers
173
+ self.transport: Any = transport
174
+ self.relays: list[str] = list(relay_urls) # not a store: the configured relays
175
+ self.grants: grants_mod.GrantStore = grant_store or grants_mod.GrantStore()
176
+ self.timeout: float = timeout
177
+ # The peering state machine. fleetmesh_core decides; WireNode adds the
178
+ # real envelope. Anything satisfying PeeringHost may stand in (R11).
179
+ self.host: PeeringHost = host or WireNode(keystore.node_sk, desc, policy, node_class, clock=self.clock)
180
+ self.log: st.Ring[tuple[Any, ...]] # built and bound by the base (see STORES)
181
+ # callbacks the node bridges onto its event bus; the compartment does not know the bus
182
+ self.on_grant_issued: list[Callable[[str, str, str, Optional[str]], Any]] = [
183
+ lambda peer, cause, env_id, tier: self.say('grant_issued', peer=peer, cause=cause, envelope_id=env_id, tier=tier)
184
+ ] # (peer, cause, envelope_id, tier)
185
+ def _say_peered(peer: str, role: str, transport: Optional[str] = None) -> Any:
186
+ return self.say('peer_connected', peer=peer, role=role, transport=transport or 'direct')
187
+
188
+ self.on_peered: list[Callable[[str, str, Optional[str]], Any]] = [_say_peered]
189
+
190
+ # ---- helpers -----------------------------------------------------------
191
+ def handle_discover(self, inbound: Any) -> Optional[tuple[Any, ...]]:
192
+ """discover-features/2.0: a peer asks what this node speaks and is
193
+ answered from the descriptor, by fleetmesh_core/peering's disclose, so the
194
+ answer cannot name a protocol the descriptor does not. Anyone may
195
+ ask; the answer is what the descriptor already says in public."""
196
+ msg, sender = getattr(inbound, 'message', None), getattr(inbound, 'sender', None)
197
+ if not isinstance(msg, dict) or not isinstance(sender, str) or not discover.is_queries(msg):
198
+ return None
199
+ reply = discover.disclose(self.ks.did, did_nostr(sender), str(msg.get('id', '')),
200
+ self.host.disclose(discover.query_list(msg) or None))
201
+ try:
202
+ self.wire_client.send(sender, reply)
203
+ except Exception as e: # the asker may hold no descriptor we can address; nothing to do
204
+ return ('ignored', sender, 'disclose not deliverable: %s' % e)
205
+ return ('disclosed', sender, len(reply['body']['disclosures']))
206
+
207
+ def _flush_envelopes(self) -> dict[str, dict[str, tuple[bool, str]]]:
208
+ out: dict[str, dict[str, tuple[bool, str]]] = {}
209
+ while self.host.published:
210
+ ev = self.host.published.pop(0)
211
+ out[ev['id']] = self.publisher(self.relays, ev, self.timeout)
212
+ return out
213
+
214
+ def _fetch_envelope(self, envelope_id: Optional[str], issuer_pk: str) -> NostrEvent:
215
+ """The grant this issuer currently publishes for the two of us.
216
+
217
+ A kind 30801 is addressable, and every grant between one pair carries
218
+ the same blinded `d`, so each new grant replaces the last one at that
219
+ coordinate. Fetching by the id an amend happens to name therefore works
220
+ only until the next amend: two grants issued in the same second tie on
221
+ created_at, NIP-01 breaks the tie on the lower id, and about half the
222
+ time the *newer* envelope is the one the relay drops. The subject then
223
+ asks for an id nothing serves.
224
+
225
+ So the coordinate is what is asked for, and the named id is a
226
+ preference within the answer. An amend is a notification that the grant
227
+ changed; the addressable event is what the grant *is*.
228
+ """
229
+ d = identity.grant_tag(self.ks.node_sk, issuer_pk)
230
+ found = relay.query_all(self.relays, [{'kinds': [GRANT_KIND], 'authors': [issuer_pk],
231
+ '#d': [d]}], self.timeout)
232
+ if not found:
233
+ raise PeeringError('this issuer publishes no grant for us; %s was named'
234
+ % (envelope_id or '?')[:16])
235
+ exact = [e for e in found if e['id'] == envelope_id]
236
+ if exact:
237
+ return exact[0]
238
+ # NIP-01 supersession order: latest created_at, lower id breaks a tie.
239
+ return sorted(found, key=lambda e: (-e['created_at'], e['id']))[0]
240
+
241
+ def reconcile(self, now: Optional[int] = None) -> list[tuple[Any, ...]]:
242
+ """Take up a grant that changed, without waiting to be told.
243
+
244
+ An amend is a notification; the addressable kind 30801 is what the grant
245
+ IS, and this method is that sentence made true. A subject used to learn
246
+ of a new grant only from a peering/1.0/amend, which travels as a stored
247
+ gift wrap - kind 1059, one of the four messages the specification says
248
+ must survive an offline recipient.
249
+
250
+ A relay is not obliged to keep one. `relay.pocketstr.com` answers a
251
+ kind 1059 publish with OK and true, and then neither stores it nor
252
+ serves it back to the recipient itself. Every amend on a four-node mesh
253
+ was lost that way for a night. The two ends then name different grants
254
+ for ever: every receipt is refused as divergent, so every window closes
255
+ with no claim, so every window is active silence, so every peering earns
256
+ an S2 that nobody did anything to deserve, and the tier falls to
257
+ probation where only a person can raise it.
258
+
259
+ Asking the coordinate costs one query per peering and depends on nothing
260
+ arriving. A protocol that must not conclude anything from an absence
261
+ must also not depend on a presence.
262
+ """
263
+ now_val = now if now is not None else self.clock()
264
+ did: list[tuple[Any, ...]] = []
265
+ for peer in self.grants.peers():
266
+ held = self.grants.get(peer, grants_mod.RECEIVED)
267
+ if held is None or held['status'] != 'live':
268
+ continue
269
+ if now_val - self._reconciled.get(peer, 0) < RECONCILE_EVERY:
270
+ continue
271
+ self._reconciled[peer] = now_val
272
+ before = held['envelope_id']
273
+ try:
274
+ # No id is named on purpose: a named id is a preference within
275
+ # the answer, and what is wanted here is whatever the issuer
276
+ # publishes now.
277
+ self._take_received(None, peer)
278
+ except Exception as e:
279
+ self._refuse('reconcile', 'could not read the grant this issuer publishes',
280
+ peer, {'error': '%s: %s' % (type(e).__name__, e)})
281
+ continue
282
+ now_held = self.grants.get(peer, grants_mod.RECEIVED)
283
+ after = now_held['envelope_id'] if now_held is not None else None
284
+ if after and after != before:
285
+ did.append(('grant reconciled', peer, before[:12], after[:12]))
286
+ return did
287
+
288
+ def _refuse(self, kind: str, why: str, subject: Optional[str] = None, detail: Optional[Mapping[str, Any]] = None) -> Any:
289
+ return self.refusals.refuse(kind, why, subject=subject, detail=detail, at=self.clock())
290
+
291
+ @property
292
+ def refused(self) -> list[Refusal]:
293
+ """The last refusals, as the typed tuple Refusals keeps (positional
294
+ reads still work; a NamedTuple is a tuple)."""
295
+ return list(self.refusals.last(200))
296
+
297
+ def issue(
298
+ self,
299
+ peer: str,
300
+ payload: Mapping[str, Any],
301
+ cause: str,
302
+ reason: str,
303
+ effective_epoch: int,
304
+ now: Optional[int] = None,
305
+ on_clamp: Optional[Callable[[str, str, dict[str, list[Any]]], Any]] = None,
306
+ ) -> Issued:
307
+ """Write, publish and record one grant, and tell the subject why.
308
+
309
+ `effective_epoch` is the window this grant governs from. Passing the
310
+ window already running is what breaks a receipt chain, so a caller that
311
+ means "from the next boundary" should say so with `decide` instead of
312
+ working it out here.
313
+
314
+ Raises GrantUnpublished if no relay took the envelope, having recorded
315
+ nothing: a grant the subject cannot fetch is worse than no grant.
316
+ Raises ValueError for a cause the schema does not define.
317
+ """
318
+ if cause not in CAUSES:
319
+ raise ValueError('cause %r is not one the amend schema defines: %s'
320
+ % (cause, ', '.join(CAUSES)))
321
+ now_val = self.clock() if now is None else now
322
+ tier = payload.get('tier')
323
+ if not isinstance(tier, str):
324
+ # ceilings() would raise a bare KeyError on None a line later; the
325
+ # payload is the caller's and the fault should be named as such.
326
+ raise ValueError('grant payload for %s names no tier' % peer[:16])
327
+ # Every tier has a ceiling and a grant MUST NOT set a limit above it.
328
+ # Only one of the three writers checked; the other two took whatever
329
+ # payload they were handed.
330
+ capped = window.within(payload.get('limits') or {}, tier)
331
+ if capped != (payload.get('limits') or {}):
332
+ over = {k: [payload['limits'][k], capped[k]] for k in capped
333
+ if capped[k] != payload['limits'][k]}
334
+ self.refusals.refuse('grant', 'carried limits above its tier ceiling', subject=peer,
335
+ detail={'tier': tier, 'over': over}, at=now_val)
336
+ if on_clamp is not None:
337
+ on_clamp(peer, tier, over)
338
+ payload = dict(payload, limits={k: (int(v) if float(v).is_integer() else v)
339
+ for k, v in capped.items()})
340
+ # The cast is the boundary between the operator's untyped payload (from
341
+ # app.py / budget.py) and the protocol's GrantPayload shape; the keys
342
+ # were checked by the schema when the grant was written, not here.
343
+ grant = cast(GrantPayload, dict(payload, blinding=secrets.token_bytes(32).hex()))
344
+ env = self.host._publish_envelope(peer, grant, now_val)
345
+ published = self._flush_envelopes()
346
+ took = {url: note for url, (ok_, note) in (published.get(env['id']) or {}).items() if ok_}
347
+ if self.relays and not took:
348
+ why = {url: note for url, (_, note) in (published.get(env['id']) or {}).items()}
349
+ self.refusals.refuse('grant', 'no relay would take the envelope', subject=peer,
350
+ detail={'envelope': env['id'], 'cause': cause, 'relays': why}, at=now_val)
351
+ raise GrantUnpublished(
352
+ 'the grant for %s was not recorded: no relay took envelope %s (%s)'
353
+ % (peer[:16], env['id'][:12],
354
+ '; '.join('%s: %s' % (u, n or 'refused') for u, n in sorted(why.items())) or 'no relays'))
355
+ self.host.issued[peer] = grant
356
+ self.grants.put(peer, grants_mod.ISSUED, grant, env['id'], now_val, event=env,
357
+ env_created_at=env['created_at'], effective_epoch=effective_epoch)
358
+ self.grants.clear_pending(peer)
359
+ # `cause` passed the CAUSES guard above, so the Literal holds; the
360
+ # envelope is signed because WireNode signs, which the Protocol does
361
+ # not promise - a host that returned an unsigned one would fail here
362
+ # at the relay, not silently.
363
+ amend_body: PeeringAmendBody = {'reason': reason, 'cause': cast(AmendCause, cause),
364
+ 'new_grant_envelope': env['id'], 'envelope_event': cast(NostrEvent, env)}
365
+ self.wire_client.send(peer, self.host._msg('amend', amend_body,
366
+ did_nostr(peer)), stored=True)
367
+ for cb in self.on_grant_issued:
368
+ cb(peer, cause, env['id'], tier)
369
+ return {'envelope': cast(NostrEvent, env), 'payload': grant, 'published': published, 'cause': cause}
370
+
371
+ def decide(
372
+ self,
373
+ peer: str,
374
+ payload: Mapping[str, Any],
375
+ reason: str,
376
+ severity: Optional[str],
377
+ decided_epoch: int,
378
+ ) -> PeeringDecision:
379
+ """Decide a grant now and let it take effect at the next boundary.
380
+
381
+ An amend inside a window makes the two ends name different grants for
382
+ the rest of it: the subject's receipts still cite the grant it holds,
383
+ the issuer has moved on, and every one is refused as naming a grant
384
+ that did not govern that window. The window then closes on a claim one
385
+ receipt short of what was measured, which is divergence, which is an S2
386
+ against a node that did nothing wrong.
387
+
388
+ So a decision is written down and published at the boundary. The store
389
+ keeps one pending grant per peer and a later decision replaces an
390
+ earlier one, which is how an operator's word outranks the ladder's
391
+ without either having to know about the other.
392
+ """
393
+ self.grants.put_pending(peer, payload, reason, severity, decided_epoch)
394
+ return {'payload': payload, 'effective_from': decided_epoch + 1}
395
+
396
+ # ---- the compartment contract -------------------------------------------------
397
+ def tick(self, now: int) -> list[tuple[Any, ...]]:
398
+ return self.reconcile(now)
399
+
400
+ def state(self) -> PeeringStateReport:
401
+ return {
402
+ 'peers': len(self.grants.peers()),
403
+ 'reconciled': {p[:16]: at for p, at in self._reconciled.items()},
404
+ 'refused': self.refusals.total(),
405
+ # Peers that proposed from a different schema than this build. Not
406
+ # a fault - they peer on what we share - but a mesh on several
407
+ # schemas is something an operator should see, so it is here and
408
+ # in trouble().
409
+ 'digest_mismatch': {p[:16]: d[:16] for p, d in self.host.digest_mismatch.items()},
410
+ }
411
+
412
+ def trouble(self) -> list[str]:
413
+ out: list[str] = []
414
+ recent = self.refusals.within(self.TROUBLE_WINDOW, self.clock(), kind='amend')
415
+ if recent:
416
+ out.append('%d amend(s) could not be acted on: %s. The two ends will name different '
417
+ 'grants until a reconcile fixes it'
418
+ % (len(recent), '; '.join(sorted({r.why for r in recent}))[:120]))
419
+ drift = self.host.digest_mismatch
420
+ if drift:
421
+ out.append('%d peer(s) were built from another schema (%s): peering holds on the protocols '
422
+ 'shared with each; `meshnode peers` names them' % (
423
+ len(drift), ', '.join(sorted({d[:8] for d in drift.values()}))))
424
+ return out
425
+
426
+ def _take_received(
427
+ self,
428
+ envelope_id: Optional[str],
429
+ issuer_pk: str,
430
+ envelope_event: Optional[Mapping[str, Any]] = None,
431
+ ) -> GrantPayload:
432
+ """Read the grant this issuer now publishes for us, and keep it.
433
+
434
+ Two things this must not do. It must not record the id an amend named
435
+ while holding a different envelope's payload, which is how the two came
436
+ to disagree about the tier in force. And it must not move backwards:
437
+ amends can arrive out of order and a grant is only superseded forwards,
438
+ so an envelope older than the one already held is left alone.
439
+ """
440
+ env: Mapping[str, Any]
441
+ if envelope_event is not None and (envelope_id is None or envelope_event.get('id') == envelope_id):
442
+ env = envelope_event
443
+ else:
444
+ env = self._fetch_envelope(envelope_id, issuer_pk)
445
+ held = self.grants.get(issuer_pk, grants_mod.RECEIVED)
446
+ if held is not None and held.get('env_created_at') is not None:
447
+ if (env['created_at'], env['id']) < (held['env_created_at'], held['envelope_id']):
448
+ return held['payload']
449
+ if envelope_id is None and held is not None and held['status'] != 'live' and env['created_at'] <= held['updated_at']:
450
+ # This node zeroed the grant it held, on a terminate or a
451
+ # revocation, and the issuer republishes nothing when it
452
+ # terminates: the coordinate goes on naming the grant it ended.
453
+ # Reading it back on the next reconcile made a terminate undo
454
+ # itself - the peer that sent one while this node was down saw its
455
+ # own grant come back live. An envelope no newer than the zero is
456
+ # the one it ended; only one the issuer made afterwards is a new
457
+ # grant, and that is a new peering, which `propose` begins - and a
458
+ # handshake names its envelope, so this blind read is never it.
459
+ return held['payload']
460
+ grant = open_envelope(env, self.ks.node_sk)
461
+ self.grants.put(issuer_pk, grants_mod.RECEIVED, grant, env['id'], self.clock(),
462
+ env_created_at=env['created_at'], event=env)
463
+ return grant
464
+
@@ -0,0 +1,142 @@
1
+ """The kind 30801 grant envelope: how one is written and signed (WireNode)
2
+ and how the subject opens and checks one (open_envelope). Pure protocol;
3
+ nothing here knows about the transport, the store or the node."""
4
+ from __future__ import annotations
5
+
6
+ import json
7
+ import threading
8
+ import time
9
+ from typing import Any, Callable, Mapping, Optional, cast
10
+
11
+ from fleetmesh_core.crypto import crypto, nip44
12
+ from fleetmesh_core.identity import identity
13
+ from fleetmesh_core.peering import peering
14
+ from fleetmesh_core.storage import store
15
+ from fleetmesh_core.wire import schema_files
16
+ from fleetmesh_core.wire.interfaces import GrantPayload, NostrEvent
17
+
18
+ GRANT_KIND: int = 30801
19
+
20
+ # What may cause a grant to change, read from the schema rather than written
21
+ # out again here. Three call sites each spelled their own cause as a bare
22
+ # string; all three happened to be right, and nothing would have said so if one
23
+ # were not. The subject's rules turn on this word - only `remedy` is adverse -
24
+ # so a typo would file a fault against an honest issuer, or hide a real one.
25
+ _SCHEMA: str = schema_files.schema_path('didcomm', 'peering', 'amend.schema.json')
26
+ with open(_SCHEMA, encoding='utf-8') as _f:
27
+ CAUSES: tuple[str, ...] = tuple(json.load(_f)['properties']['body']['properties']['cause']['enum'])
28
+
29
+
30
+ class PeeringError(Exception):
31
+ pass
32
+
33
+
34
+ class GrantUnpublished(PeeringError):
35
+ """No relay would take the envelope, so the grant was not recorded.
36
+
37
+ The envelope IS the grant: the subject fetches it by id, opens it with the
38
+ conversation key and checks the commitment. An issuer that records a grant
39
+ whose envelope reached no relay has written down a decision the subject can
40
+ never read, and will then refuse every receipt that names the grant the
41
+ subject still holds - for ever, because nothing retries and nothing says
42
+ why. So the store is left alone and this is raised instead, naming what
43
+ each relay said.
44
+ """
45
+
46
+
47
+ # How often a subject re-reads the grant its issuer publishes. An amend usually
48
+ # arrives and this changes nothing; when one does not, this is the whole of what
49
+ # keeps the two ends naming the same grant.
50
+ RECONCILE_EVERY: int = 300
51
+ # How much of the handshake log is kept: the node's own log keeps as many.
52
+ LOG_KEEP: int = 500
53
+
54
+
55
+
56
+ class WireNode(peering.Node):
57
+ """The reference node with one method made real: the envelope it publishes."""
58
+
59
+ def __init__(
60
+ self,
61
+ sk_hex: str,
62
+ descriptor: Any,
63
+ policy: Optional[Any] = None,
64
+ node_class: str = 'edge',
65
+ clock: Optional[Callable[[], int]] = None,
66
+ ) -> None:
67
+ super().__init__(sk_hex, descriptor, policy, node_class)
68
+ self.clock: Callable[[], int] = clock or (lambda: int(time.time()))
69
+ self.published: list[NostrEvent] = [] # kind 30801 events awaiting publication
70
+ self._last_envelope_at: dict[str, int] = {} # subject -> created_at of the last one written
71
+ # The serve thread answers a propose with an offer while the tick
72
+ # thread's own propose comes back with an accept, and both write a
73
+ # grant for the same subject. Unlocked, both read the same `last` and
74
+ # stamped the same second, which is exactly the tie the guard below
75
+ # exists to prevent; on the rig the relay kept one and the issuer the other.
76
+ self._envelope_lock = threading.Lock()
77
+
78
+ def _publish_envelope(self, subject: str, grant: GrantPayload, now: Optional[int]) -> NostrEvent:
79
+ """Write and sign the kind 30801 for one grant.
80
+
81
+ Every grant to one subject carries the same blinded `d`, so each new one
82
+ replaces the last at that addressable coordinate - and NIP-01 breaks a
83
+ created_at tie on the *lower* id, so two grants written in the same
84
+ second leave the relay holding whichever id sorts first, which is the
85
+ older one about half the time. The issuer then believes it granted
86
+ something the subject can never read. A grant is only superseded
87
+ forwards, so the timestamp moves forwards too.
88
+ """
89
+ with self._envelope_lock:
90
+ now_val = now or self.clock()
91
+ last = self._last_envelope_at.get(subject)
92
+ if last is not None and now_val <= last:
93
+ now_val = last + 1
94
+ self._last_envelope_at[subject] = now_val
95
+ return self._write_envelope(subject, grant, now_val)
96
+
97
+ def _write_envelope(self, subject: str, grant: GrantPayload, now_val: int) -> NostrEvent:
98
+ d = identity.grant_tag(self.sk, subject)
99
+ canon = crypto.canonical(grant)
100
+ commit = crypto.commit(canon, grant['blinding'])
101
+ window_s = grant['window']
102
+ tags = [['d', d], ['commit', commit],
103
+ ['scheme', peering.CONSTANTS['commitment']['scheme'], 'nip44/2'],
104
+ ['window', str(window_s)], ['epoch', str(now_val // window_s)],
105
+ ['expiration', str(now_val + window_s * 24)]]
106
+ conv = nip44.conversation_key(self.sk, subject)
107
+ ev = store.make_signed_event(self.sk, now_val, GRANT_KIND, tags, nip44.encrypt(canon.decode(), conv))
108
+ self.envelopes[subject] = ev
109
+ self.published.append(ev)
110
+ return ev
111
+
112
+
113
+ def open_envelope(envelope: NostrEvent | Mapping[str, Any], my_sk: str) -> GrantPayload:
114
+ """The grant inside a kind 30801 addressed to me, verified against its tags.
115
+
116
+ Three things must hold: the d tag is the blinded pair identifier I derive
117
+ myself; the ciphertext opens under the conversation key; and the commit
118
+ tag opens to the plaintext under the blinding the plaintext carries.
119
+ """
120
+ if envelope.get('kind') != GRANT_KIND:
121
+ raise PeeringError('not a kind 30801')
122
+ issuer = envelope['pubkey']
123
+ tags = {t[0]: t for t in envelope.get('tags', []) if t and isinstance(t, (list, tuple))}
124
+ d_tag = tags.get('d')
125
+ d_val = d_tag[1] if d_tag and len(d_tag) > 1 else None
126
+ if not d_val or d_val != identity.grant_tag(my_sk, issuer):
127
+ raise PeeringError('the d tag is not the pair identifier I derive with this issuer')
128
+ try:
129
+ plain = nip44.decrypt(envelope['content'], nip44.conversation_key(my_sk, issuer))
130
+ grant = json.loads(plain)
131
+ except (nip44.Nip44Error, ValueError) as e:
132
+ raise PeeringError('the envelope does not open: %s' % e)
133
+ if grant.get('subject') != crypto.pubkey(my_sk):
134
+ raise PeeringError('the grant names another subject')
135
+ commit_tag = tags.get('commit')
136
+ commit_val = commit_tag[1] if commit_tag and len(commit_tag) > 1 else None
137
+ if not commit_val or not crypto.open_commit(commit_val, crypto.canonical(grant), grant.get('blinding', '')):
138
+ raise PeeringError('the commitment does not open to the grant')
139
+ # json.loads gives a dict; the three checks above are what makes it a
140
+ # grant addressed to me. Its keys are node/validate.py's business.
141
+ return cast(GrantPayload, grant)
142
+