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,107 @@
1
+ """MobileLeafClient: the leaf side. Cold-start mediator discovery over
2
+ kind 11801 descriptors under the operator-independence rule, the
3
+ mediate-request it sends, the grant it records, and the forward it wraps
4
+ an inner envelope in."""
5
+ from __future__ import annotations
6
+
7
+ import json
8
+ import os
9
+ import time
10
+ from typing import Any, Mapping, Sequence
11
+
12
+ from fleetmesh_core.wire.interfaces import NostrEvent
13
+ from fleetmesh_core.wire.wire_types import ForwardBody, Message
14
+ from node import keystore
15
+ from .messages import make_forward, make_mediate_request
16
+
17
+
18
+ from .types import MediationGrantInfo
19
+
20
+
21
+ class MobileLeafClient:
22
+ """Mobile Leaf client implementing mediation negotiation and cold-start discovery."""
23
+
24
+ def __init__(self, ks: keystore.Keystore) -> None:
25
+ self.ks: keystore.Keystore = ks
26
+ self.did: str = f"did:nostr:{ks.node_pk}"
27
+ self.mediators: dict[str, MediationGrantInfo] = {} # mediator_did -> grant info
28
+
29
+ @staticmethod
30
+ def discover_mediators(
31
+ descriptors: Sequence[NostrEvent],
32
+ required_count: int = 2,
33
+ require_distinct_operators: bool = True,
34
+ ) -> list[NostrEvent]:
35
+ """Select independent mediator anchors per 3-Layer Operator Independence Model.
36
+
37
+ Spec reference: spec.html lines 3332-3348 (Cold-Start Mediator Discovery via NIP-66).
38
+ Filters for:
39
+ 1. Descriptor carries ['c', 'mediator'] capability hint.
40
+ 2. Carries WebSocket endpoint (wss:// or ws://).
41
+ 3. Selected anchors have distinct 'operator' pubkeys.
42
+ """
43
+ candidates: list[NostrEvent] = []
44
+ for ev in descriptors:
45
+ if ev.get('kind') != 11801:
46
+ continue
47
+ tags = ev.get('tags', [])
48
+ tag_map = {t[0]: [row[1:] for row in tags if row and row[0] == t[0]] for t in tags if t}
49
+
50
+ # Check mediator capability
51
+ c_tags = [c[0] for c in tag_map.get('c', []) if c]
52
+ if 'mediator' not in c_tags:
53
+ continue
54
+
55
+ # Check endpoint
56
+ eps = tag_map.get('endpoint', [])
57
+ has_ws = any(e and e[0] in ('wss', 'ws') for e in eps)
58
+ if not has_ws:
59
+ continue
60
+
61
+ candidates.append(ev)
62
+
63
+ # Select independent anchors by operator
64
+ selected: list[NostrEvent] = []
65
+ seen_operators: set[str] = set()
66
+
67
+ for cand in candidates:
68
+ # The comprehension above builds a different map (tag -> rows), so
69
+ # this one gets its own name rather than reusing `tags`.
70
+ first_values = {t[0]: t[1] for t in cand.get('tags', []) if len(t) > 1}
71
+ op = first_values.get('operator') or cand.get('pubkey')
72
+
73
+ if require_distinct_operators and op in seen_operators:
74
+ continue
75
+
76
+ seen_operators.add(op)
77
+ selected.append(cand)
78
+ if len(selected) >= required_count:
79
+ break
80
+
81
+ return selected
82
+
83
+ def build_mediate_request(self, mediator_did: str) -> Message[Mapping[str, Any]]:
84
+ return make_mediate_request(self.did, mediator_did)
85
+
86
+ def record_mediation_grant(self, grant_msg: Mapping[str, Any]) -> None:
87
+ med_did = grant_msg.get('from', '')
88
+ body = grant_msg.get('body', {})
89
+ self.mediators[med_did] = {
90
+ 'routing_keys': body.get('routing_keys', []),
91
+ 'endpoint': body.get('endpoint'),
92
+ 'granted_at': int(time.time()),
93
+ }
94
+
95
+ def wrap_forward(self, inner_envelope_json: str, mediator_did: str, recipient_leaf_did: str) -> Message[ForwardBody]:
96
+ """Wrap an inner encrypted payload into a routing/2.0 forward message."""
97
+ attachment = {
98
+ 'id': 'att-' + os.urandom(8).hex(),
99
+ 'data': {'json': json.loads(inner_envelope_json) if isinstance(inner_envelope_json, str) else inner_envelope_json},
100
+ }
101
+ return make_forward(
102
+ from_did=self.did,
103
+ to_did=mediator_did,
104
+ next_recipient=recipient_leaf_did,
105
+ attachments=[attachment],
106
+ )
107
+
@@ -0,0 +1,302 @@
1
+ """The wire: PIURIs, bounds, the two guards every handler shares, and the
2
+ constructors for every coordinate-mediation/2.0, routing/2.0 and
3
+ messagepickup/2.0 message this node sends.
4
+
5
+ Nothing here touches storage or the node; it is the vocabulary the service
6
+ and the leaf client both speak.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import os
11
+ from typing import Any, Mapping, Optional, Sequence
12
+
13
+ from fleetmesh_core.wire.wire_types import (
14
+ ForwardBody, KeylistUpdateBody, KeylistUpdateResponseBody, MediateDenyBody, MediateGrantBody,
15
+ Message, PickupDeliveryBody, PickupDeliveryRequestBody, PickupMessagesReceivedBody, PickupStatusBody,
16
+ PickupStatusRequestBody,
17
+ )
18
+
19
+ # Protocol PIURIs
20
+ MEDIATE_REQUEST = 'https://didcomm.org/coordinate-mediation/2.0/mediate-request'
21
+ MEDIATE_GRANT = 'https://didcomm.org/coordinate-mediation/2.0/mediate-grant'
22
+ MEDIATE_DENY = 'https://didcomm.org/coordinate-mediation/2.0/mediate-deny'
23
+ KEYLIST_UPDATE = 'https://didcomm.org/coordinate-mediation/2.0/keylist-update'
24
+ KEYLIST_UPDATE_RESPONSE = 'https://didcomm.org/coordinate-mediation/2.0/keylist-update-response'
25
+ KEYLIST_QUERY = 'https://didcomm.org/coordinate-mediation/2.0/keylist-query'
26
+ KEYLIST_RESPONSE = 'https://didcomm.org/coordinate-mediation/2.0/keylist-response'
27
+
28
+ ROUTING_FORWARD = 'https://didcomm.org/routing/2.0/forward'
29
+
30
+ PICKUP_STATUS_REQUEST = 'https://didcomm.org/messagepickup/2.0/status-request'
31
+ PICKUP_STATUS = 'https://didcomm.org/messagepickup/2.0/status'
32
+ PICKUP_DELIVERY_REQUEST = 'https://didcomm.org/messagepickup/2.0/delivery-request'
33
+ PICKUP_DELIVERY = 'https://didcomm.org/messagepickup/2.0/delivery'
34
+ PICKUP_MESSAGES_RECEIVED = 'https://didcomm.org/messagepickup/2.0/messages-received'
35
+
36
+ # The prefixes the Mediation subsystem takes off the ProtocolRouter.
37
+ PIURIS = (
38
+ 'https://didcomm.org/coordinate-mediation/2.0',
39
+ 'https://didcomm.org/routing/2.0',
40
+ 'https://didcomm.org/messagepickup/2.0',
41
+ )
42
+
43
+ # Bounds on one pickup delivery-request. `limit` is the leaf's own number and
44
+ # was taken as sent: 0 or a negative number is a LIMIT SQLite reads as "no
45
+ # limit", and a huge one is the whole inbox serialised into one reply. One
46
+ # drain per request, between one message and this many.
47
+ PICKUP_LIMIT_MIN: int = 1
48
+ PICKUP_LIMIT_MAX: int = 100
49
+ PICKUP_LIMIT_DEFAULT: int = 10
50
+
51
+
52
+ def clamp_pickup_limit(raw: Any) -> int:
53
+ """`limit` from a delivery-request as an int in [PICKUP_LIMIT_MIN, PICKUP_LIMIT_MAX]."""
54
+ try:
55
+ n = int(raw)
56
+ except (TypeError, ValueError):
57
+ n = PICKUP_LIMIT_DEFAULT
58
+ if isinstance(raw, bool):
59
+ n = PICKUP_LIMIT_DEFAULT
60
+ return max(PICKUP_LIMIT_MIN, min(PICKUP_LIMIT_MAX, n))
61
+
62
+
63
+ def leaf_of(msg: Mapping[str, Any], sender: Optional[str]) -> str:
64
+ """The DID a mediation message is acting for.
65
+
66
+ `msg['from']` is a plaintext field the sender wrote. On its own it is a
67
+ claim, and every mediation handler used it as the identity: a leaf that
68
+ wrote another leaf's DID into `from` drained that leaf's inbox and
69
+ rewrote its keylist. The authenticated identity is the authcrypt `skid`
70
+ of the envelope the message arrived in, which the transport layer hands
71
+ down as `sender`. When it is given, `from` must agree with it; when it is
72
+ not (an operator command, a test), `from` is all there is and is used.
73
+ """
74
+ claimed = msg.get('from', '') or ''
75
+ if sender:
76
+ if claimed and claimed != sender:
77
+ raise PermissionError('message from %s claims to be from %s' % (sender, claimed))
78
+ return sender
79
+ return claimed
80
+
81
+
82
+ # ---------------------------------------------------------------------------
83
+ # Message Constructors
84
+ # ---------------------------------------------------------------------------
85
+
86
+ def make_mediate_request(
87
+ from_did: str,
88
+ to_did: str,
89
+ thid: Optional[str] = None,
90
+ ) -> Message[Mapping[str, Any]]:
91
+ msg_id = 'med-req-' + os.urandom(8).hex()
92
+ msg: Message[Mapping[str, Any]] = {
93
+ 'id': msg_id,
94
+ 'type': MEDIATE_REQUEST,
95
+ 'from': from_did,
96
+ 'to': [to_did],
97
+ 'thid': thid or msg_id,
98
+ 'body': {},
99
+ }
100
+ return msg
101
+
102
+
103
+ def make_mediate_grant(
104
+ from_did: str,
105
+ to_did: str,
106
+ routing_keys: Sequence[str],
107
+ endpoint: Optional[str] = None,
108
+ thid: Optional[str] = None,
109
+ ) -> Message[MediateGrantBody]:
110
+ msg_id = 'med-grant-' + os.urandom(8).hex()
111
+ body: MediateGrantBody = {'routing_keys': list(routing_keys)}
112
+ if endpoint:
113
+ body['endpoint'] = endpoint
114
+ msg: Message[MediateGrantBody] = {
115
+ 'id': msg_id,
116
+ 'type': MEDIATE_GRANT,
117
+ 'from': from_did,
118
+ 'to': [to_did],
119
+ 'thid': thid or msg_id,
120
+ 'body': body,
121
+ }
122
+ return msg
123
+
124
+
125
+ def make_mediate_deny(
126
+ from_did: str,
127
+ to_did: str,
128
+ reason: str = 'capacity',
129
+ thid: Optional[str] = None,
130
+ ) -> Message[MediateDenyBody]:
131
+ msg_id = 'med-deny-' + os.urandom(8).hex()
132
+ msg: Message[MediateDenyBody] = {
133
+ 'id': msg_id,
134
+ 'type': MEDIATE_DENY,
135
+ 'from': from_did,
136
+ 'to': [to_did],
137
+ 'thid': thid or msg_id,
138
+ 'body': {'reason': reason},
139
+ }
140
+ return msg
141
+
142
+
143
+ def make_keylist_update(
144
+ from_did: str,
145
+ to_did: str,
146
+ updates: Sequence[Mapping[str, str]],
147
+ thid: Optional[str] = None,
148
+ ) -> Message[KeylistUpdateBody]:
149
+ msg_id = 'kl-upd-' + os.urandom(8).hex()
150
+ msg: Message[KeylistUpdateBody] = {
151
+ 'id': msg_id,
152
+ 'type': KEYLIST_UPDATE,
153
+ 'from': from_did,
154
+ 'to': [to_did],
155
+ 'thid': thid or msg_id,
156
+ 'body': {'updates': list(updates)},
157
+ }
158
+ return msg
159
+
160
+
161
+ def make_keylist_update_response(
162
+ from_did: str,
163
+ to_did: str,
164
+ updated: Sequence[Mapping[str, str]],
165
+ thid: Optional[str] = None,
166
+ ) -> Message[KeylistUpdateResponseBody]:
167
+ msg_id = 'kl-resp-' + os.urandom(8).hex()
168
+ msg: Message[KeylistUpdateResponseBody] = {
169
+ 'id': msg_id,
170
+ 'type': KEYLIST_UPDATE_RESPONSE,
171
+ 'from': from_did,
172
+ 'to': [to_did],
173
+ 'thid': thid or msg_id,
174
+ 'body': {'updated': list(updated)},
175
+ }
176
+ return msg
177
+
178
+
179
+ def make_forward(
180
+ from_did: str,
181
+ to_did: str,
182
+ next_recipient: str,
183
+ attachments: Sequence[Mapping[str, Any]],
184
+ thid: Optional[str] = None,
185
+ ) -> Message[ForwardBody]:
186
+ """DIDComm routing/2.0 forward message wrapping an inner encrypted envelope."""
187
+ msg_id = 'fwd-' + os.urandom(8).hex()
188
+ msg: Message[ForwardBody] = {
189
+ 'id': msg_id,
190
+ 'type': ROUTING_FORWARD,
191
+ 'from': from_did,
192
+ 'to': [to_did],
193
+ 'thid': thid or msg_id,
194
+ 'body': {'next': next_recipient},
195
+ 'attachments': list(attachments),
196
+ }
197
+ return msg
198
+
199
+
200
+ def make_pickup_status_request(
201
+ from_did: str,
202
+ to_did: str,
203
+ recipient_key: Optional[str] = None,
204
+ thid: Optional[str] = None,
205
+ ) -> Message[PickupStatusRequestBody]:
206
+ msg_id = 'pck-req-' + os.urandom(8).hex()
207
+ body: PickupStatusRequestBody = {}
208
+ if recipient_key:
209
+ body['recipient_key'] = recipient_key
210
+ msg: Message[PickupStatusRequestBody] = {
211
+ 'id': msg_id,
212
+ 'type': PICKUP_STATUS_REQUEST,
213
+ 'from': from_did,
214
+ 'to': [to_did],
215
+ 'thid': thid or msg_id,
216
+ 'body': body,
217
+ }
218
+ return msg
219
+
220
+
221
+ def make_pickup_status(
222
+ from_did: str,
223
+ to_did: str,
224
+ message_count: int = 0,
225
+ recipient_key: Optional[str] = None,
226
+ thid: Optional[str] = None,
227
+ ) -> Message[PickupStatusBody]:
228
+ msg_id = 'pck-stat-' + os.urandom(8).hex()
229
+ body: PickupStatusBody = {'message_count': message_count}
230
+ if recipient_key:
231
+ body['recipient_key'] = recipient_key
232
+ msg: Message[PickupStatusBody] = {
233
+ 'id': msg_id,
234
+ 'type': PICKUP_STATUS,
235
+ 'from': from_did,
236
+ 'to': [to_did],
237
+ 'thid': thid or msg_id,
238
+ 'body': body,
239
+ }
240
+ return msg
241
+
242
+
243
+ def make_pickup_delivery_request(
244
+ from_did: str,
245
+ to_did: str,
246
+ limit: int = 10,
247
+ recipient_key: Optional[str] = None,
248
+ thid: Optional[str] = None,
249
+ ) -> Message[PickupDeliveryRequestBody]:
250
+ msg_id = 'pck-del-req-' + os.urandom(8).hex()
251
+ body: PickupDeliveryRequestBody = {'limit': limit}
252
+ if recipient_key:
253
+ body['recipient_key'] = recipient_key
254
+ msg: Message[PickupDeliveryRequestBody] = {
255
+ 'id': msg_id,
256
+ 'type': PICKUP_DELIVERY_REQUEST,
257
+ 'from': from_did,
258
+ 'to': [to_did],
259
+ 'thid': thid or msg_id,
260
+ 'body': body,
261
+ }
262
+ return msg
263
+
264
+
265
+ def make_pickup_delivery(
266
+ from_did: str,
267
+ to_did: str,
268
+ messages: Sequence[Mapping[str, Any]],
269
+ recipient_key: Optional[str] = None,
270
+ thid: Optional[str] = None,
271
+ ) -> Message[PickupDeliveryBody]:
272
+ msg_id = 'pck-del-' + os.urandom(8).hex()
273
+ body: PickupDeliveryBody = {'messages': list(messages)}
274
+ if recipient_key:
275
+ body['recipient_key'] = recipient_key
276
+ msg: Message[PickupDeliveryBody] = {
277
+ 'id': msg_id,
278
+ 'type': PICKUP_DELIVERY,
279
+ 'from': from_did,
280
+ 'to': [to_did],
281
+ 'thid': thid or msg_id,
282
+ 'body': body,
283
+ }
284
+ return msg
285
+
286
+
287
+ def make_pickup_messages_received(
288
+ from_did: str,
289
+ to_did: str,
290
+ message_id_list: Sequence[str],
291
+ thid: Optional[str] = None,
292
+ ) -> Message[PickupMessagesReceivedBody]:
293
+ msg_id = 'pck-ack-' + os.urandom(8).hex()
294
+ msg: Message[PickupMessagesReceivedBody] = {
295
+ 'id': msg_id,
296
+ 'type': PICKUP_MESSAGES_RECEIVED,
297
+ 'from': from_did,
298
+ 'to': [to_did],
299
+ 'thid': thid or msg_id,
300
+ 'body': {'message_id_list': list(message_id_list)},
301
+ }
302
+ return msg
@@ -0,0 +1,233 @@
1
+ """MediatorService: the anchor side. Admits leaves, keeps their keylists,
2
+ buffers forwarded envelopes and drains them on pickup, bounded.
3
+
4
+ Every handler identifies the leaf by `leaf_of(msg, sender)` - the proven
5
+ envelope sender, with `from` allowed only to agree - and acts only on that
6
+ leaf's own keys and inbox. See the docstrings on `_keys_of` and
7
+ `handle_messages_received` for the two cross-leaf reads that rule closed.
8
+ """
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ import os
13
+ import threading
14
+ import time
15
+ from typing import Any, Callable, Mapping, Optional, Sequence
16
+
17
+ from fleetmesh_core.wire.wire_types import KeylistUpdateResponseBody, MediateDenyBody, MediateGrantBody, Message, PickupDeliveryBody
18
+ from .messages import (PICKUP_LIMIT_DEFAULT, ROUTING_FORWARD, clamp_pickup_limit, leaf_of,
19
+ make_keylist_update_response, make_mediate_deny, make_mediate_grant,
20
+ make_pickup_delivery)
21
+ from .store import MediationStore
22
+
23
+
24
+ from .types import DeliveredMessage
25
+
26
+
27
+ class MediatorService:
28
+ """Anchor-side mediation and store-and-forward buffer for mobile leaves."""
29
+
30
+ def __init__(
31
+ self,
32
+ db_path: str = ':memory:',
33
+ mediator_did: str = '',
34
+ routing_keys: Optional[Sequence[str]] = None,
35
+ clock: Optional[Callable[[], int]] = None,
36
+ max_buffered_messages: int = 1000,
37
+ db: Optional[Any] = None,
38
+ ) -> None:
39
+ self.db_path: str = db_path
40
+ self.mediator_did: str = mediator_did
41
+ self.routing_keys: list[str] = list(routing_keys or ([mediator_did + '#agree'] if mediator_did else []))
42
+ self.clock: Callable[[], int] = clock or (lambda: int(time.time()))
43
+ self.max_buffered_messages: int = max_buffered_messages
44
+ self._lock: threading.RLock = threading.RLock()
45
+ self.db: Any = db if db is not None else MediationStore(db_path)
46
+
47
+ def close(self) -> None:
48
+ """Safely close underlying SQLite persistence."""
49
+ self.db.close()
50
+
51
+ def handle_mediate_request(self, msg: Mapping[str, Any], sender: Optional[str] = None) -> Message[MediateGrantBody] | Message[MediateDenyBody]:
52
+ """Admit leaf into mediation and grant routing keys.
53
+
54
+ `sender` is the authenticated envelope sender (see `leaf_of`)."""
55
+ leaf_did = leaf_of(msg, sender)
56
+ if not leaf_did:
57
+ return make_mediate_deny(self.mediator_did, leaf_did, reason='missing from DID', thid=msg.get('id'))
58
+
59
+ now = self.clock()
60
+ with self.db.transaction() as cur:
61
+ cur.execute('''
62
+ INSERT OR REPLACE INTO leaves (leaf_did, admitted_at, status)
63
+ VALUES (?, ?, 'active')
64
+ ''', (leaf_did, now))
65
+ # Automatically register leaf_did as initial recipient_key
66
+ cur.execute('''
67
+ INSERT OR REPLACE INTO keylist (recipient_key, leaf_did, registered_at)
68
+ VALUES (?, ?, ?)
69
+ ''', (leaf_did, leaf_did, now))
70
+
71
+ return make_mediate_grant(
72
+ from_did=self.mediator_did,
73
+ to_did=leaf_did,
74
+ routing_keys=self.routing_keys,
75
+ thid=msg.get('id'),
76
+ )
77
+
78
+ def handle_keylist_update(self, msg: Mapping[str, Any], sender: Optional[str] = None) -> Message[KeylistUpdateResponseBody]:
79
+ """Update registered recipient keys for a leaf."""
80
+ leaf_did = leaf_of(msg, sender)
81
+ updates = msg.get('body', {}).get('updates', [])
82
+ updated_out: list[dict[str, str]] = []
83
+ now = self.clock()
84
+
85
+ with self.db.transaction() as cur:
86
+ for u in updates:
87
+ key = u.get('recipient_key')
88
+ action = u.get('action')
89
+ if not key:
90
+ continue
91
+ if action == 'add':
92
+ cur.execute('''
93
+ INSERT OR REPLACE INTO keylist (recipient_key, leaf_did, registered_at)
94
+ VALUES (?, ?, ?)
95
+ ''', (key, leaf_did, now))
96
+ updated_out.append({'recipient_key': key, 'action': 'add', 'result': 'success'})
97
+ elif action == 'remove':
98
+ cur_res = cur.execute('''
99
+ DELETE FROM keylist WHERE recipient_key = ? AND leaf_did = ?
100
+ ''', (key, leaf_did))
101
+ res = 'success' if cur_res.rowcount > 0 else 'no_change'
102
+ updated_out.append({'recipient_key': key, 'action': 'remove', 'result': res})
103
+
104
+ return make_keylist_update_response(
105
+ from_did=self.mediator_did,
106
+ to_did=leaf_did,
107
+ updated=updated_out,
108
+ thid=msg.get('id'),
109
+ )
110
+
111
+ def handle_forward(self, msg: Mapping[str, Any]) -> bool:
112
+ """Buffer inbound forward envelope for delivery to leaf."""
113
+ if msg.get('type') != ROUTING_FORWARD:
114
+ return False
115
+
116
+ next_recipient = msg.get('body', {}).get('next', '')
117
+ if not next_recipient:
118
+ return False
119
+
120
+ # Find registered leaf and verify queue bound
121
+ with self.db.transaction() as cur:
122
+ cur.execute('SELECT leaf_did FROM keylist WHERE recipient_key = ?', (next_recipient,))
123
+ row = cur.fetchone()
124
+ if not row:
125
+ # Check if next_recipient is an admitted leaf_did directly
126
+ cur.execute('SELECT leaf_did FROM leaves WHERE leaf_did = ?', (next_recipient,))
127
+ leaf_row = cur.fetchone()
128
+ if not leaf_row:
129
+ return False
130
+
131
+ # Bound queue to avoid memory/storage exhaustion
132
+ cur.execute('SELECT COUNT(*) FROM inbox WHERE recipient_key = ? AND delivered = 0', (next_recipient,))
133
+ count_row = cur.fetchone()
134
+ current_count = int(count_row[0] or 0) if count_row else 0
135
+ if current_count >= self.max_buffered_messages:
136
+ return False
137
+
138
+ # Store forwarded envelope attachments in inbox
139
+ attachments = msg.get('attachments', [])
140
+ payload_str = json.dumps(attachments, sort_keys=True)
141
+ msg_id = msg.get('id') or ('fwd-store-' + os.urandom(8).hex())
142
+ now = self.clock()
143
+
144
+ cur.execute('''
145
+ INSERT OR REPLACE INTO inbox (msg_id, recipient_key, sender_did, payload, enqueued_at, delivered)
146
+ VALUES (?, ?, ?, ?, ?, 0)
147
+ ''', (msg_id, next_recipient, msg.get('from', ''), payload_str, now))
148
+
149
+ return True
150
+
151
+ def get_queue_status(self, recipient_key: str) -> int:
152
+ row = self.db._one('SELECT COUNT(*) FROM inbox WHERE recipient_key = ? AND delivered = 0', (recipient_key,))
153
+ return int(row[0] or 0) if row else 0
154
+
155
+ def _keys_of(self, leaf_did: str) -> list[str]:
156
+ """Every recipient_key registered to this leaf, and nothing else.
157
+
158
+ The leaf's own DID is here only because mediate-request registered it.
159
+ A `recipient_key` a leaf merely names in a request is NOT added, which
160
+ is what let one leaf ask for another leaf's messages."""
161
+ return [r[0] for r in self.db._all('SELECT recipient_key FROM keylist WHERE leaf_did = ?', (leaf_did,))]
162
+
163
+ def handle_pickup_delivery_request(self, msg: Mapping[str, Any], sender: Optional[str] = None) -> Message[PickupDeliveryBody]:
164
+ """Drain queued forwarded envelopes for the requesting leaf."""
165
+ leaf_did = leaf_of(msg, sender)
166
+ limit = clamp_pickup_limit(msg.get('body', {}).get('limit', PICKUP_LIMIT_DEFAULT))
167
+ target_key = msg.get('body', {}).get('recipient_key') or leaf_did
168
+
169
+ messages_out: list[DeliveredMessage] = []
170
+ with self.db._lock:
171
+ keys = self._keys_of(leaf_did)
172
+ if target_key in keys:
173
+ keys = [target_key] # a named key narrows the drain to that key
174
+ if not keys:
175
+ return make_pickup_delivery(from_did=self.mediator_did, to_did=leaf_did, messages=[],
176
+ recipient_key=target_key, thid=msg.get('id'))
177
+
178
+ placeholders = ','.join('?' for _ in keys)
179
+ rows = self.db._all(f'''
180
+ SELECT msg_id, recipient_key, sender_did, payload, enqueued_at
181
+ FROM inbox WHERE recipient_key IN ({placeholders}) AND delivered = 0
182
+ ORDER BY enqueued_at ASC LIMIT ?
183
+ ''', (*keys, limit))
184
+
185
+ for mid, rk, sender, payload_str, enq in rows:
186
+ try:
187
+ attachments = json.loads(payload_str)
188
+ except Exception:
189
+ attachments = []
190
+ messages_out.append({
191
+ 'id': mid,
192
+ 'recipient_key': rk,
193
+ 'sender': sender,
194
+ 'attachments': attachments,
195
+ 'enqueued_at': enq,
196
+ })
197
+
198
+ return make_pickup_delivery(
199
+ from_did=self.mediator_did,
200
+ to_did=leaf_did,
201
+ messages=messages_out,
202
+ recipient_key=target_key,
203
+ thid=msg.get('id'),
204
+ )
205
+
206
+ def handle_messages_received(self, msg: Mapping[str, Any], sender: Optional[str] = None) -> int:
207
+ """Mark picked-up messages as delivered and purge.
208
+
209
+ Only messages addressed to one of THIS leaf's registered keys are
210
+ purged. The DELETE was by msg_id alone, so a leaf that learned or
211
+ guessed another leaf's message ids could destroy that leaf's mail."""
212
+ leaf_did = leaf_of(msg, sender)
213
+ mids = [m for m in msg.get('body', {}).get('message_id_list', []) if isinstance(m, str)]
214
+ if not mids:
215
+ return 0
216
+
217
+ with self.db.transaction() as cur:
218
+ keys = [r[0] for r in cur.execute('SELECT recipient_key FROM keylist WHERE leaf_did = ?',
219
+ (leaf_did,)).fetchall()]
220
+ if not keys:
221
+ return 0
222
+ purged = 0
223
+ # Chunked so a long ack cannot exceed SQLite's bound-variable limit.
224
+ step = 400
225
+ for i in range(0, len(mids), step):
226
+ chunk = mids[i:i + step]
227
+ cur_del = cur.execute(f'''
228
+ DELETE FROM inbox WHERE msg_id IN ({','.join('?' for _ in chunk)})
229
+ AND recipient_key IN ({','.join('?' for _ in keys)})
230
+ ''', (*chunk, *keys))
231
+ purged += cur_del.rowcount
232
+ return purged
233
+
@@ -0,0 +1,37 @@
1
+ """SQLite persistence for mediation: admitted leaves, their keylists, and
2
+ the buffered inbox a leaf drains on pickup."""
3
+ from __future__ import annotations
4
+
5
+ from node.compartment.schema import Column, Index, Table
6
+ from node.storage import SqliteStore
7
+
8
+
9
+ class MediationStore(SqliteStore):
10
+ """Thread-safe SQLite store for mediation leaves, keylists, and buffered inboxes."""
11
+
12
+ SCHEMA = (
13
+ Table('leaves', (
14
+ Column('leaf_did', 'TEXT', primary_key=True),
15
+ Column('admitted_at', 'INTEGER', not_null=True),
16
+ Column('status', 'TEXT', not_null=True, default="'active'"),
17
+ )),
18
+ Table('keylist', (
19
+ Column('recipient_key', 'TEXT', primary_key=True),
20
+ Column('leaf_did', 'TEXT', not_null=True),
21
+ Column('registered_at', 'INTEGER', not_null=True),
22
+ ), indices=(
23
+ Index('idx_keylist_leaf', ('leaf_did',)),
24
+ )),
25
+ Table('inbox', (
26
+ Column('msg_id', 'TEXT', primary_key=True),
27
+ Column('recipient_key', 'TEXT', not_null=True),
28
+ Column('sender_did', 'TEXT'),
29
+ Column('payload', 'TEXT', not_null=True),
30
+ Column('enqueued_at', 'INTEGER', not_null=True),
31
+ Column('delivered', 'INTEGER', not_null=True, default=0),
32
+ ), indices=(
33
+ Index('idx_inbox_recip', ('recipient_key', 'delivered')),
34
+ )),
35
+ )
36
+
37
+