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.
- fleetmesh_node-0.2.0.dist-info/METADATA +57 -0
- fleetmesh_node-0.2.0.dist-info/RECORD +338 -0
- fleetmesh_node-0.2.0.dist-info/WHEEL +5 -0
- fleetmesh_node-0.2.0.dist-info/entry_points.txt +2 -0
- fleetmesh_node-0.2.0.dist-info/licenses/LICENSE +121 -0
- fleetmesh_node-0.2.0.dist-info/top_level.txt +2 -0
- node/RELEASE +1 -0
- node/__init__.py +35 -0
- node/app/__init__.py +49 -0
- node/app/commands.py +265 -0
- node/app/config.py +131 -0
- node/app/control.py +670 -0
- node/app/core.py +867 -0
- node/app/plugins.py +76 -0
- node/app/router.py +83 -0
- node/app/socket.py +431 -0
- node/app/systemd.py +36 -0
- node/app/views.py +204 -0
- node/channels/__init__.py +378 -0
- node/channels/bus.py +331 -0
- node/channels/journal.py +46 -0
- node/commands/__init__.py +140 -0
- node/commands/adapter.py +210 -0
- node/commands/base.py +278 -0
- node/commands/cluster_cmds.py +183 -0
- node/commands/compute_cmds.py +401 -0
- node/commands/core.py +474 -0
- node/commands/gossip_cmds.py +106 -0
- node/commands/nsite_cmds.py +141 -0
- node/commands/peering.py +402 -0
- node/commands/plugin_cmds.py +135 -0
- node/commands/sidecar_cmds.py +120 -0
- node/commands/sync.py +388 -0
- node/commands/transport_cmds.py +451 -0
- node/commands/video_cmds.py +99 -0
- node/compartment/__init__.py +124 -0
- node/compartment/base.py +868 -0
- node/compartment/command.py +458 -0
- node/compartment/context.py +163 -0
- node/compartment/every.py +108 -0
- node/compartment/handler.py +131 -0
- node/compartment/lifecycle.py +460 -0
- node/compartment/lint.py +388 -0
- node/compartment/need.py +158 -0
- node/compartment/problem.py +521 -0
- node/compartment/refusals.py +120 -0
- node/compartment/registry.py +790 -0
- node/compartment/schema.py +290 -0
- node/compartment/sidecar.py +499 -0
- node/compartment/stores.py +662 -0
- node/compartment/wire.py +225 -0
- node/compartment/worker.py +61 -0
- node/config/__init__.py +43 -0
- node/config/base.py +116 -0
- node/config/change.py +441 -0
- node/config/changestore.py +201 -0
- node/config/compartments.py +1144 -0
- node/config/node.py +318 -0
- node/config/parameter.py +563 -0
- node/config/protocols.py +274 -0
- node/config/records.py +171 -0
- node/examples/__init__.py +1 -0
- node/examples/demo.py +226 -0
- node/examples/follower.py +46 -0
- node/interfaces.py +235 -0
- node/keystore/__init__.py +27 -0
- node/keystore/core.py +103 -0
- node/keystore/enclave.py +688 -0
- node/meshnode.py +111 -0
- node/metrics/__init__.py +396 -0
- node/metrics/adapters.py +102 -0
- node/metrics/face.py +110 -0
- node/planes/__init__.py +21 -0
- node/planes/identity/__init__.py +20 -0
- node/planes/identity/announce/__init__.py +9 -0
- node/planes/identity/announce/compartment.py +447 -0
- node/planes/identity/announce/types.py +26 -0
- node/planes/identity/discovery/__init__.py +9 -0
- node/planes/identity/discovery/compartment.py +522 -0
- node/planes/identity/discovery/types.py +13 -0
- node/planes/identity/linked/__init__.py +1 -0
- node/planes/identity/linked/compartment.py +270 -0
- node/planes/identity/linked/types.py +25 -0
- node/planes/identity/presence/__init__.py +7 -0
- node/planes/identity/presence/compartment.py +409 -0
- node/planes/identity/presence/types.py +14 -0
- node/planes/operations/__init__.py +13 -0
- node/planes/operations/scheduler/__init__.py +9 -0
- node/planes/operations/scheduler/compartment.py +353 -0
- node/planes/operations/scheduler/types.py +21 -0
- node/planes/operations/updates/__init__.py +17 -0
- node/planes/operations/updates/core.py +310 -0
- node/planes/operations/updates/store.py +79 -0
- node/planes/operations/updates/types.py +20 -0
- node/planes/peering/__init__.py +28 -0
- node/planes/peering/budget/__init__.py +43 -0
- node/planes/peering/budget/chain.py +43 -0
- node/planes/peering/budget/core.py +537 -0
- node/planes/peering/budget/evaluation.py +80 -0
- node/planes/peering/budget/meter.py +100 -0
- node/planes/peering/budget/receipts.py +439 -0
- node/planes/peering/budget/shape.py +101 -0
- node/planes/peering/budget/tick.py +110 -0
- node/planes/peering/budget/types.py +67 -0
- node/planes/peering/budget/windows.py +346 -0
- node/planes/peering/capacity/__init__.py +7 -0
- node/planes/peering/capacity/compartment.py +342 -0
- node/planes/peering/capacity/types.py +24 -0
- node/planes/peering/peering/__init__.py +25 -0
- node/planes/peering/peering/core.py +464 -0
- node/planes/peering/peering/envelope.py +142 -0
- node/planes/peering/peering/handshake.py +198 -0
- node/planes/peering/peering/shape.py +80 -0
- node/planes/peering/peering/types.py +39 -0
- node/planes/peering/reporter/__init__.py +42 -0
- node/planes/peering/reporter/constants.py +29 -0
- node/planes/peering/reporter/core.py +299 -0
- node/planes/peering/reporter/finding.py +74 -0
- node/planes/peering/reporter/findings.py +171 -0
- node/planes/peering/reporter/publish.py +285 -0
- node/planes/peering/reporter/reading.py +193 -0
- node/planes/peering/reporter/shape.py +86 -0
- node/planes/peering/reporter/types.py +50 -0
- node/planes/peering/strain/__init__.py +9 -0
- node/planes/peering/strain/compartment.py +204 -0
- node/planes/peering/strain/types.py +21 -0
- node/planes/peering/trust/__init__.py +9 -0
- node/planes/peering/trust/compartment.py +239 -0
- node/planes/peering/trust/types.py +30 -0
- node/planes/services/__init__.py +33 -0
- node/planes/services/compute/__init__.py +160 -0
- node/planes/services/compute/common.py +134 -0
- node/planes/services/compute/compartment.py +681 -0
- node/planes/services/compute/executors/__init__.py +121 -0
- node/planes/services/compute/executors/agent.py +840 -0
- node/planes/services/compute/executors/bacalhau.py +605 -0
- node/planes/services/compute/executors/base.py +127 -0
- node/planes/services/compute/executors/harness.py +439 -0
- node/planes/services/compute/executors/mcp.py +454 -0
- node/planes/services/compute/executors/media.py +898 -0
- node/planes/services/compute/executors/nondet.py +283 -0
- node/planes/services/compute/executors/providers.py +499 -0
- node/planes/services/compute/executors/registry.py +140 -0
- node/planes/services/compute/executors/wasm.py +244 -0
- node/planes/services/compute/executors/zk.py +604 -0
- node/planes/services/compute/jobs.py +227 -0
- node/planes/services/compute/load.py +180 -0
- node/planes/services/compute/market.py +433 -0
- node/planes/services/compute/sandbox.py +282 -0
- node/planes/services/compute/types.py +84 -0
- node/planes/services/compute_wire/__init__.py +58 -0
- node/planes/services/compute_wire/compartment.py +256 -0
- node/planes/services/compute_wire/messages.py +320 -0
- node/planes/services/compute_wire/sealing.py +147 -0
- node/planes/services/compute_wire/session.py +572 -0
- node/planes/services/compute_wire/types.py +49 -0
- node/planes/services/compute_wire/verify.py +331 -0
- node/planes/services/dvm/__init__.py +24 -0
- node/planes/services/dvm/gateway.py +526 -0
- node/planes/services/dvm/protocol.py +35 -0
- node/planes/services/dvm/translate.py +15 -0
- node/planes/services/dvm/types.py +25 -0
- node/planes/services/gateway/__init__.py +40 -0
- node/planes/services/gateway/settle.py +279 -0
- node/planes/services/gateway/translate.py +644 -0
- node/planes/services/gossip/__init__.py +22 -0
- node/planes/services/gossip/core.py +929 -0
- node/planes/services/gossip/envelope.py +154 -0
- node/planes/services/gossip/mesh.py +127 -0
- node/planes/services/gossip/types.py +50 -0
- node/planes/services/nsite/__init__.py +7 -0
- node/planes/services/nsite/client.py +62 -0
- node/planes/services/nsite/compartment.py +752 -0
- node/planes/services/nsite/types.py +44 -0
- node/planes/services/paidfetch/__init__.py +7 -0
- node/planes/services/paidfetch/compartment.py +862 -0
- node/planes/services/paidfetch/types.py +99 -0
- node/planes/services/settle/__init__.py +27 -0
- node/planes/services/settle/compartment.py +384 -0
- node/planes/services/settle/engine.py +456 -0
- node/planes/services/settle/gateways.py +541 -0
- node/planes/services/settle/payment.py +512 -0
- node/planes/services/settle/store.py +74 -0
- node/planes/services/settle/types.py +58 -0
- node/planes/services/sidecars/__init__.py +8 -0
- node/planes/services/sidecars/compartment.py +461 -0
- node/planes/services/sidecars/manifest.py +304 -0
- node/planes/services/sidecars/manifests/10-grasp.json +37 -0
- node/planes/services/sidecars/manifests/20-ncontainer.json +31 -0
- node/planes/services/sidecars/manifests/30-magpie.json +28 -0
- node/planes/services/sidecars/types.py +43 -0
- node/planes/services/video/__init__.py +106 -0
- node/planes/services/video/barter.py +407 -0
- node/planes/services/video/cli.py +158 -0
- node/planes/services/video/compartment.py +932 -0
- node/planes/services/video/ingest.py +524 -0
- node/planes/services/video/ledger.py +705 -0
- node/planes/services/video/packager.py +847 -0
- node/planes/services/video/player.py +456 -0
- node/planes/services/video/timed_segments.py +124 -0
- node/planes/services/video/transit.py +478 -0
- node/planes/store/__init__.py +24 -0
- node/planes/store/blob_replication/__init__.py +7 -0
- node/planes/store/blob_replication/compartment.py +496 -0
- node/planes/store/blob_replication/types.py +35 -0
- node/planes/store/blob_wire/__init__.py +48 -0
- node/planes/store/blob_wire/client.py +57 -0
- node/planes/store/blob_wire/core.py +359 -0
- node/planes/store/blob_wire/intake.py +74 -0
- node/planes/store/blob_wire/messages.py +85 -0
- node/planes/store/projected/__init__.py +7 -0
- node/planes/store/projected/compartment.py +258 -0
- node/planes/store/projected/types.py +29 -0
- node/planes/store/retention/__init__.py +7 -0
- node/planes/store/retention/compartment.py +214 -0
- node/planes/store/retention/types.py +12 -0
- node/planes/store/sync/__init__.py +35 -0
- node/planes/store/sync/core.py +311 -0
- node/planes/store/sync/envelope.py +106 -0
- node/planes/store/sync/handover.py +206 -0
- node/planes/store/sync/initiator.py +128 -0
- node/planes/store/sync/responder.py +114 -0
- node/planes/store/sync/rules.py +179 -0
- node/planes/store/sync/shape.py +80 -0
- node/planes/store/sync/types.py +51 -0
- node/planes/transport/__init__.py +23 -0
- node/planes/transport/bridge/__init__.py +13 -0
- node/planes/transport/bridge/compartment.py +785 -0
- node/planes/transport/bridge/ratelimit.py +44 -0
- node/planes/transport/bridge/types.py +43 -0
- node/planes/transport/lan/__init__.py +9 -0
- node/planes/transport/lan/compartment.py +446 -0
- node/planes/transport/lan/types.py +25 -0
- node/planes/transport/link/__init__.py +7 -0
- node/planes/transport/link/compartment.py +257 -0
- node/planes/transport/link/types.py +27 -0
- node/planes/transport/mediation/__init__.py +45 -0
- node/planes/transport/mediation/compartment.py +238 -0
- node/planes/transport/mediation/leaf.py +107 -0
- node/planes/transport/mediation/messages.py +302 -0
- node/planes/transport/mediation/service.py +233 -0
- node/planes/transport/mediation/store.py +37 -0
- node/planes/transport/mediation/types.py +20 -0
- node/planes/transport/p2p/__init__.py +7 -0
- node/planes/transport/p2p/compartment.py +349 -0
- node/planes/transport/p2p/types.py +22 -0
- node/protocols/__init__.py +277 -0
- node/protocols/activitypub/__init__.py +56 -0
- node/protocols/activitypub/actor.py +230 -0
- node/protocols/activitypub/federation.py +490 -0
- node/protocols/activitypub/media.py +145 -0
- node/protocols/activitypub/models.py +286 -0
- node/protocols/activitypub/package.py +271 -0
- node/protocols/activitypub/store.py +564 -0
- node/protocols/arcade/__init__.py +26 -0
- node/protocols/atproto/__init__.py +13 -0
- node/protocols/atproto/firehose.py +126 -0
- node/protocols/atproto/metering.py +165 -0
- node/protocols/atproto/package.py +155 -0
- node/protocols/atproto/repo.py +250 -0
- node/protocols/atproto/xrpc.py +285 -0
- node/protocols/base.py +109 -0
- node/protocols/circles/__init__.py +29 -0
- node/protocols/dm/__init__.py +50 -0
- node/protocols/grasp/__init__.py +22 -0
- node/protocols/isotime.py +15 -0
- node/protocols/nostr/__init__.py +51 -0
- node/protocols/nostr/blobstore.py +383 -0
- node/protocols/nostr/blossom.py +627 -0
- node/protocols/nostr/client.py +377 -0
- node/protocols/nostr/descriptor.py +211 -0
- node/protocols/nostr/eventstore.py +304 -0
- node/protocols/nostr/package.py +182 -0
- node/protocols/nostr/peers.py +109 -0
- node/protocols/nostr/relayserver.py +656 -0
- node/protocols/p2panda/__init__.py +22 -0
- node/protocols/p2panda/identity.py +19 -0
- node/protocols/p2panda/package.py +236 -0
- node/protocols/p2panda/rung.py +152 -0
- node/protocols/p2panda/store.py +273 -0
- node/protocols/rhio/__init__.py +33 -0
- node/protocols/rhio/package.py +275 -0
- node/protocols/rhio/rung.py +136 -0
- node/protocols/rhio/store.py +1423 -0
- node/protocols/ssb/muxrpc.py +256 -0
- node/protocols/ssb/package.py +267 -0
- node/protocols/textile/__init__.py +111 -0
- node/protocols/textile/transducers.py +259 -0
- node/release.py +119 -0
- node/storage/__init__.py +88 -0
- node/storage/accounts.py +305 -0
- node/storage/adapter.py +161 -0
- node/storage/base.py +385 -0
- node/storage/dialect.py +209 -0
- node/storage/grants.py +317 -0
- node/storage/locator.py +150 -0
- node/storage/postgres.py +294 -0
- node/storage/projected.py +953 -0
- node/storage/sharding.py +448 -0
- node/storage/standing.py +568 -0
- node/testnet/__init__.py +9 -0
- node/testnet/cluster.py +731 -0
- node/testnet/fips_daemon.py +230 -0
- node/transport/__init__.py +94 -0
- node/transport/const.py +110 -0
- node/transport/didcomm_inbound.py +251 -0
- node/transport/dnstxt.py +169 -0
- node/transport/fips_rung/__init__.py +67 -0
- node/transport/fips_rung/core.py +666 -0
- node/transport/fips_rung/listener.py +96 -0
- node/transport/fips_rung/types.py +12 -0
- node/transport/host.py +334 -0
- node/transport/iroh_rung/__init__.py +29 -0
- node/transport/iroh_rung/core.py +477 -0
- node/transport/iroh_rung/quic.py +942 -0
- node/transport/iroh_rung/types.py +22 -0
- node/transport/ladder.py +145 -0
- node/transport/lineproto.py +49 -0
- node/transport/mdns.py +303 -0
- node/transport/netfetch.py +283 -0
- node/transport/relay_rung/__init__.py +23 -0
- node/transport/relay_rung/cooldown.py +87 -0
- node/transport/relay_rung/core.py +138 -0
- node/transport/relay_rung/inbound.py +161 -0
- node/transport/relay_rung/ledger.py +104 -0
- node/transport/relay_rung/listen.py +121 -0
- node/transport/relay_rung/outbound.py +181 -0
- node/transport/relay_rung/shape.py +71 -0
- node/transport/relay_rung/types.py +58 -0
- node/transport/relay_rung/wire.py +63 -0
- node/transport/router.py +761 -0
- node/transport/rung.py +276 -0
- node/transport/ssb_rung.py +166 -0
- node/transport/ws.py +502 -0
- sidecar/fleetmesh_announce.py +338 -0
- sidecar/fleetmesh_p2p.py +373 -0
- sidecar/fleetmesh_rhio.py +352 -0
- sidecar/fleetmesh_ssb.py +603 -0
|
@@ -0,0 +1,377 @@
|
|
|
1
|
+
"""A nostr relay client: publish an event and wait for its OK, or query.
|
|
2
|
+
|
|
3
|
+
The only module in the node that speaks the relay wire (REQ/EVENT/OK/EOSE/
|
|
4
|
+
AUTH). Two kinds of caller: the relay rung (node.transport.relay_rung), for
|
|
5
|
+
gift wraps to and from peers, and the control-plane publishers (descriptor,
|
|
6
|
+
presence, discovery, reports), for the node's own signed events. Neither is
|
|
7
|
+
the other's business, so they share this and nothing else.
|
|
8
|
+
|
|
9
|
+
The node talks to relays for three things: to publish its own descriptor,
|
|
10
|
+
grants and reports; to read other nodes' descriptors; and, as its default
|
|
11
|
+
transport, to send and receive gift wraps. Query collects until EOSE; a
|
|
12
|
+
subscription yields events as they arrive and is how the transport listens.
|
|
13
|
+
"""
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import json
|
|
17
|
+
import logging
|
|
18
|
+
import os
|
|
19
|
+
import socket
|
|
20
|
+
import time
|
|
21
|
+
from typing import Any, Callable, cast, Iterator, Mapping, Optional, Sequence
|
|
22
|
+
|
|
23
|
+
from node.interfaces import EventLike, NostrEvent, NostrFilter
|
|
24
|
+
from node.transport import ws
|
|
25
|
+
|
|
26
|
+
log = logging.getLogger(__name__)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class RelayError(Exception):
|
|
30
|
+
pass
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
# A subscription that receives nothing is either quiet or dead, and a socket
|
|
34
|
+
# that was dropped without a close frame looks quiet forever. Past PING_AFTER
|
|
35
|
+
# seconds of silence the client pings; past DEAD_AFTER with no frame back,
|
|
36
|
+
# not even a pong, the subscription is declared dead and the listener
|
|
37
|
+
# reconnects.
|
|
38
|
+
PING_AFTER: float = 60.0
|
|
39
|
+
DEAD_AFTER: float = 150.0
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class Relay:
|
|
43
|
+
def __init__(
|
|
44
|
+
self,
|
|
45
|
+
url: str,
|
|
46
|
+
timeout: float = 15.0,
|
|
47
|
+
auth_signer: Optional[Callable[[str, str], Optional[EventLike]]] = None,
|
|
48
|
+
ping_after: float = PING_AFTER,
|
|
49
|
+
dead_after: float = DEAD_AFTER,
|
|
50
|
+
on_eose: Optional[Callable[[str], None]] = None,
|
|
51
|
+
) -> None:
|
|
52
|
+
self.url: str = url
|
|
53
|
+
self.timeout: float = timeout
|
|
54
|
+
# Liveness thresholds are per connection so a test (or an operator
|
|
55
|
+
# on a flaky link) sets them on the one Relay, not on the module.
|
|
56
|
+
self.ping_after: float = ping_after
|
|
57
|
+
self.dead_after: float = dead_after
|
|
58
|
+
self.auth_signer: Optional[Callable[[str, str], Optional[EventLike]]] = auth_signer
|
|
59
|
+
self.on_eose: Optional[Callable[[str], None]] = on_eose
|
|
60
|
+
self.ws: Optional[ws.WebSocket] = None
|
|
61
|
+
self._n: int = 0
|
|
62
|
+
self._subs: dict[str, list[NostrFilter | Mapping[str, Any]]] = {}
|
|
63
|
+
self._pending: dict[str, list[NostrEvent]] = {} # events read while waiting for an EOSE
|
|
64
|
+
self._confirmed: set[str] = set() # subscriptions the relay answered with an EOSE
|
|
65
|
+
|
|
66
|
+
def connect(self) -> Relay:
|
|
67
|
+
if self.ws is None:
|
|
68
|
+
# Every way the handshake can fail is one answer to the caller:
|
|
69
|
+
# the relay is not reachable. ws.connect raises OSError for the
|
|
70
|
+
# socket (refused, timed out, DNS), ws.ConnectionClosed for a
|
|
71
|
+
# peer that closed mid-handshake or sent an endless header, and
|
|
72
|
+
# ConnectionError for a refused upgrade (both are OSError
|
|
73
|
+
# subclasses; named so a reader need not know that). Callers
|
|
74
|
+
# that catch RelayError must not see a bare ConnectionClosed.
|
|
75
|
+
try:
|
|
76
|
+
self.ws = ws.connect(self.url, self.timeout)
|
|
77
|
+
except (OSError, ws.ConnectionClosed) as e:
|
|
78
|
+
raise RelayError('%s: %s: %s' % (self.url, type(e).__name__, e)) from e
|
|
79
|
+
if hasattr(self.ws.sock, 'settimeout'):
|
|
80
|
+
self.ws.sock.settimeout(self.timeout)
|
|
81
|
+
return self
|
|
82
|
+
|
|
83
|
+
def close(self) -> None:
|
|
84
|
+
if self.ws is not None:
|
|
85
|
+
self.ws.close()
|
|
86
|
+
self.ws = None
|
|
87
|
+
self._subs.clear()
|
|
88
|
+
|
|
89
|
+
def _send(self, msg: list[Any]) -> None:
|
|
90
|
+
self.connect()
|
|
91
|
+
assert self.ws is not None
|
|
92
|
+
self.ws.send_text(json.dumps(msg, separators=(',', ':')))
|
|
93
|
+
|
|
94
|
+
def _recv(self) -> list[Any]:
|
|
95
|
+
assert self.ws is not None
|
|
96
|
+
raw = self.ws.recv_text()
|
|
97
|
+
try:
|
|
98
|
+
msg = json.loads(raw)
|
|
99
|
+
except ValueError:
|
|
100
|
+
raise RelayError('relay sent something that is not JSON')
|
|
101
|
+
if not isinstance(msg, list) or not msg:
|
|
102
|
+
raise RelayError('relay sent a malformed message')
|
|
103
|
+
return msg
|
|
104
|
+
|
|
105
|
+
def _handle_auth(self, challenge: str) -> bool:
|
|
106
|
+
"""Sign and send a NIP-42 AUTH message if auth_signer is configured.
|
|
107
|
+
|
|
108
|
+
Returns True if an AUTH response was sent.
|
|
109
|
+
"""
|
|
110
|
+
if self.auth_signer is not None:
|
|
111
|
+
try:
|
|
112
|
+
auth_ev = self.auth_signer(self.url, challenge)
|
|
113
|
+
except Exception:
|
|
114
|
+
# The signer is caller-supplied code (keystore, HSM, test
|
|
115
|
+
# stub); a fault in it means "no AUTH", which the caller
|
|
116
|
+
# sees as the relay refusing the subscription. Logged, since
|
|
117
|
+
# a broken signer is a bug and not a relay condition.
|
|
118
|
+
log.exception('relay %s: auth_signer failed', self.url)
|
|
119
|
+
return False
|
|
120
|
+
if auth_ev is not None:
|
|
121
|
+
self._send(['AUTH', auth_ev])
|
|
122
|
+
return True
|
|
123
|
+
return False
|
|
124
|
+
|
|
125
|
+
def _sub_id(self) -> str:
|
|
126
|
+
self._n += 1
|
|
127
|
+
return 'sub-%d-%s' % (self._n, os.urandom(3).hex())
|
|
128
|
+
|
|
129
|
+
def publish(self, event: EventLike) -> tuple[bool, str]:
|
|
130
|
+
"""(accepted, message) for one event."""
|
|
131
|
+
self._send(['EVENT', event])
|
|
132
|
+
deadline = time.time() + self.timeout
|
|
133
|
+
authed = False
|
|
134
|
+
retried = False
|
|
135
|
+
last_rejected_reason: Optional[str] = None
|
|
136
|
+
while time.time() < deadline:
|
|
137
|
+
msg = self._recv()
|
|
138
|
+
if msg[0] == 'AUTH' and len(msg) > 1 and isinstance(msg[1], str):
|
|
139
|
+
if self._handle_auth(msg[1]):
|
|
140
|
+
authed = True
|
|
141
|
+
if last_rejected_reason and 'auth-required' in last_rejected_reason and not retried:
|
|
142
|
+
retried = True
|
|
143
|
+
self._send(['EVENT', event])
|
|
144
|
+
continue
|
|
145
|
+
if msg[0] == 'OK' and len(msg) > 2:
|
|
146
|
+
if msg[1] == event['id']:
|
|
147
|
+
accepted = bool(msg[2])
|
|
148
|
+
reason = msg[3] if len(msg) > 3 else ''
|
|
149
|
+
if not accepted and 'auth-required' in reason:
|
|
150
|
+
last_rejected_reason = reason
|
|
151
|
+
if authed and not retried:
|
|
152
|
+
retried = True
|
|
153
|
+
self._send(['EVENT', event])
|
|
154
|
+
continue
|
|
155
|
+
if self.auth_signer and not retried:
|
|
156
|
+
# Wait for AUTH challenge if one arrives
|
|
157
|
+
continue
|
|
158
|
+
return accepted, reason
|
|
159
|
+
# OK for another event (e.g. kind 22242 AUTH response); continue
|
|
160
|
+
continue
|
|
161
|
+
if msg[0] == 'NOTICE':
|
|
162
|
+
return False, 'NOTICE: %s' % (msg[1] if len(msg) > 1 else '')
|
|
163
|
+
if last_rejected_reason is not None:
|
|
164
|
+
return False, last_rejected_reason
|
|
165
|
+
return False, 'no OK within %.0fs' % self.timeout
|
|
166
|
+
|
|
167
|
+
def query(self, filters: Sequence[NostrFilter | Mapping[str, Any]], limit_time: Optional[float] = None) -> list[NostrEvent]:
|
|
168
|
+
"""Every stored event matching the filters, until EOSE."""
|
|
169
|
+
sid = self._sub_id()
|
|
170
|
+
self._subs[sid] = list(filters)
|
|
171
|
+
self._send(['REQ', sid] + list(filters))
|
|
172
|
+
out: list[NostrEvent] = []
|
|
173
|
+
seen: set[str] = set()
|
|
174
|
+
deadline = time.time() + (limit_time or self.timeout)
|
|
175
|
+
authed = False
|
|
176
|
+
re_requested = False
|
|
177
|
+
while time.time() < deadline:
|
|
178
|
+
msg = self._recv()
|
|
179
|
+
if msg[0] == 'AUTH' and len(msg) > 1 and isinstance(msg[1], str):
|
|
180
|
+
if self._handle_auth(msg[1]):
|
|
181
|
+
authed = True
|
|
182
|
+
if not re_requested:
|
|
183
|
+
re_requested = True
|
|
184
|
+
self._send(['REQ', sid] + self._subs[sid])
|
|
185
|
+
continue
|
|
186
|
+
if msg[0] == 'EVENT' and len(msg) > 2 and msg[1] == sid:
|
|
187
|
+
ev = msg[2]
|
|
188
|
+
# Anything that is not an object with an id is not an event and
|
|
189
|
+
# every caller indexes ev['id'], so it is dropped here rather
|
|
190
|
+
# than crash the first reader.
|
|
191
|
+
if isinstance(ev, dict) and isinstance(ev.get('id'), str) and ev['id'] not in seen:
|
|
192
|
+
seen.add(ev['id'])
|
|
193
|
+
# The wire boundary: what a relay serves under EVENT is an
|
|
194
|
+
# event by contract, and callers verify the ones they trust.
|
|
195
|
+
out.append(cast(NostrEvent, ev))
|
|
196
|
+
elif msg[0] == 'EOSE' and len(msg) > 1 and msg[1] == sid:
|
|
197
|
+
if re_requested:
|
|
198
|
+
re_requested = False
|
|
199
|
+
continue
|
|
200
|
+
break
|
|
201
|
+
elif msg[0] == 'CLOSED' and len(msg) > 1 and msg[1] == sid:
|
|
202
|
+
reason = msg[2] if len(msg) > 2 else ''
|
|
203
|
+
if 'auth-required' in reason and self.auth_signer and not re_requested:
|
|
204
|
+
if authed:
|
|
205
|
+
re_requested = True
|
|
206
|
+
self._send(['REQ', sid] + self._subs[sid])
|
|
207
|
+
continue
|
|
208
|
+
try:
|
|
209
|
+
next_msg = self._recv()
|
|
210
|
+
if next_msg[0] == 'AUTH' and len(next_msg) > 1 and isinstance(next_msg[1], str):
|
|
211
|
+
if self._handle_auth(next_msg[1]):
|
|
212
|
+
authed = True
|
|
213
|
+
re_requested = True
|
|
214
|
+
self._send(['REQ', sid] + self._subs[sid])
|
|
215
|
+
continue
|
|
216
|
+
except (RelayError, ws.ConnectionClosed, OSError) as e:
|
|
217
|
+
# The relay closed us after CLOSED without a usable AUTH
|
|
218
|
+
# challenge; the RelayError below is the answer either way.
|
|
219
|
+
log.debug('relay %s: no AUTH after CLOSED: %s', self.url, e)
|
|
220
|
+
raise RelayError('relay closed the subscription: %s' % reason)
|
|
221
|
+
self._send(['CLOSE', sid])
|
|
222
|
+
self._subs.pop(sid, None)
|
|
223
|
+
return out
|
|
224
|
+
|
|
225
|
+
def subscribe(self, filters: Sequence[NostrFilter | Mapping[str, Any]], ready: bool = False) -> str:
|
|
226
|
+
"""Open a subscription; returns its id. Read with events().
|
|
227
|
+
|
|
228
|
+
`ready` waits for the relay's EOSE before returning, which is the only
|
|
229
|
+
way a caller can know the subscription is registered. Without it this
|
|
230
|
+
sends REQ and returns, so a caller that subscribes and then causes an
|
|
231
|
+
event races the server: an ephemeral kind fanned out before the REQ is
|
|
232
|
+
processed reaches nobody, is never stored, and no timeout on events()
|
|
233
|
+
can recover it. Stored events that arrive before the EOSE are kept and
|
|
234
|
+
handed to events() rather than dropped on the floor.
|
|
235
|
+
"""
|
|
236
|
+
sid = self._sub_id()
|
|
237
|
+
self._subs[sid] = list(filters)
|
|
238
|
+
self._send(['REQ', sid] + list(filters))
|
|
239
|
+
if ready and self._await_eose(sid):
|
|
240
|
+
self._confirmed.add(sid)
|
|
241
|
+
return sid
|
|
242
|
+
|
|
243
|
+
def confirmed(self, sid: str) -> bool:
|
|
244
|
+
"""True if the relay answered this subscription's REQ with an EOSE.
|
|
245
|
+
|
|
246
|
+
Only ever true for a `subscribe(ready=True)`. A caller that waited and
|
|
247
|
+
did not get one has a socket that is up and a subscription the relay
|
|
248
|
+
has not owned up to, which is worth saying rather than assuming either
|
|
249
|
+
way.
|
|
250
|
+
"""
|
|
251
|
+
return sid in self._confirmed
|
|
252
|
+
|
|
253
|
+
def _await_eose(self, sid: str) -> bool:
|
|
254
|
+
"""Read until this subscription's EOSE, buffering what comes first.
|
|
255
|
+
|
|
256
|
+
True if the EOSE came. False if the wait ran out, the relay closed the
|
|
257
|
+
subscription, or the socket went - the three cases where a caller that
|
|
258
|
+
is about to cause an event must not believe it is being listened to.
|
|
259
|
+
"""
|
|
260
|
+
deadline = time.time() + self.timeout
|
|
261
|
+
while time.time() < deadline:
|
|
262
|
+
try:
|
|
263
|
+
msg = self._recv()
|
|
264
|
+
except socket.timeout:
|
|
265
|
+
continue
|
|
266
|
+
except (OSError, ws.ConnectionClosed):
|
|
267
|
+
return False
|
|
268
|
+
if msg[0] == 'EOSE' and len(msg) > 1 and msg[1] == sid:
|
|
269
|
+
return True
|
|
270
|
+
if msg[0] == 'AUTH' and len(msg) > 1 and isinstance(msg[1], str):
|
|
271
|
+
if self._handle_auth(msg[1]):
|
|
272
|
+
self._send(['REQ', sid] + self._subs[sid])
|
|
273
|
+
continue
|
|
274
|
+
if msg[0] == 'CLOSED' and len(msg) > 1 and msg[1] == sid:
|
|
275
|
+
return False
|
|
276
|
+
if msg[0] == 'EVENT' and len(msg) > 2 and msg[1] == sid:
|
|
277
|
+
self._pending.setdefault(sid, []).append(msg[2])
|
|
278
|
+
return False
|
|
279
|
+
|
|
280
|
+
def events(self, sid: str, until: Optional[float] = None) -> Iterator[NostrEvent]:
|
|
281
|
+
"""Yield events on a subscription until `until` (a time) or the socket closes."""
|
|
282
|
+
for held in self._pending.pop(sid, []):
|
|
283
|
+
yield held # arrived before the EOSE `ready` waited for
|
|
284
|
+
pinged_at: Optional[float] = None
|
|
285
|
+
authed = False
|
|
286
|
+
while until is None or time.time() < until:
|
|
287
|
+
try:
|
|
288
|
+
msg = self._recv()
|
|
289
|
+
except socket.timeout:
|
|
290
|
+
assert self.ws is not None
|
|
291
|
+
idle = self.ws.idle()
|
|
292
|
+
if idle > self.dead_after:
|
|
293
|
+
raise ws.ConnectionClosed('no frame in %.0fs, not even a pong: the subscription is dead' % idle)
|
|
294
|
+
if idle > self.ping_after and (pinged_at is None or time.time() - pinged_at > self.ping_after / 2):
|
|
295
|
+
try:
|
|
296
|
+
self.ws.ping()
|
|
297
|
+
except OSError:
|
|
298
|
+
return
|
|
299
|
+
pinged_at = time.time()
|
|
300
|
+
continue # nothing arrived; the subscription is still open
|
|
301
|
+
except (OSError, ws.ConnectionClosed):
|
|
302
|
+
return
|
|
303
|
+
if msg[0] == 'AUTH' and len(msg) > 1 and isinstance(msg[1], str):
|
|
304
|
+
if self._handle_auth(msg[1]):
|
|
305
|
+
authed = True
|
|
306
|
+
continue
|
|
307
|
+
if msg[0] == 'CLOSED' and len(msg) > 1 and msg[1] == sid:
|
|
308
|
+
reason = msg[2] if len(msg) > 2 else ''
|
|
309
|
+
if 'auth-required' in reason and self.auth_signer and sid in self._subs:
|
|
310
|
+
if authed:
|
|
311
|
+
self._send(['REQ', sid] + self._subs[sid])
|
|
312
|
+
continue
|
|
313
|
+
try:
|
|
314
|
+
next_msg = self._recv()
|
|
315
|
+
if next_msg[0] == 'AUTH' and len(next_msg) > 1 and isinstance(next_msg[1], str):
|
|
316
|
+
if self._handle_auth(next_msg[1]):
|
|
317
|
+
authed = True
|
|
318
|
+
self._send(['REQ', sid] + self._subs[sid])
|
|
319
|
+
continue
|
|
320
|
+
except (RelayError, ws.ConnectionClosed, OSError) as e:
|
|
321
|
+
# Same as query(): CLOSED with no AUTH to answer ends the stream.
|
|
322
|
+
log.debug('relay %s: no AUTH after CLOSED: %s', self.url, e)
|
|
323
|
+
return
|
|
324
|
+
if msg[0] == 'EOSE' and len(msg) > 1 and msg[1] == sid:
|
|
325
|
+
self._confirmed.add(sid)
|
|
326
|
+
if self.on_eose is not None:
|
|
327
|
+
self.on_eose(sid)
|
|
328
|
+
continue
|
|
329
|
+
if msg[0] == 'EVENT' and len(msg) > 2 and msg[1] == sid and isinstance(msg[2], dict):
|
|
330
|
+
# The guard used to sit in every consumer; it belongs here,
|
|
331
|
+
# where the promise of the return type is made.
|
|
332
|
+
yield cast(NostrEvent, msg[2])
|
|
333
|
+
|
|
334
|
+
def unsubscribe(self, sid: str) -> None:
|
|
335
|
+
self._send(['CLOSE', sid])
|
|
336
|
+
self._subs.pop(sid, None)
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
def publish_all(
|
|
340
|
+
urls: Sequence[str],
|
|
341
|
+
event: EventLike,
|
|
342
|
+
timeout: float = 15.0,
|
|
343
|
+
auth_signer: Optional[Callable[[str, str], Optional[EventLike]]] = None,
|
|
344
|
+
) -> dict[str, tuple[bool, str]]:
|
|
345
|
+
"""{url: (accepted, message)}; one failed relay does not stop the others."""
|
|
346
|
+
out: dict[str, tuple[bool, str]] = {}
|
|
347
|
+
for url in urls:
|
|
348
|
+
r = Relay(url, timeout, auth_signer=auth_signer)
|
|
349
|
+
try:
|
|
350
|
+
out[url] = r.publish(event)
|
|
351
|
+
except Exception as e:
|
|
352
|
+
out[url] = (False, '%s: %s' % (type(e).__name__, e))
|
|
353
|
+
finally:
|
|
354
|
+
r.close()
|
|
355
|
+
return out
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
def query_all(
|
|
359
|
+
urls: Sequence[str],
|
|
360
|
+
filters: Sequence[NostrFilter | Mapping[str, Any]],
|
|
361
|
+
timeout: float = 15.0,
|
|
362
|
+
auth_signer: Optional[Callable[[str, str], Optional[EventLike]]] = None,
|
|
363
|
+
) -> list[NostrEvent]:
|
|
364
|
+
"""Events from every relay, deduplicated by id."""
|
|
365
|
+
seen: dict[str, NostrEvent] = {}
|
|
366
|
+
for url in urls:
|
|
367
|
+
r = Relay(url, timeout, auth_signer=auth_signer)
|
|
368
|
+
try:
|
|
369
|
+
for ev in r.query(filters):
|
|
370
|
+
seen.setdefault(ev['id'], ev)
|
|
371
|
+
except (RelayError, ws.ConnectionClosed, OSError) as e:
|
|
372
|
+
# One relay down must not cost the answers of the others; the
|
|
373
|
+
# caller asked for the union of what is reachable.
|
|
374
|
+
log.info('relay %s: query skipped: %s', url, e)
|
|
375
|
+
finally:
|
|
376
|
+
r.close()
|
|
377
|
+
return list(seen.values())
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
"""The kind 11801 a node publishes, and the DID document a resolver derives.
|
|
2
|
+
|
|
3
|
+
Built from the node's keys and its MeshNode manifest: class, endpoints,
|
|
4
|
+
operator, the protocols it implements as c tags, the plain capabilities as
|
|
5
|
+
hints, expiry within the class maximum, and a NIP-13 nonce mined to the
|
|
6
|
+
policy's bits. Every descriptor is run through fleetmesh_core.identity.validate_descriptor
|
|
7
|
+
before it leaves, so this node can never publish what it would decline.
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import time
|
|
12
|
+
from typing import TYPE_CHECKING, Any, Mapping, Optional, Sequence, TypedDict
|
|
13
|
+
from fleetmesh_core.crypto import crypto
|
|
14
|
+
from fleetmesh_core.identity import did, identity
|
|
15
|
+
from fleetmesh_core.identity.did import Document as DIDDocument
|
|
16
|
+
from fleetmesh_core.wire import schema_files
|
|
17
|
+
from fleetmesh_core.storage import store # noqa: E402
|
|
18
|
+
from node.interfaces import EventLike, NostrEvent, TagList
|
|
19
|
+
|
|
20
|
+
if TYPE_CHECKING:
|
|
21
|
+
from node.keystore import Keystore
|
|
22
|
+
|
|
23
|
+
CONSTANTS = identity.CONSTANTS
|
|
24
|
+
DIGEST = schema_files.digest()
|
|
25
|
+
VERSION = schema_files.load_kinds()['version']
|
|
26
|
+
SOFTWARE = (identity.FLEETMESH.rstrip('/'), '0.0.1') # the project URL, derived and never written down
|
|
27
|
+
KIND: int = 11801
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def mine(
|
|
31
|
+
pubkey: str,
|
|
32
|
+
created_at: int,
|
|
33
|
+
kind: int,
|
|
34
|
+
tags: TagList,
|
|
35
|
+
content: str,
|
|
36
|
+
bits: int,
|
|
37
|
+
max_tries: int = 1 << 26,
|
|
38
|
+
) -> tuple[TagList, str]:
|
|
39
|
+
"""Find a NIP-13 nonce so the event id has at least `bits` leading zeros.
|
|
40
|
+
|
|
41
|
+
The nonce tag is ['nonce', '<n>', '<target bits>'] and it is the last tag,
|
|
42
|
+
so everything else about the event is fixed before mining starts.
|
|
43
|
+
"""
|
|
44
|
+
base = [t for t in tags if t[0] != 'nonce']
|
|
45
|
+
for n in range(max_tries):
|
|
46
|
+
trial = base + [['nonce', str(n), str(bits)]]
|
|
47
|
+
eid = store.event_id(pubkey, created_at, kind, trial, content)
|
|
48
|
+
if identity.pow_bits(eid) >= bits:
|
|
49
|
+
return trial, eid
|
|
50
|
+
raise RuntimeError('no nonce found in %d tries' % max_tries)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def build(
|
|
55
|
+
keystore: Keystore,
|
|
56
|
+
node_class: str,
|
|
57
|
+
endpoints: Sequence[tuple[str, str, int] | list[Any]],
|
|
58
|
+
operator_pubkey: str,
|
|
59
|
+
protocols: Sequence[str],
|
|
60
|
+
now: Optional[int] = None,
|
|
61
|
+
capabilities: Sequence[str] = (),
|
|
62
|
+
mediator: Optional[str] = None,
|
|
63
|
+
policy: Optional[tuple[str, ...] | list[str]] = None,
|
|
64
|
+
coverage: Optional[str] = None,
|
|
65
|
+
nips: Optional[Sequence[int]] = None,
|
|
66
|
+
lifetime: Optional[int] = None,
|
|
67
|
+
bits: Optional[int] = None,
|
|
68
|
+
content: str = '',
|
|
69
|
+
fips_key: Optional[str] = None,
|
|
70
|
+
extra_tags: Sequence[Sequence[str]] = (),
|
|
71
|
+
) -> NostrEvent:
|
|
72
|
+
"""A signed kind 11801.
|
|
73
|
+
|
|
74
|
+
endpoints: [(scheme, address, preference)]. protocols: the fleetmesh protocol
|
|
75
|
+
identifiers this node implements; peering/1.0 and budget/1.0 are required
|
|
76
|
+
of every class and are added if missing. policy: (sha256 hex, url).
|
|
77
|
+
extra_tags: optional tags a subsystem contributes (mint, price), placed
|
|
78
|
+
before operator so the fixed tail stays where readers expect it.
|
|
79
|
+
"""
|
|
80
|
+
now = int(now if now is not None else time.time())
|
|
81
|
+
cap = CONSTANTS['descriptor']
|
|
82
|
+
max_life = cap['max_lifetime_seconds_leaf' if node_class == 'leaf' else 'max_lifetime_seconds_anchor_edge']
|
|
83
|
+
lifetime = min(lifetime or max_life, max_life)
|
|
84
|
+
bits = cap['default_pow_bits'] if bits is None else bits
|
|
85
|
+
mk = keystore.multikeys()
|
|
86
|
+
protos = sorted(set(protocols) | set(identity.REQUIRED_PROTOCOLS))
|
|
87
|
+
tags: TagList = [
|
|
88
|
+
['class', node_class],
|
|
89
|
+
['protocol', VERSION, DIGEST],
|
|
90
|
+
['software', SOFTWARE[0], SOFTWARE[1]],
|
|
91
|
+
['key', 'agreement', mk['agreement']],
|
|
92
|
+
['key', 'transport', mk['transport']],
|
|
93
|
+
]
|
|
94
|
+
if fips_key:
|
|
95
|
+
tags.append(['key', 'fips', fips_key])
|
|
96
|
+
for scheme, address, pref in sorted(endpoints, key=lambda e: (int(e[2]), e[0])):
|
|
97
|
+
tags.append(['endpoint', scheme, address, str(pref)])
|
|
98
|
+
if mediator:
|
|
99
|
+
tags.append(['mediator', mediator])
|
|
100
|
+
tags += [['c', p] for p in protos]
|
|
101
|
+
tags += [['c', c] for c in capabilities]
|
|
102
|
+
if nips:
|
|
103
|
+
tags.append(['nips'] + [str(n) for n in nips])
|
|
104
|
+
if coverage:
|
|
105
|
+
tags.append(['coverage', coverage])
|
|
106
|
+
if policy:
|
|
107
|
+
tags.append(['policy', policy[0]] + ([policy[1]] if len(policy) > 1 and policy[1] else []))
|
|
108
|
+
for t in extra_tags:
|
|
109
|
+
tags.append([str(x) for x in t])
|
|
110
|
+
tags.append(['operator', operator_pubkey])
|
|
111
|
+
tags.append(['expiration', str(now + lifetime)])
|
|
112
|
+
if bits:
|
|
113
|
+
tags, _ = mine(keystore.node_pk, now, KIND, tags, content, bits)
|
|
114
|
+
ev: NostrEvent = store.make_signed_event(keystore.node_sk, now, KIND, tags, content)
|
|
115
|
+
problems = identity.validate_descriptor(ev, now=now, digest=DIGEST)
|
|
116
|
+
if problems:
|
|
117
|
+
raise ValueError('refusing to publish an invalid descriptor: %s' % '; '.join(problems))
|
|
118
|
+
return ev
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
class ManifestEndpoint(TypedDict, total=False):
|
|
122
|
+
transport: str
|
|
123
|
+
address: str
|
|
124
|
+
ticket: str
|
|
125
|
+
priority: int
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
class ManifestOperator(TypedDict, total=False):
|
|
129
|
+
pubkey: str
|
|
130
|
+
policyUrl: str
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
ManifestSpecDict = TypedDict('ManifestSpecDict', {
|
|
134
|
+
'class': str,
|
|
135
|
+
'endpoints': list[ManifestEndpoint],
|
|
136
|
+
'capabilities': list[str],
|
|
137
|
+
'operator': ManifestOperator,
|
|
138
|
+
}, total=False)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
class MeshNodeManifest(TypedDict, total=False):
|
|
142
|
+
spec: ManifestSpecDict
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def from_manifest(
|
|
146
|
+
keystore: Keystore,
|
|
147
|
+
manifest: MeshNodeManifest | Mapping[str, Any],
|
|
148
|
+
now: Optional[int] = None,
|
|
149
|
+
bits: Optional[int] = None,
|
|
150
|
+
) -> NostrEvent:
|
|
151
|
+
"""A descriptor from a MeshNode manifest (manifests/meshnode-*.yaml, parsed)."""
|
|
152
|
+
spec = manifest['spec']
|
|
153
|
+
eps: list[tuple[str, str, int]] = []
|
|
154
|
+
for e in spec.get('endpoints', []):
|
|
155
|
+
addr = str(e.get('address') or e.get('ticket') or '')
|
|
156
|
+
eps.append((str(e.get('transport', '')), addr, int(e.get('priority', 50))))
|
|
157
|
+
caps = [c for c in spec.get('capabilities', []) if not c.startswith('https://')]
|
|
158
|
+
protos = [c for c in spec.get('capabilities', []) if c.startswith('https://')]
|
|
159
|
+
pol = spec.get('operator', {}).get('policyUrl')
|
|
160
|
+
policy = (crypto.sha256(pol.encode()), pol) if pol else None
|
|
161
|
+
return build(keystore, spec['class'], eps, spec['operator']['pubkey'], protos, now=now,
|
|
162
|
+
capabilities=caps, policy=policy, bits=bits)
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
# ---------------------------------------------------------- resolution ----
|
|
166
|
+
def genuine(desc: Any, pubkey: Optional[str] = None) -> bool:
|
|
167
|
+
"""Signed by its own pubkey, and by the pubkey it was asked for.
|
|
168
|
+
|
|
169
|
+
A relay answers a query by author with whatever it chooses to serve under
|
|
170
|
+
that name. Nothing verified the signature of a resolved descriptor, so a
|
|
171
|
+
relay could hand a node a descriptor naming an agreement key of the
|
|
172
|
+
relay's choosing, and every envelope to that peer would then be encrypted
|
|
173
|
+
to it. The rule for heartbeats already says it: believing an event
|
|
174
|
+
unsigned lets a relay speak as any peer.
|
|
175
|
+
"""
|
|
176
|
+
from fleetmesh_core.crypto import giftwrap
|
|
177
|
+
return (isinstance(desc, dict) and (pubkey is None or desc.get('pubkey') == pubkey)
|
|
178
|
+
and giftwrap.verify_event(desc))
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def newest(
|
|
182
|
+
descriptors: Sequence[NostrEvent | EventLike],
|
|
183
|
+
now: Optional[int] = None,
|
|
184
|
+
digest: str = DIGEST,
|
|
185
|
+
pubkey: Optional[str] = None,
|
|
186
|
+
) -> Optional[NostrEvent]:
|
|
187
|
+
"""The live, genuine descriptor among candidates for one pubkey, or None."""
|
|
188
|
+
now = int(now if now is not None else time.time())
|
|
189
|
+
cands = list(descriptors)
|
|
190
|
+
live = [d for d in cands if genuine(d, pubkey)
|
|
191
|
+
and not identity.validate_descriptor(d, now=now, digest=digest)]
|
|
192
|
+
picked = max(live, key=lambda d: int(d['created_at'])) if live else None
|
|
193
|
+
return picked # type: ignore[return-value]
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def resolve(
|
|
197
|
+
pubkey: str,
|
|
198
|
+
relay_urls: Sequence[str],
|
|
199
|
+
now: Optional[int] = None,
|
|
200
|
+
timeout: float = 15.0,
|
|
201
|
+
) -> Optional[NostrEvent]:
|
|
202
|
+
"""The live kind 11801 for a pubkey from these relays, or None."""
|
|
203
|
+
from . import client
|
|
204
|
+
found = client.query_all(relay_urls, [{'kinds': [KIND], 'authors': [pubkey]}], timeout)
|
|
205
|
+
return newest(found, now, pubkey=pubkey)
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def did_document(desc: EventLike) -> DIDDocument:
|
|
209
|
+
"""The node profile a did:nostr resolver emits for a kind 11801 (fleetmesh_core/did)."""
|
|
210
|
+
return did.document(desc)
|
|
211
|
+
|