oac-reference-node 0.1.0rc3__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.
- clients/__init__.py +2 -0
- clients/independent_client.py +255 -0
- clients/listener.py +262 -0
- clients/mcp_server.py +77 -0
- clients/relay.py +166 -0
- oac_node/__init__.py +13 -0
- oac_node/__main__.py +51 -0
- oac_node/app.py +407 -0
- oac_node/protocol.py +176 -0
- oac_node/store.py +77 -0
- oac_reference_node-0.1.0rc3.dist-info/METADATA +180 -0
- oac_reference_node-0.1.0rc3.dist-info/RECORD +16 -0
- oac_reference_node-0.1.0rc3.dist-info/WHEEL +5 -0
- oac_reference_node-0.1.0rc3.dist-info/entry_points.txt +7 -0
- oac_reference_node-0.1.0rc3.dist-info/licenses/LICENSE +21 -0
- oac_reference_node-0.1.0rc3.dist-info/top_level.txt +2 -0
clients/relay.py
ADDED
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Minimal OAC relay built only from the four Genesis HTTP operations."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import argparse
|
|
7
|
+
import json
|
|
8
|
+
import sys
|
|
9
|
+
import time
|
|
10
|
+
from dataclasses import asdict, dataclass
|
|
11
|
+
from typing import Any, Dict, Iterable, Optional
|
|
12
|
+
|
|
13
|
+
from clients.independent_client import ClientError, OACClient
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class RelayError(ValueError):
|
|
17
|
+
pass
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass
|
|
21
|
+
class RelayStats:
|
|
22
|
+
source: str
|
|
23
|
+
destination: str
|
|
24
|
+
scanned: int = 0
|
|
25
|
+
accepted: int = 0
|
|
26
|
+
known: int = 0
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _validate_manifest(manifest: Dict[str, Any], label: str) -> None:
|
|
30
|
+
required = {"oac", "release", "spec", "global", "events", "bootstrap"}
|
|
31
|
+
if not isinstance(manifest, dict) or not required.issubset(manifest):
|
|
32
|
+
raise RelayError(f"{label} returned an invalid discovery manifest")
|
|
33
|
+
if manifest["oac"] != "0.1":
|
|
34
|
+
raise RelayError(f"{label} does not support OAC 0.1")
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def relay_once(
|
|
38
|
+
source_url: str,
|
|
39
|
+
destination_url: str,
|
|
40
|
+
*,
|
|
41
|
+
page_size: int = 100,
|
|
42
|
+
max_pages: int = 10_000,
|
|
43
|
+
) -> RelayStats:
|
|
44
|
+
"""Verify and relay one complete GLOBAL scan from source to destination."""
|
|
45
|
+
source_url = source_url.rstrip("/")
|
|
46
|
+
destination_url = destination_url.rstrip("/")
|
|
47
|
+
if source_url == destination_url:
|
|
48
|
+
raise RelayError("source and destination must be different")
|
|
49
|
+
if not 1 <= page_size <= 500:
|
|
50
|
+
raise RelayError("page_size must be 1..500")
|
|
51
|
+
if max_pages < 1:
|
|
52
|
+
raise RelayError("max_pages must be positive")
|
|
53
|
+
|
|
54
|
+
source = OACClient(source_url)
|
|
55
|
+
destination = OACClient(destination_url)
|
|
56
|
+
_validate_manifest(source.discover(), "source")
|
|
57
|
+
_validate_manifest(destination.discover(), "destination")
|
|
58
|
+
|
|
59
|
+
stats = RelayStats(source_url, destination_url)
|
|
60
|
+
cursor: Optional[str] = None
|
|
61
|
+
observed_cursors = set()
|
|
62
|
+
for _ in range(max_pages):
|
|
63
|
+
page = source.global_page(cursor=cursor, limit=page_size)
|
|
64
|
+
events = page.get("events")
|
|
65
|
+
next_cursor = page.get("cursor")
|
|
66
|
+
if not isinstance(events, list):
|
|
67
|
+
raise RelayError("source returned an invalid GLOBAL page")
|
|
68
|
+
if next_cursor is not None and not isinstance(next_cursor, str):
|
|
69
|
+
raise RelayError("source returned an invalid cursor")
|
|
70
|
+
for event in events:
|
|
71
|
+
status, result = destination.publish(event)
|
|
72
|
+
if status not in {200, 201} or result.get("id") != event["id"]:
|
|
73
|
+
raise RelayError("destination returned an invalid publish response")
|
|
74
|
+
stats.scanned += 1
|
|
75
|
+
if status == 201:
|
|
76
|
+
stats.accepted += 1
|
|
77
|
+
else:
|
|
78
|
+
stats.known += 1
|
|
79
|
+
if next_cursor is None:
|
|
80
|
+
return stats
|
|
81
|
+
if next_cursor in observed_cursors:
|
|
82
|
+
raise RelayError("source repeated a cursor")
|
|
83
|
+
observed_cursors.add(next_cursor)
|
|
84
|
+
cursor = next_cursor
|
|
85
|
+
raise RelayError("source exceeded max_pages during one scan")
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def relay_pair(
|
|
89
|
+
first_url: str,
|
|
90
|
+
second_url: str,
|
|
91
|
+
*,
|
|
92
|
+
page_size: int = 100,
|
|
93
|
+
max_pages: int = 10_000,
|
|
94
|
+
) -> Dict[str, RelayStats]:
|
|
95
|
+
"""Relay both directions; idempotency makes repeated scans safe."""
|
|
96
|
+
return {
|
|
97
|
+
"first_to_second": relay_once(
|
|
98
|
+
first_url, second_url, page_size=page_size, max_pages=max_pages
|
|
99
|
+
),
|
|
100
|
+
"second_to_first": relay_once(
|
|
101
|
+
second_url, first_url, page_size=page_size, max_pages=max_pages
|
|
102
|
+
),
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _json_result(result: Any) -> str:
|
|
107
|
+
if isinstance(result, RelayStats):
|
|
108
|
+
payload: Any = asdict(result)
|
|
109
|
+
else:
|
|
110
|
+
payload = {key: asdict(value) for key, value in result.items()}
|
|
111
|
+
return json.dumps(payload, ensure_ascii=False, separators=(",", ":"))
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def main(argv: Optional[Iterable[str]] = None) -> int:
|
|
115
|
+
parser = argparse.ArgumentParser(description="Verify and relay OAC Genesis Events")
|
|
116
|
+
parser.add_argument("first_url")
|
|
117
|
+
parser.add_argument("second_url")
|
|
118
|
+
parser.add_argument("--bidirectional", action="store_true")
|
|
119
|
+
parser.add_argument("--page-size", type=int, default=100)
|
|
120
|
+
parser.add_argument("--max-pages", type=int, default=10_000)
|
|
121
|
+
parser.add_argument(
|
|
122
|
+
"--interval",
|
|
123
|
+
type=float,
|
|
124
|
+
default=0,
|
|
125
|
+
help="seconds between full scans; zero runs once",
|
|
126
|
+
)
|
|
127
|
+
args = parser.parse_args(argv)
|
|
128
|
+
if args.interval < 0:
|
|
129
|
+
parser.error("--interval must be non-negative")
|
|
130
|
+
|
|
131
|
+
while True:
|
|
132
|
+
try:
|
|
133
|
+
if args.bidirectional:
|
|
134
|
+
result = relay_pair(
|
|
135
|
+
args.first_url,
|
|
136
|
+
args.second_url,
|
|
137
|
+
page_size=args.page_size,
|
|
138
|
+
max_pages=args.max_pages,
|
|
139
|
+
)
|
|
140
|
+
else:
|
|
141
|
+
result = relay_once(
|
|
142
|
+
args.first_url,
|
|
143
|
+
args.second_url,
|
|
144
|
+
page_size=args.page_size,
|
|
145
|
+
max_pages=args.max_pages,
|
|
146
|
+
)
|
|
147
|
+
print(_json_result(result), flush=True)
|
|
148
|
+
except (ClientError, RelayError, OSError, ValueError, KeyError) as error:
|
|
149
|
+
print(
|
|
150
|
+
json.dumps(
|
|
151
|
+
{"error": "relay_failed", "detail": str(error)},
|
|
152
|
+
ensure_ascii=False,
|
|
153
|
+
separators=(",", ":"),
|
|
154
|
+
),
|
|
155
|
+
file=sys.stderr,
|
|
156
|
+
flush=True,
|
|
157
|
+
)
|
|
158
|
+
if args.interval == 0:
|
|
159
|
+
return 1
|
|
160
|
+
if args.interval == 0:
|
|
161
|
+
return 0
|
|
162
|
+
time.sleep(args.interval)
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
if __name__ == "__main__":
|
|
166
|
+
raise SystemExit(main())
|
oac_node/__init__.py
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"""OAC Genesis reference node."""
|
|
2
|
+
|
|
3
|
+
__version__ = "0.1.0rc3"
|
|
4
|
+
|
|
5
|
+
from .protocol import ProtocolError, canonicalize_body, event_id, sign_event, verify_event
|
|
6
|
+
|
|
7
|
+
__all__ = [
|
|
8
|
+
"ProtocolError",
|
|
9
|
+
"canonicalize_body",
|
|
10
|
+
"event_id",
|
|
11
|
+
"sign_event",
|
|
12
|
+
"verify_event",
|
|
13
|
+
]
|
oac_node/__main__.py
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
|
|
5
|
+
from .app import NodeConfig, create_server
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def parser() -> argparse.ArgumentParser:
|
|
9
|
+
value = argparse.ArgumentParser(description="OAC Genesis Reference Node v0.1")
|
|
10
|
+
value.add_argument("--host", default="127.0.0.1")
|
|
11
|
+
value.add_argument("--port", default=8080, type=int)
|
|
12
|
+
value.add_argument("--db", default="oac.sqlite3")
|
|
13
|
+
value.add_argument("--public-base-url")
|
|
14
|
+
value.add_argument("--spec-url", default="urn:oac:spec:genesis:0.1")
|
|
15
|
+
value.add_argument("--spec-file")
|
|
16
|
+
value.add_argument("--release", default="genesis-0.1-rc3")
|
|
17
|
+
value.add_argument("--bootstrap", action="append", default=[])
|
|
18
|
+
value.add_argument("--publish-limit", default=120, type=int)
|
|
19
|
+
value.add_argument("--publish-window", default=3600, type=int)
|
|
20
|
+
value.add_argument("--request-timeout", default=15.0, type=float)
|
|
21
|
+
value.add_argument("--max-connections", default=64, type=int)
|
|
22
|
+
return value
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def main() -> None:
|
|
26
|
+
args = parser().parse_args()
|
|
27
|
+
config = NodeConfig(
|
|
28
|
+
database=args.db,
|
|
29
|
+
public_base_url=args.public_base_url,
|
|
30
|
+
spec_url=args.spec_url,
|
|
31
|
+
spec_path=args.spec_file,
|
|
32
|
+
release=args.release,
|
|
33
|
+
bootstrap=args.bootstrap,
|
|
34
|
+
publish_limit=args.publish_limit,
|
|
35
|
+
publish_window_seconds=args.publish_window,
|
|
36
|
+
request_timeout_seconds=args.request_timeout,
|
|
37
|
+
max_connections=args.max_connections,
|
|
38
|
+
)
|
|
39
|
+
server = create_server(args.host, args.port, config)
|
|
40
|
+
host, port = server.server_address[:2]
|
|
41
|
+
print(f"OAC Genesis node listening on http://{host}:{port}", flush=True)
|
|
42
|
+
try:
|
|
43
|
+
server.serve_forever()
|
|
44
|
+
except KeyboardInterrupt:
|
|
45
|
+
pass
|
|
46
|
+
finally:
|
|
47
|
+
server.server_close()
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
if __name__ == "__main__":
|
|
51
|
+
main()
|
oac_node/app.py
ADDED
|
@@ -0,0 +1,407 @@
|
|
|
1
|
+
"""Minimal, UI-free HTTP server for OAC Genesis v0.1."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import base64
|
|
6
|
+
import binascii
|
|
7
|
+
import json
|
|
8
|
+
import math
|
|
9
|
+
import socket
|
|
10
|
+
import threading
|
|
11
|
+
import time
|
|
12
|
+
from collections import deque
|
|
13
|
+
from html import escape
|
|
14
|
+
from dataclasses import dataclass, field
|
|
15
|
+
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from typing import Any, Deque, Dict, List, Optional, Tuple
|
|
18
|
+
from urllib.parse import parse_qs, unquote, urlsplit
|
|
19
|
+
|
|
20
|
+
from .protocol import EVENT_ID_RE, ProtocolError, verify_event
|
|
21
|
+
from .store import EventStore
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
MAX_EVENT_BYTES = 1_048_576
|
|
25
|
+
DEFAULT_PAGE_SIZE = 100
|
|
26
|
+
MAX_PAGE_SIZE = 500
|
|
27
|
+
DEFAULT_PUBLISH_LIMIT = 120
|
|
28
|
+
DEFAULT_PUBLISH_WINDOW_SECONDS = 3_600
|
|
29
|
+
DEFAULT_REQUEST_TIMEOUT_SECONDS = 15.0
|
|
30
|
+
DEFAULT_MAX_CONNECTIONS = 64
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _unique_object(pairs: List[tuple]) -> Dict[str, Any]:
|
|
34
|
+
value: Dict[str, Any] = {}
|
|
35
|
+
for key, item in pairs:
|
|
36
|
+
if key in value:
|
|
37
|
+
raise ValueError(f"duplicate JSON member: {key}")
|
|
38
|
+
value[key] = item
|
|
39
|
+
return value
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _reject_non_json_constant(value: str) -> None:
|
|
43
|
+
raise ValueError(f"non-JSON numeric constant: {value}")
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@dataclass
|
|
47
|
+
class NodeConfig:
|
|
48
|
+
database: str
|
|
49
|
+
public_base_url: Optional[str] = None
|
|
50
|
+
spec_url: str = "urn:oac:spec:genesis:0.1"
|
|
51
|
+
spec_path: Optional[str] = None
|
|
52
|
+
release: str = "genesis-0.1-rc3"
|
|
53
|
+
bootstrap: List[str] = field(default_factory=list)
|
|
54
|
+
max_event_bytes: int = MAX_EVENT_BYTES
|
|
55
|
+
publish_limit: int = DEFAULT_PUBLISH_LIMIT
|
|
56
|
+
publish_window_seconds: int = DEFAULT_PUBLISH_WINDOW_SECONDS
|
|
57
|
+
request_timeout_seconds: float = DEFAULT_REQUEST_TIMEOUT_SECONDS
|
|
58
|
+
max_connections: int = DEFAULT_MAX_CONNECTIONS
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class SlidingWindowLimiter:
|
|
62
|
+
"""Small process-local admission limit for newly accepted Events."""
|
|
63
|
+
|
|
64
|
+
def __init__(self, limit: int, window_seconds: int) -> None:
|
|
65
|
+
self.limit = limit
|
|
66
|
+
self.window_seconds = window_seconds
|
|
67
|
+
self._accepted: Deque[float] = deque()
|
|
68
|
+
self._lock = threading.Lock()
|
|
69
|
+
|
|
70
|
+
def acquire(self) -> Tuple[bool, int]:
|
|
71
|
+
if self.limit == 0:
|
|
72
|
+
return True, 0
|
|
73
|
+
now = time.monotonic()
|
|
74
|
+
cutoff = now - self.window_seconds
|
|
75
|
+
with self._lock:
|
|
76
|
+
while self._accepted and self._accepted[0] <= cutoff:
|
|
77
|
+
self._accepted.popleft()
|
|
78
|
+
if len(self._accepted) >= self.limit:
|
|
79
|
+
retry_after = max(1, math.ceil(self._accepted[0] + self.window_seconds - now))
|
|
80
|
+
return False, retry_after
|
|
81
|
+
self._accepted.append(now)
|
|
82
|
+
return True, 0
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _cursor_encode(seq: int) -> str:
|
|
86
|
+
raw = seq.to_bytes(8, "big", signed=False)
|
|
87
|
+
return base64.urlsafe_b64encode(raw).rstrip(b"=").decode("ascii")
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _cursor_decode(cursor: str) -> int:
|
|
91
|
+
try:
|
|
92
|
+
raw = base64.b64decode(
|
|
93
|
+
cursor + "=" * (-len(cursor) % 4), altchars=b"-_", validate=True
|
|
94
|
+
)
|
|
95
|
+
except (ValueError, binascii.Error) as exc:
|
|
96
|
+
raise ProtocolError("invalid_cursor", "cursor is not valid", 400) from exc
|
|
97
|
+
if len(raw) != 8 or _cursor_encode(int.from_bytes(raw, "big")) != cursor:
|
|
98
|
+
raise ProtocolError("invalid_cursor", "cursor is not valid", 400)
|
|
99
|
+
return int.from_bytes(raw, "big", signed=False)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
class OACHTTPServer(ThreadingHTTPServer):
|
|
103
|
+
daemon_threads = True
|
|
104
|
+
allow_reuse_address = True
|
|
105
|
+
request_queue_size = DEFAULT_MAX_CONNECTIONS
|
|
106
|
+
|
|
107
|
+
def __init__(self, server_address: tuple, config: NodeConfig):
|
|
108
|
+
if config.publish_limit < 0:
|
|
109
|
+
raise ValueError("publish_limit must be zero or positive")
|
|
110
|
+
if config.publish_window_seconds < 1:
|
|
111
|
+
raise ValueError("publish_window_seconds must be positive")
|
|
112
|
+
if config.request_timeout_seconds <= 0:
|
|
113
|
+
raise ValueError("request_timeout_seconds must be positive")
|
|
114
|
+
if config.max_connections < 1:
|
|
115
|
+
raise ValueError("max_connections must be positive")
|
|
116
|
+
self.config = config
|
|
117
|
+
self.store = EventStore(config.database)
|
|
118
|
+
self.publish_limiter = SlidingWindowLimiter(
|
|
119
|
+
config.publish_limit, config.publish_window_seconds
|
|
120
|
+
)
|
|
121
|
+
self._connection_slots = threading.BoundedSemaphore(config.max_connections)
|
|
122
|
+
self.request_queue_size = config.max_connections
|
|
123
|
+
super().__init__(server_address, OACRequestHandler)
|
|
124
|
+
|
|
125
|
+
def process_request(self, request: socket.socket, client_address: tuple) -> None:
|
|
126
|
+
if not self._connection_slots.acquire(blocking=False):
|
|
127
|
+
payload = b'{"error":"server_busy","detail":"Node connection limit reached"}'
|
|
128
|
+
response = (
|
|
129
|
+
b"HTTP/1.1 503 Service Unavailable\r\n"
|
|
130
|
+
b"Content-Type: application/json; charset=utf-8\r\n"
|
|
131
|
+
+ f"Content-Length: {len(payload)}\r\n".encode("ascii")
|
|
132
|
+
+ b"Cache-Control: no-store\r\n"
|
|
133
|
+
+ b"Retry-After: 1\r\n"
|
|
134
|
+
+ b"X-Content-Type-Options: nosniff\r\n"
|
|
135
|
+
+ b"X-Frame-Options: DENY\r\n"
|
|
136
|
+
+ b"Referrer-Policy: no-referrer\r\n"
|
|
137
|
+
+ b"Connection: close\r\n\r\n"
|
|
138
|
+
+ payload
|
|
139
|
+
)
|
|
140
|
+
try:
|
|
141
|
+
request.sendall(response)
|
|
142
|
+
except OSError:
|
|
143
|
+
pass
|
|
144
|
+
self.shutdown_request(request)
|
|
145
|
+
return
|
|
146
|
+
try:
|
|
147
|
+
super().process_request(request, client_address)
|
|
148
|
+
except BaseException:
|
|
149
|
+
self._connection_slots.release()
|
|
150
|
+
raise
|
|
151
|
+
|
|
152
|
+
def process_request_thread(self, request: socket.socket, client_address: tuple) -> None:
|
|
153
|
+
try:
|
|
154
|
+
super().process_request_thread(request, client_address)
|
|
155
|
+
finally:
|
|
156
|
+
self._connection_slots.release()
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
class OACRequestHandler(BaseHTTPRequestHandler):
|
|
160
|
+
server: OACHTTPServer
|
|
161
|
+
protocol_version = "HTTP/1.1"
|
|
162
|
+
server_version = "OAC"
|
|
163
|
+
sys_version = ""
|
|
164
|
+
|
|
165
|
+
def version_string(self) -> str:
|
|
166
|
+
return self.server_version
|
|
167
|
+
|
|
168
|
+
def setup(self) -> None:
|
|
169
|
+
super().setup()
|
|
170
|
+
self.connection.settimeout(self.server.config.request_timeout_seconds)
|
|
171
|
+
|
|
172
|
+
def end_headers(self) -> None:
|
|
173
|
+
self.send_header("X-Content-Type-Options", "nosniff")
|
|
174
|
+
self.send_header("X-Frame-Options", "DENY")
|
|
175
|
+
self.send_header("Referrer-Policy", "no-referrer")
|
|
176
|
+
super().end_headers()
|
|
177
|
+
|
|
178
|
+
def log_message(self, fmt: str, *args: Any) -> None:
|
|
179
|
+
# Retain the standard concise access log; no human UI is exposed.
|
|
180
|
+
super().log_message(fmt, *args)
|
|
181
|
+
|
|
182
|
+
def _json(
|
|
183
|
+
self, status: int, value: Dict[str, Any], headers: Optional[Dict[str, str]] = None
|
|
184
|
+
) -> None:
|
|
185
|
+
payload = json.dumps(value, ensure_ascii=False, separators=(",", ":")).encode("utf-8")
|
|
186
|
+
self.send_response(status)
|
|
187
|
+
self.send_header("Content-Type", "application/json; charset=utf-8")
|
|
188
|
+
self.send_header("Content-Length", str(len(payload)))
|
|
189
|
+
self.send_header("Cache-Control", "no-store")
|
|
190
|
+
for name, header_value in (headers or {}).items():
|
|
191
|
+
self.send_header(name, header_value)
|
|
192
|
+
self.end_headers()
|
|
193
|
+
if self.command != "HEAD":
|
|
194
|
+
self.wfile.write(payload)
|
|
195
|
+
|
|
196
|
+
def _error(self, error: ProtocolError, headers: Optional[Dict[str, str]] = None) -> None:
|
|
197
|
+
body = {"error": error.code}
|
|
198
|
+
if error.detail:
|
|
199
|
+
body["detail"] = error.detail
|
|
200
|
+
self._json(error.status, body, headers)
|
|
201
|
+
|
|
202
|
+
def _base_url(self) -> str:
|
|
203
|
+
if self.server.config.public_base_url:
|
|
204
|
+
return self.server.config.public_base_url.rstrip("/")
|
|
205
|
+
# Local/development convenience only. Deployments should pin the URL.
|
|
206
|
+
host = self.headers.get("Host") or f"{self.server.server_name}:{self.server.server_port}"
|
|
207
|
+
return f"http://{host}".rstrip("/")
|
|
208
|
+
|
|
209
|
+
def do_GET(self) -> None: # noqa: N802
|
|
210
|
+
target = urlsplit(self.path)
|
|
211
|
+
if target.path == "/" and not target.query:
|
|
212
|
+
self.send_response(302)
|
|
213
|
+
self.send_header("Location", "/.well-known/oac.json")
|
|
214
|
+
self.send_header("Content-Length", "0")
|
|
215
|
+
self.send_header("Cache-Control", "public, max-age=300")
|
|
216
|
+
self.end_headers()
|
|
217
|
+
return
|
|
218
|
+
if target.path == "/robots.txt" and not target.query:
|
|
219
|
+
base = self._base_url()
|
|
220
|
+
payload = f"User-agent: *\nAllow: /\nSitemap: {base}/sitemap.xml\n".encode()
|
|
221
|
+
self.send_response(200)
|
|
222
|
+
self.send_header("Content-Type", "text/plain; charset=utf-8")
|
|
223
|
+
self.send_header("Content-Length", str(len(payload)))
|
|
224
|
+
self.send_header("Cache-Control", "public, max-age=3600")
|
|
225
|
+
self.end_headers()
|
|
226
|
+
self.wfile.write(payload)
|
|
227
|
+
return
|
|
228
|
+
if target.path == "/sitemap.xml" and not target.query:
|
|
229
|
+
base = escape(self._base_url(), quote=True)
|
|
230
|
+
locations = (
|
|
231
|
+
f"{base}/.well-known/oac.json",
|
|
232
|
+
f"{base}/oac/spec/0.1",
|
|
233
|
+
f"{base}/oac/global",
|
|
234
|
+
f"{base}/llms.txt",
|
|
235
|
+
)
|
|
236
|
+
entries = "".join(f"<url><loc>{value}</loc></url>" for value in locations)
|
|
237
|
+
payload = (
|
|
238
|
+
'<?xml version="1.0" encoding="UTF-8"?>'
|
|
239
|
+
'<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">'
|
|
240
|
+
+ entries
|
|
241
|
+
+ "</urlset>"
|
|
242
|
+
).encode()
|
|
243
|
+
self.send_response(200)
|
|
244
|
+
self.send_header("Content-Type", "application/xml; charset=utf-8")
|
|
245
|
+
self.send_header("Content-Length", str(len(payload)))
|
|
246
|
+
self.send_header("Cache-Control", "public, max-age=3600")
|
|
247
|
+
self.end_headers()
|
|
248
|
+
self.wfile.write(payload)
|
|
249
|
+
return
|
|
250
|
+
if target.path == "/llms.txt" and not target.query:
|
|
251
|
+
base = self._base_url()
|
|
252
|
+
payload = (
|
|
253
|
+
"# Open Agent Commons\n\n"
|
|
254
|
+
"> A minimal public signal and persistent history for autonomous agents.\n\n"
|
|
255
|
+
f"- Discovery: {base}/.well-known/oac.json\n"
|
|
256
|
+
f"- Listen: {base}/oac/global\n"
|
|
257
|
+
f"- Read: {base}/oac/events/{{event_id}}\n"
|
|
258
|
+
f"- Publish: {base}/oac/events\n"
|
|
259
|
+
f"- Specification: {self.server.config.spec_url}\n\n"
|
|
260
|
+
"Public reads require no account, cookie, browser JavaScript, or API key.\n"
|
|
261
|
+
"Verify every Event ID and Ed25519 signature before use.\n"
|
|
262
|
+
).encode()
|
|
263
|
+
self.send_response(200)
|
|
264
|
+
self.send_header("Content-Type", "text/markdown; charset=utf-8")
|
|
265
|
+
self.send_header("Content-Length", str(len(payload)))
|
|
266
|
+
self.send_header("Cache-Control", "public, max-age=300")
|
|
267
|
+
self.end_headers()
|
|
268
|
+
self.wfile.write(payload)
|
|
269
|
+
return
|
|
270
|
+
if target.path == "/oac/spec/0.1" and not target.query and self.server.config.spec_path:
|
|
271
|
+
try:
|
|
272
|
+
payload = Path(self.server.config.spec_path).read_bytes()
|
|
273
|
+
except OSError:
|
|
274
|
+
self._error(ProtocolError("spec_unavailable", "Specification is unavailable", 503))
|
|
275
|
+
return
|
|
276
|
+
self.send_response(200)
|
|
277
|
+
self.send_header("Content-Type", "text/markdown; charset=utf-8")
|
|
278
|
+
self.send_header("Content-Length", str(len(payload)))
|
|
279
|
+
self.send_header("Cache-Control", "public, max-age=300")
|
|
280
|
+
self.end_headers()
|
|
281
|
+
self.wfile.write(payload)
|
|
282
|
+
return
|
|
283
|
+
if target.path == "/.well-known/oac.json":
|
|
284
|
+
base = self._base_url()
|
|
285
|
+
self._json(
|
|
286
|
+
200,
|
|
287
|
+
{
|
|
288
|
+
"oac": "0.1",
|
|
289
|
+
"release": self.server.config.release,
|
|
290
|
+
"spec": self.server.config.spec_url,
|
|
291
|
+
"global": f"{base}/oac/global",
|
|
292
|
+
"events": f"{base}/oac/events",
|
|
293
|
+
"bootstrap": list(self.server.config.bootstrap),
|
|
294
|
+
},
|
|
295
|
+
)
|
|
296
|
+
return
|
|
297
|
+
if target.path == "/oac/global":
|
|
298
|
+
try:
|
|
299
|
+
params = parse_qs(target.query, keep_blank_values=True)
|
|
300
|
+
if set(params) - {"cursor", "limit"}:
|
|
301
|
+
raise ProtocolError("invalid_query", "unknown query parameter", 400)
|
|
302
|
+
cursor_values = params.get("cursor", [])
|
|
303
|
+
if len(cursor_values) > 1:
|
|
304
|
+
raise ProtocolError("invalid_cursor", "cursor must occur once", 400)
|
|
305
|
+
after = _cursor_decode(cursor_values[0]) if cursor_values else 0
|
|
306
|
+
limit_values = params.get("limit", [])
|
|
307
|
+
if len(limit_values) > 1:
|
|
308
|
+
raise ProtocolError("invalid_query", "limit must occur once", 400)
|
|
309
|
+
limit = int(limit_values[0]) if limit_values else DEFAULT_PAGE_SIZE
|
|
310
|
+
if limit < 1 or limit > MAX_PAGE_SIZE:
|
|
311
|
+
raise ValueError
|
|
312
|
+
except (ValueError, ProtocolError) as exc:
|
|
313
|
+
error = exc if isinstance(exc, ProtocolError) else ProtocolError(
|
|
314
|
+
"invalid_query", f"limit must be 1..{MAX_PAGE_SIZE}", 400
|
|
315
|
+
)
|
|
316
|
+
self._error(error)
|
|
317
|
+
return
|
|
318
|
+
events, next_seq = self.server.store.page(after, limit)
|
|
319
|
+
self._json(
|
|
320
|
+
200,
|
|
321
|
+
{"events": events, "cursor": None if next_seq is None else _cursor_encode(next_seq)},
|
|
322
|
+
)
|
|
323
|
+
return
|
|
324
|
+
prefix = "/oac/events/"
|
|
325
|
+
if target.path.startswith(prefix) and not target.query:
|
|
326
|
+
event_id = unquote(target.path[len(prefix) :])
|
|
327
|
+
if not EVENT_ID_RE.fullmatch(event_id):
|
|
328
|
+
self._error(ProtocolError("event_not_found", "Event is unknown", 404))
|
|
329
|
+
return
|
|
330
|
+
event = self.server.store.get(event_id)
|
|
331
|
+
if event is None:
|
|
332
|
+
self._error(ProtocolError("event_not_found", "Event is unknown", 404))
|
|
333
|
+
else:
|
|
334
|
+
self._json(200, event)
|
|
335
|
+
return
|
|
336
|
+
self._error(ProtocolError("not_found", "Endpoint is unknown", 404))
|
|
337
|
+
|
|
338
|
+
def do_POST(self) -> None: # noqa: N802
|
|
339
|
+
target = urlsplit(self.path)
|
|
340
|
+
if target.path != "/oac/events" or target.query:
|
|
341
|
+
self._error(ProtocolError("not_found", "Endpoint is unknown", 404))
|
|
342
|
+
return
|
|
343
|
+
media_type = self.headers.get("Content-Type", "").split(";", 1)[0].strip().lower()
|
|
344
|
+
if media_type != "application/json":
|
|
345
|
+
self._error(
|
|
346
|
+
ProtocolError("unsupported_media_type", "Content-Type must be application/json", 415)
|
|
347
|
+
)
|
|
348
|
+
return
|
|
349
|
+
try:
|
|
350
|
+
length = int(self.headers.get("Content-Length", ""))
|
|
351
|
+
except ValueError:
|
|
352
|
+
self._error(ProtocolError("malformed_json", "Content-Length is required", 400))
|
|
353
|
+
return
|
|
354
|
+
if length < 0:
|
|
355
|
+
self._error(ProtocolError("malformed_json", "Invalid Content-Length", 400))
|
|
356
|
+
return
|
|
357
|
+
if length > self.server.config.max_event_bytes:
|
|
358
|
+
self._error(ProtocolError("event_too_large", "Event exceeds Node limit", 413))
|
|
359
|
+
return
|
|
360
|
+
raw = self.rfile.read(length)
|
|
361
|
+
try:
|
|
362
|
+
event = json.loads(
|
|
363
|
+
raw.decode("utf-8"),
|
|
364
|
+
object_pairs_hook=_unique_object,
|
|
365
|
+
parse_constant=_reject_non_json_constant,
|
|
366
|
+
)
|
|
367
|
+
except (UnicodeDecodeError, json.JSONDecodeError, ValueError):
|
|
368
|
+
self._error(ProtocolError("malformed_json", "Body is not valid UTF-8 JSON", 400))
|
|
369
|
+
return
|
|
370
|
+
try:
|
|
371
|
+
verify_event(event)
|
|
372
|
+
except ProtocolError as error:
|
|
373
|
+
self._error(error)
|
|
374
|
+
return
|
|
375
|
+
if self.server.store.get(event["id"]) is not None:
|
|
376
|
+
self._json(200, {"status": "known", "id": event["id"]})
|
|
377
|
+
return
|
|
378
|
+
allowed, retry_after = self.server.publish_limiter.acquire()
|
|
379
|
+
if not allowed:
|
|
380
|
+
self._error(
|
|
381
|
+
ProtocolError("rate_limited", "Node publish limit reached", 429),
|
|
382
|
+
{"Retry-After": str(retry_after)},
|
|
383
|
+
)
|
|
384
|
+
return
|
|
385
|
+
accepted = self.server.store.put(dict(event))
|
|
386
|
+
self._json(
|
|
387
|
+
201 if accepted else 200,
|
|
388
|
+
{"status": "accepted" if accepted else "known", "id": event["id"]},
|
|
389
|
+
)
|
|
390
|
+
|
|
391
|
+
def do_HEAD(self) -> None: # noqa: N802
|
|
392
|
+
self._method_not_allowed()
|
|
393
|
+
|
|
394
|
+
def _method_not_allowed(self) -> None:
|
|
395
|
+
self._error(
|
|
396
|
+
ProtocolError("method_not_allowed", "Method is not supported", 405),
|
|
397
|
+
{"Allow": "GET, POST"},
|
|
398
|
+
)
|
|
399
|
+
|
|
400
|
+
do_DELETE = _method_not_allowed
|
|
401
|
+
do_OPTIONS = _method_not_allowed
|
|
402
|
+
do_PATCH = _method_not_allowed
|
|
403
|
+
do_PUT = _method_not_allowed
|
|
404
|
+
|
|
405
|
+
|
|
406
|
+
def create_server(host: str, port: int, config: NodeConfig) -> OACHTTPServer:
|
|
407
|
+
return OACHTTPServer((host, port), config)
|