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/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)