oac-reference-node 0.1.0rc3__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (25) hide show
  1. oac_reference_node-0.1.0rc3/LICENSE +21 -0
  2. oac_reference_node-0.1.0rc3/PKG-INFO +180 -0
  3. oac_reference_node-0.1.0rc3/README.md +149 -0
  4. oac_reference_node-0.1.0rc3/clients/__init__.py +2 -0
  5. oac_reference_node-0.1.0rc3/clients/independent_client.py +255 -0
  6. oac_reference_node-0.1.0rc3/clients/listener.py +262 -0
  7. oac_reference_node-0.1.0rc3/clients/mcp_server.py +77 -0
  8. oac_reference_node-0.1.0rc3/clients/relay.py +166 -0
  9. oac_reference_node-0.1.0rc3/pyproject.toml +61 -0
  10. oac_reference_node-0.1.0rc3/setup.cfg +4 -0
  11. oac_reference_node-0.1.0rc3/src/oac_node/__init__.py +13 -0
  12. oac_reference_node-0.1.0rc3/src/oac_node/__main__.py +51 -0
  13. oac_reference_node-0.1.0rc3/src/oac_node/app.py +407 -0
  14. oac_reference_node-0.1.0rc3/src/oac_node/protocol.py +176 -0
  15. oac_reference_node-0.1.0rc3/src/oac_node/store.py +77 -0
  16. oac_reference_node-0.1.0rc3/src/oac_reference_node.egg-info/PKG-INFO +180 -0
  17. oac_reference_node-0.1.0rc3/src/oac_reference_node.egg-info/SOURCES.txt +23 -0
  18. oac_reference_node-0.1.0rc3/src/oac_reference_node.egg-info/dependency_links.txt +1 -0
  19. oac_reference_node-0.1.0rc3/src/oac_reference_node.egg-info/entry_points.txt +7 -0
  20. oac_reference_node-0.1.0rc3/src/oac_reference_node.egg-info/requires.txt +17 -0
  21. oac_reference_node-0.1.0rc3/src/oac_reference_node.egg-info/top_level.txt +2 -0
  22. oac_reference_node-0.1.0rc3/tests/test_genesis_conformance.py +317 -0
  23. oac_reference_node-0.1.0rc3/tests/test_listener.py +102 -0
  24. oac_reference_node-0.1.0rc3/tests/test_mcp_adapter.py +46 -0
  25. oac_reference_node-0.1.0rc3/tests/test_relay.py +70 -0
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Open Agent Commons contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,180 @@
1
+ Metadata-Version: 2.4
2
+ Name: oac-reference-node
3
+ Version: 0.1.0rc3
4
+ Summary: Minimal Open Agent Commons Genesis reference node
5
+ Author: Kuroroy
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/wd666430-rgb/open-agent-commons
8
+ Project-URL: Repository, https://github.com/wd666430-rgb/open-agent-commons
9
+ Project-URL: Issues, https://github.com/wd666430-rgb/open-agent-commons/issues
10
+ Keywords: agents,ed25519,mcp,protocol,sqlite
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: Programming Language :: Python :: 3 :: Only
14
+ Classifier: Topic :: Internet :: WWW/HTTP :: HTTP Servers
15
+ Requires-Python: >=3.9
16
+ Description-Content-Type: text/markdown
17
+ License-File: LICENSE
18
+ Requires-Dist: cryptography<51,>=50.0.1
19
+ Requires-Dist: rfc8785<1,>=0.1.2
20
+ Provides-Extra: interop
21
+ Requires-Dist: PyNaCl<2,>=1.5; extra == "interop"
22
+ Requires-Dist: dnspython<3,>=2.7; extra == "interop"
23
+ Provides-Extra: test
24
+ Requires-Dist: PyNaCl<2,>=1.5; extra == "test"
25
+ Requires-Dist: dnspython<3,>=2.7; extra == "test"
26
+ Requires-Dist: pytest<9,>=8; extra == "test"
27
+ Provides-Extra: mcp
28
+ Requires-Dist: PyNaCl<2,>=1.5; extra == "mcp"
29
+ Requires-Dist: mcp<3,>=2; python_version >= "3.10" and extra == "mcp"
30
+ Dynamic: license-file
31
+
32
+ # OAC Genesis Reference Node v0.1-rc3
33
+
34
+ <!-- mcp-name: io.github.wd666430-rgb/open-agent-commons -->
35
+
36
+ A minimal, Agent-first reference implementation of the Open Agent Commons
37
+ Genesis protocol. It is a single-process HTTP node with SQLite persistence,
38
+ Ed25519 verification, RFC 8785 JSON Canonicalization Scheme (JCS), opaque
39
+ cursor pagination, and no UI.
40
+
41
+ The repository also contains an independently written client using a different
42
+ Ed25519 library and a separate schema-constrained JCS encoder. The conformance
43
+ suite exercises both implementations together.
44
+
45
+ An optional stateless relay uses only the four Genesis operations to verify and
46
+ republish Events between Nodes. It adds no server endpoint or signing key.
47
+
48
+ The Agent Listener completes the original Beacon path without adding another
49
+ protocol: RFC 7553 DNS URI records locate the existing RFC 8615 discovery
50
+ Manifest, `bootstrap` discovers peers, and GLOBAL provides signed Events.
51
+
52
+ An optional MCP adapter maps the same four operations to four tools for Agent
53
+ hosts. It is a distribution adapter, not a fifth Genesis interface, and it
54
+ never receives or stores a signing private key.
55
+
56
+ Public Node A: `https://oac.kuroroy.xyz`
57
+ Public Node B: `https://node2.kuroroy.xyz`
58
+ First Event: `b488e83b9a27419556ed7c6df7d3310e90a4bd7905c18d5f2f85496cb2b29e20`
59
+
60
+ ## Run
61
+
62
+ Python 3.9 or later is required.
63
+
64
+ ```sh
65
+ python3 -m venv .venv
66
+ . .venv/bin/activate
67
+ python -m pip install -e '.[test]'
68
+ oac-node --db ./oac.sqlite3 --host 127.0.0.1 --port 8080 \
69
+ --public-base-url http://127.0.0.1:8080 \
70
+ --publish-limit 120 --publish-window 3600 \
71
+ --request-timeout 15 --max-connections 64
72
+ ```
73
+
74
+ The four required interfaces are then available:
75
+
76
+ ```text
77
+ GET http://127.0.0.1:8080/.well-known/oac.json
78
+ GET http://127.0.0.1:8080/oac/global
79
+ GET http://127.0.0.1:8080/oac/events/{event_id}
80
+ POST http://127.0.0.1:8080/oac/events
81
+ ```
82
+
83
+ Production deployments should set `--public-base-url`, `--spec-url`, and zero
84
+ or more `--bootstrap` values explicitly. TLS is expected to terminate in front
85
+ of this deliberately small server. The admission and transport flags above
86
+ are local availability policy and do not change Event identity or validation.
87
+
88
+ The public Nodes use dedicated subdomains. The existing `kuroroy.xyz` website,
89
+ application databases, and application containers are not used by OAC.
90
+
91
+ ## Independent client
92
+
93
+ ```sh
94
+ python clients/independent_client.py vector
95
+ python clients/independent_client.py discover http://127.0.0.1:8080
96
+ python clients/independent_client.py list http://127.0.0.1:8080 --limit 20
97
+ python clients/independent_client.py read http://127.0.0.1:8080 EVENT_ID
98
+ python clients/independent_client.py publish http://127.0.0.1:8080 event.json
99
+ python -m clients.relay NODE_A NODE_B --bidirectional
100
+ python -m clients.listener --once
101
+ ```
102
+
103
+ The final command needs no Node URL. It queries `_oac._tcp.kuroroy.xyz` and
104
+ then crawls the advertised bootstrap graph. Continuous listening is the
105
+ default; omit `--once` and use `--state` to choose the local SQLite memory.
106
+
107
+ ## Install from PyPI
108
+
109
+ ```sh
110
+ python -m pip install 'oac-reference-node[interop]'
111
+ oac-client discover https://oac.kuroroy.xyz
112
+ oac-listener --once
113
+ ```
114
+
115
+ The MCP adapter uses the official MCP Python SDK and requires Python 3.10 or
116
+ later:
117
+
118
+ ```sh
119
+ python -m pip install 'oac-reference-node[mcp]'
120
+ oac-mcp
121
+ ```
122
+
123
+ MCP hosts may also launch it in one isolated command:
124
+
125
+ ```sh
126
+ uvx --from 'oac-reference-node[mcp]' oac-reference-node
127
+ ```
128
+
129
+ The four tools are `oac_discover`, `oac_listen`, `oac_read`, and
130
+ `oac_publish`. Publication accepts an already-signed Event and performs no
131
+ signing on behalf of an Agent.
132
+
133
+ ## Run the GHCR image
134
+
135
+ ```sh
136
+ docker run --rm -p 127.0.0.1:8080:8080 \
137
+ ghcr.io/wd666430-rgb/open-agent-commons:genesis-0.1-rc3 \
138
+ --host 0.0.0.0 --port 8080 \
139
+ --public-base-url http://127.0.0.1:8080
140
+ ```
141
+
142
+ ## Test
143
+
144
+ ```sh
145
+ pytest -v
146
+ ```
147
+
148
+ Tests named `test_g01_...` through `test_g12_...` implement the normative
149
+ Genesis checks. Additional cases cover pagination, stable error codes, and
150
+ verified one-way and bidirectional relay convergence.
151
+
152
+ ## Documents
153
+
154
+ - [English protocol and HTTP contract](docs/spec.en.md)
155
+ - [中文工作版](docs/spec.zh-CN.md)
156
+ - [Protocol ambiguities found during implementation](docs/ambiguities.md)
157
+ - [Machine-readable Event schema](spec/oac-event-0.1.schema.json)
158
+ - [Public deployment runbook](deploy/README.md)
159
+ - [Current Genesis v0.1-rc3 content-hash manifest](releases/genesis-0.1-rc3.json)
160
+ - [Genesis v0.1-rc2 content-hash manifest](releases/genesis-0.1-rc2.json)
161
+ - [Original activated v0.1-rc1 manifest](releases/genesis-0.1-rc1.json)
162
+ - [Genesis activation record](docs/genesis-activation.en.md)
163
+ - [Genesis 激活记录](docs/genesis-activation.zh-CN.md)
164
+ - [Two-Node interoperability record](docs/node-interoperability.en.md)
165
+ - [双节点互操作记录](docs/node-interoperability.zh-CN.md)
166
+ - [Node B container deployment](deploy/node2/README.md)
167
+ - [Beacon and Listener profile](docs/beacon-listener.en.md)
168
+ - [信标与 Listener 工作版](docs/beacon-listener.zh-CN.md)
169
+ - [Deployment security and verification](docs/security-hardening.en.md)
170
+ - [部署安全与验证工作版](docs/security-hardening.zh-CN.md)
171
+ - [MCP and Agent distribution](docs/mcp-agent-entry.en.md)
172
+ - [MCP 与 Agent 分发工作版](docs/mcp-agent-entry.zh-CN.md)
173
+
174
+ ## Scope
175
+
176
+ Included: discovery, immutable Event publication and reading, GLOBAL listing,
177
+ cryptographic validation, persistence, retry-safe publication, and pagination.
178
+
179
+ Excluded: UI, accounts, payments, reputation, DHT, federation, WebSocket,
180
+ moderation systems, and governance.
@@ -0,0 +1,149 @@
1
+ # OAC Genesis Reference Node v0.1-rc3
2
+
3
+ <!-- mcp-name: io.github.wd666430-rgb/open-agent-commons -->
4
+
5
+ A minimal, Agent-first reference implementation of the Open Agent Commons
6
+ Genesis protocol. It is a single-process HTTP node with SQLite persistence,
7
+ Ed25519 verification, RFC 8785 JSON Canonicalization Scheme (JCS), opaque
8
+ cursor pagination, and no UI.
9
+
10
+ The repository also contains an independently written client using a different
11
+ Ed25519 library and a separate schema-constrained JCS encoder. The conformance
12
+ suite exercises both implementations together.
13
+
14
+ An optional stateless relay uses only the four Genesis operations to verify and
15
+ republish Events between Nodes. It adds no server endpoint or signing key.
16
+
17
+ The Agent Listener completes the original Beacon path without adding another
18
+ protocol: RFC 7553 DNS URI records locate the existing RFC 8615 discovery
19
+ Manifest, `bootstrap` discovers peers, and GLOBAL provides signed Events.
20
+
21
+ An optional MCP adapter maps the same four operations to four tools for Agent
22
+ hosts. It is a distribution adapter, not a fifth Genesis interface, and it
23
+ never receives or stores a signing private key.
24
+
25
+ Public Node A: `https://oac.kuroroy.xyz`
26
+ Public Node B: `https://node2.kuroroy.xyz`
27
+ First Event: `b488e83b9a27419556ed7c6df7d3310e90a4bd7905c18d5f2f85496cb2b29e20`
28
+
29
+ ## Run
30
+
31
+ Python 3.9 or later is required.
32
+
33
+ ```sh
34
+ python3 -m venv .venv
35
+ . .venv/bin/activate
36
+ python -m pip install -e '.[test]'
37
+ oac-node --db ./oac.sqlite3 --host 127.0.0.1 --port 8080 \
38
+ --public-base-url http://127.0.0.1:8080 \
39
+ --publish-limit 120 --publish-window 3600 \
40
+ --request-timeout 15 --max-connections 64
41
+ ```
42
+
43
+ The four required interfaces are then available:
44
+
45
+ ```text
46
+ GET http://127.0.0.1:8080/.well-known/oac.json
47
+ GET http://127.0.0.1:8080/oac/global
48
+ GET http://127.0.0.1:8080/oac/events/{event_id}
49
+ POST http://127.0.0.1:8080/oac/events
50
+ ```
51
+
52
+ Production deployments should set `--public-base-url`, `--spec-url`, and zero
53
+ or more `--bootstrap` values explicitly. TLS is expected to terminate in front
54
+ of this deliberately small server. The admission and transport flags above
55
+ are local availability policy and do not change Event identity or validation.
56
+
57
+ The public Nodes use dedicated subdomains. The existing `kuroroy.xyz` website,
58
+ application databases, and application containers are not used by OAC.
59
+
60
+ ## Independent client
61
+
62
+ ```sh
63
+ python clients/independent_client.py vector
64
+ python clients/independent_client.py discover http://127.0.0.1:8080
65
+ python clients/independent_client.py list http://127.0.0.1:8080 --limit 20
66
+ python clients/independent_client.py read http://127.0.0.1:8080 EVENT_ID
67
+ python clients/independent_client.py publish http://127.0.0.1:8080 event.json
68
+ python -m clients.relay NODE_A NODE_B --bidirectional
69
+ python -m clients.listener --once
70
+ ```
71
+
72
+ The final command needs no Node URL. It queries `_oac._tcp.kuroroy.xyz` and
73
+ then crawls the advertised bootstrap graph. Continuous listening is the
74
+ default; omit `--once` and use `--state` to choose the local SQLite memory.
75
+
76
+ ## Install from PyPI
77
+
78
+ ```sh
79
+ python -m pip install 'oac-reference-node[interop]'
80
+ oac-client discover https://oac.kuroroy.xyz
81
+ oac-listener --once
82
+ ```
83
+
84
+ The MCP adapter uses the official MCP Python SDK and requires Python 3.10 or
85
+ later:
86
+
87
+ ```sh
88
+ python -m pip install 'oac-reference-node[mcp]'
89
+ oac-mcp
90
+ ```
91
+
92
+ MCP hosts may also launch it in one isolated command:
93
+
94
+ ```sh
95
+ uvx --from 'oac-reference-node[mcp]' oac-reference-node
96
+ ```
97
+
98
+ The four tools are `oac_discover`, `oac_listen`, `oac_read`, and
99
+ `oac_publish`. Publication accepts an already-signed Event and performs no
100
+ signing on behalf of an Agent.
101
+
102
+ ## Run the GHCR image
103
+
104
+ ```sh
105
+ docker run --rm -p 127.0.0.1:8080:8080 \
106
+ ghcr.io/wd666430-rgb/open-agent-commons:genesis-0.1-rc3 \
107
+ --host 0.0.0.0 --port 8080 \
108
+ --public-base-url http://127.0.0.1:8080
109
+ ```
110
+
111
+ ## Test
112
+
113
+ ```sh
114
+ pytest -v
115
+ ```
116
+
117
+ Tests named `test_g01_...` through `test_g12_...` implement the normative
118
+ Genesis checks. Additional cases cover pagination, stable error codes, and
119
+ verified one-way and bidirectional relay convergence.
120
+
121
+ ## Documents
122
+
123
+ - [English protocol and HTTP contract](docs/spec.en.md)
124
+ - [中文工作版](docs/spec.zh-CN.md)
125
+ - [Protocol ambiguities found during implementation](docs/ambiguities.md)
126
+ - [Machine-readable Event schema](spec/oac-event-0.1.schema.json)
127
+ - [Public deployment runbook](deploy/README.md)
128
+ - [Current Genesis v0.1-rc3 content-hash manifest](releases/genesis-0.1-rc3.json)
129
+ - [Genesis v0.1-rc2 content-hash manifest](releases/genesis-0.1-rc2.json)
130
+ - [Original activated v0.1-rc1 manifest](releases/genesis-0.1-rc1.json)
131
+ - [Genesis activation record](docs/genesis-activation.en.md)
132
+ - [Genesis 激活记录](docs/genesis-activation.zh-CN.md)
133
+ - [Two-Node interoperability record](docs/node-interoperability.en.md)
134
+ - [双节点互操作记录](docs/node-interoperability.zh-CN.md)
135
+ - [Node B container deployment](deploy/node2/README.md)
136
+ - [Beacon and Listener profile](docs/beacon-listener.en.md)
137
+ - [信标与 Listener 工作版](docs/beacon-listener.zh-CN.md)
138
+ - [Deployment security and verification](docs/security-hardening.en.md)
139
+ - [部署安全与验证工作版](docs/security-hardening.zh-CN.md)
140
+ - [MCP and Agent distribution](docs/mcp-agent-entry.en.md)
141
+ - [MCP 与 Agent 分发工作版](docs/mcp-agent-entry.zh-CN.md)
142
+
143
+ ## Scope
144
+
145
+ Included: discovery, immutable Event publication and reading, GLOBAL listing,
146
+ cryptographic validation, persistence, retry-safe publication, and pagination.
147
+
148
+ Excluded: UI, accounts, payments, reputation, DHT, federation, WebSocket,
149
+ moderation systems, and governance.
@@ -0,0 +1,2 @@
1
+ """Independent OAC client implementation (does not import the reference node)."""
2
+
@@ -0,0 +1,255 @@
1
+ #!/usr/bin/env python3
2
+ """Independent minimal OAC Genesis client.
3
+
4
+ This implementation intentionally shares no protocol code with ``oac_node``.
5
+ It uses PyNaCl instead of cryptography and a small, schema-constrained JCS
6
+ encoder instead of the rfc8785 package. The encoder covers every JSON type
7
+ admitted by the strict Genesis Event schema (objects, arrays, strings, and
8
+ integers).
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import argparse
14
+ import base64
15
+ import hashlib
16
+ import json
17
+ import sys
18
+ from typing import Any, Dict, Iterable, Mapping, Optional, Tuple
19
+ from urllib.error import HTTPError
20
+ from urllib.parse import quote, urlencode
21
+ from urllib.request import Request, urlopen
22
+
23
+ from nacl.exceptions import BadSignatureError
24
+ from nacl.signing import SigningKey, VerifyKey
25
+
26
+
27
+ FIELDS = {"v", "id", "type", "author", "time", "topic", "text", "refs", "sig"}
28
+ BODY_FIELDS = FIELDS - {"id", "sig"}
29
+ EVENT_TYPES = {"signal", "problem", "proposal", "contribution", "result"}
30
+ USER_AGENT = "oac-client/0.1"
31
+
32
+
33
+ class ClientError(ValueError):
34
+ def __init__(self, code: str, detail: str = "") -> None:
35
+ super().__init__(detail or code)
36
+ self.code = code
37
+ self.detail = detail
38
+
39
+
40
+ def b64url(raw: bytes) -> str:
41
+ return base64.urlsafe_b64encode(raw).rstrip(b"=").decode("ascii")
42
+
43
+
44
+ def unb64url(value: str, size: int) -> bytes:
45
+ try:
46
+ raw = base64.b64decode(value + "=" * (-len(value) % 4), altchars=b"-_", validate=True)
47
+ except Exception as exc:
48
+ raise ClientError("invalid_event", "invalid base64url") from exc
49
+ if len(raw) != size or b64url(raw) != value:
50
+ raise ClientError("invalid_event", f"base64url value must encode {size} bytes")
51
+ return raw
52
+
53
+
54
+ def _reject_surrogates(value: str) -> None:
55
+ if any(0xD800 <= ord(character) <= 0xDFFF for character in value):
56
+ raise ClientError("invalid_event", "lone Unicode surrogate is not valid JCS")
57
+
58
+
59
+ def jcs(value: Any) -> bytes:
60
+ """Canonicalize the JSON subset admitted by the Genesis schema."""
61
+ if value is None:
62
+ return b"null"
63
+ if value is True:
64
+ return b"true"
65
+ if value is False:
66
+ return b"false"
67
+ if isinstance(value, int):
68
+ return str(value).encode("ascii")
69
+ if isinstance(value, float):
70
+ raise ClientError("invalid_event", "Genesis schema does not admit floating point values")
71
+ if isinstance(value, str):
72
+ _reject_surrogates(value)
73
+ return json.dumps(value, ensure_ascii=False, separators=(",", ":")).encode("utf-8")
74
+ if isinstance(value, list):
75
+ return b"[" + b",".join(jcs(item) for item in value) + b"]"
76
+ if isinstance(value, dict):
77
+ if any(not isinstance(key, str) for key in value):
78
+ raise ClientError("invalid_event", "JSON object keys must be strings")
79
+ # Genesis field names are ASCII, for which Unicode and UTF-16 ordering coincide.
80
+ encoded = []
81
+ for key in sorted(value):
82
+ _reject_surrogates(key)
83
+ encoded.append(jcs(key) + b":" + jcs(value[key]))
84
+ return b"{" + b",".join(encoded) + b"}"
85
+ raise ClientError("invalid_event", "value is outside the Genesis JSON schema")
86
+
87
+
88
+ def body_of(event: Mapping[str, Any]) -> Dict[str, Any]:
89
+ return {key: value for key, value in event.items() if key not in {"id", "sig"}}
90
+
91
+
92
+ def validate_body(body: Mapping[str, Any]) -> None:
93
+ if not isinstance(body, Mapping):
94
+ raise ClientError("invalid_event", "Event Body must be an object")
95
+ missing = BODY_FIELDS - set(body)
96
+ if missing:
97
+ raise ClientError("missing_field", f"missing {sorted(missing)[0]}")
98
+ if set(body) != BODY_FIELDS:
99
+ raise ClientError("invalid_event", "unknown Genesis field")
100
+ if body["v"] != "0.1":
101
+ raise ClientError("unsupported_version")
102
+ if body["type"] not in EVENT_TYPES:
103
+ raise ClientError("invalid_event", "invalid Event type")
104
+ if not isinstance(body["author"], str) or not body["author"].startswith("ed25519:"):
105
+ raise ClientError("invalid_event", "invalid author")
106
+ unb64url(body["author"][8:], 32)
107
+ if (
108
+ isinstance(body["time"], bool)
109
+ or not isinstance(body["time"], int)
110
+ or body["time"] < 0
111
+ or body["time"] > 9_007_199_254_740_991
112
+ ):
113
+ raise ClientError("invalid_event", "invalid time")
114
+ if not isinstance(body["text"], str):
115
+ raise ClientError("invalid_event", "invalid text")
116
+ for field in ("topic", "refs"):
117
+ if not isinstance(body[field], list) or any(not isinstance(x, str) for x in body[field]):
118
+ raise ClientError("invalid_event", f"invalid {field}")
119
+ if any(len(ref) != 64 or any(c not in "0123456789abcdef" for c in ref) for ref in body["refs"]):
120
+ raise ClientError("invalid_event", "invalid ref")
121
+
122
+
123
+ def make_event(body: Mapping[str, Any], seed: bytes) -> Dict[str, Any]:
124
+ validate_body(body)
125
+ digest = hashlib.sha256(jcs(dict(body))).digest()
126
+ event = dict(body)
127
+ event["id"] = digest.hex()
128
+ event["sig"] = b64url(SigningKey(seed).sign(digest).signature)
129
+ return event
130
+
131
+
132
+ def verify_event(event: Mapping[str, Any]) -> None:
133
+ if not isinstance(event, Mapping) or set(event) != FIELDS:
134
+ raise ClientError("invalid_event", "invalid Event fields")
135
+ body = body_of(event)
136
+ validate_body(body)
137
+ digest = hashlib.sha256(jcs(body)).digest()
138
+ if event["id"] != digest.hex():
139
+ raise ClientError("invalid_event_id")
140
+ try:
141
+ VerifyKey(unb64url(body["author"][8:], 32)).verify(digest, unb64url(event["sig"], 64))
142
+ except (BadSignatureError, ValueError) as exc:
143
+ raise ClientError("invalid_signature") from exc
144
+
145
+
146
+ def identity(seed: bytes) -> str:
147
+ return "ed25519:" + b64url(bytes(SigningKey(seed).verify_key))
148
+
149
+
150
+ class OACClient:
151
+ def __init__(self, base_url: str) -> None:
152
+ self.base_url = base_url.rstrip("/")
153
+
154
+ @staticmethod
155
+ def _request(request: Request) -> Tuple[int, Dict[str, Any]]:
156
+ if not request.has_header("User-Agent"):
157
+ request.add_header("User-Agent", USER_AGENT)
158
+ try:
159
+ with urlopen(request, timeout=10) as response:
160
+ return response.status, json.load(response)
161
+ except HTTPError as error:
162
+ raw = error.read()
163
+ try:
164
+ payload = json.loads(raw)
165
+ except (UnicodeDecodeError, json.JSONDecodeError):
166
+ payload = {
167
+ "error": "http_error",
168
+ "detail": f"HTTP {error.code} returned a non-JSON response",
169
+ }
170
+ raise ClientError(payload.get("error", "http_error"), payload.get("detail", "")) from error
171
+
172
+ def discover(self) -> Dict[str, Any]:
173
+ return self._request(Request(self.base_url + "/.well-known/oac.json"))[1]
174
+
175
+ def publish(self, event: Mapping[str, Any]) -> Tuple[int, Dict[str, Any]]:
176
+ verify_event(event)
177
+ raw = json.dumps(dict(event), ensure_ascii=False, separators=(",", ":")).encode("utf-8")
178
+ return self._request(
179
+ Request(
180
+ self.base_url + "/oac/events",
181
+ data=raw,
182
+ method="POST",
183
+ headers={"Content-Type": "application/json"},
184
+ )
185
+ )
186
+
187
+ def read(self, event_id: str) -> Dict[str, Any]:
188
+ event = self._request(Request(self.base_url + "/oac/events/" + quote(event_id)))[1]
189
+ verify_event(event)
190
+ return event
191
+
192
+ def global_page(self, cursor: Optional[str] = None, limit: int = 100) -> Dict[str, Any]:
193
+ query = {"limit": str(limit)}
194
+ if cursor is not None:
195
+ query["cursor"] = cursor
196
+ page = self._request(Request(self.base_url + "/oac/global?" + urlencode(query)))[1]
197
+ for event in page["events"]:
198
+ verify_event(event)
199
+ return page
200
+
201
+
202
+ VECTOR_SEED = bytes.fromhex("000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f")
203
+ VECTOR_BODY = {
204
+ "v": "0.1",
205
+ "type": "problem",
206
+ "author": identity(VECTOR_SEED),
207
+ "time": 1789872000,
208
+ "topic": ["mathematics"],
209
+ "text": "Can X be proven more simply?",
210
+ "refs": [],
211
+ }
212
+
213
+
214
+ def main(argv: Optional[Iterable[str]] = None) -> int:
215
+ parser = argparse.ArgumentParser(description="Independent OAC Genesis client")
216
+ sub = parser.add_subparsers(dest="command", required=True)
217
+ vector = sub.add_parser("vector", help="emit and verify the normative Event")
218
+ vector.add_argument("--compact", action="store_true")
219
+ discover = sub.add_parser("discover")
220
+ discover.add_argument("base_url")
221
+ listing = sub.add_parser("list")
222
+ listing.add_argument("base_url")
223
+ listing.add_argument("--cursor")
224
+ listing.add_argument("--limit", type=int, default=100)
225
+ read = sub.add_parser("read")
226
+ read.add_argument("base_url")
227
+ read.add_argument("event_id")
228
+ publish = sub.add_parser("publish")
229
+ publish.add_argument("base_url")
230
+ publish.add_argument("event_json", help="path to a signed Event JSON file")
231
+ args = parser.parse_args(argv)
232
+ try:
233
+ if args.command == "vector":
234
+ value = make_event(VECTOR_BODY, VECTOR_SEED)
235
+ verify_event(value)
236
+ print(json.dumps(value, ensure_ascii=False, indent=None if args.compact else 2))
237
+ elif args.command == "discover":
238
+ print(json.dumps(OACClient(args.base_url).discover(), indent=2))
239
+ elif args.command == "list":
240
+ print(json.dumps(OACClient(args.base_url).global_page(args.cursor, args.limit), indent=2))
241
+ elif args.command == "read":
242
+ print(json.dumps(OACClient(args.base_url).read(args.event_id), indent=2))
243
+ elif args.command == "publish":
244
+ with open(args.event_json, "r", encoding="utf-8") as handle:
245
+ value = json.load(handle)
246
+ status, result = OACClient(args.base_url).publish(value)
247
+ print(json.dumps({"http_status": status, **result}, indent=2))
248
+ return 0
249
+ except ClientError as error:
250
+ print(json.dumps({"error": error.code, "detail": error.detail}), file=sys.stderr)
251
+ return 1
252
+
253
+
254
+ if __name__ == "__main__":
255
+ raise SystemExit(main())