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