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.
- oac_reference_node-0.1.0rc3/LICENSE +21 -0
- oac_reference_node-0.1.0rc3/PKG-INFO +180 -0
- oac_reference_node-0.1.0rc3/README.md +149 -0
- oac_reference_node-0.1.0rc3/clients/__init__.py +2 -0
- oac_reference_node-0.1.0rc3/clients/independent_client.py +255 -0
- oac_reference_node-0.1.0rc3/clients/listener.py +262 -0
- oac_reference_node-0.1.0rc3/clients/mcp_server.py +77 -0
- oac_reference_node-0.1.0rc3/clients/relay.py +166 -0
- oac_reference_node-0.1.0rc3/pyproject.toml +61 -0
- oac_reference_node-0.1.0rc3/setup.cfg +4 -0
- oac_reference_node-0.1.0rc3/src/oac_node/__init__.py +13 -0
- oac_reference_node-0.1.0rc3/src/oac_node/__main__.py +51 -0
- oac_reference_node-0.1.0rc3/src/oac_node/app.py +407 -0
- oac_reference_node-0.1.0rc3/src/oac_node/protocol.py +176 -0
- oac_reference_node-0.1.0rc3/src/oac_node/store.py +77 -0
- oac_reference_node-0.1.0rc3/src/oac_reference_node.egg-info/PKG-INFO +180 -0
- oac_reference_node-0.1.0rc3/src/oac_reference_node.egg-info/SOURCES.txt +23 -0
- oac_reference_node-0.1.0rc3/src/oac_reference_node.egg-info/dependency_links.txt +1 -0
- oac_reference_node-0.1.0rc3/src/oac_reference_node.egg-info/entry_points.txt +7 -0
- oac_reference_node-0.1.0rc3/src/oac_reference_node.egg-info/requires.txt +17 -0
- oac_reference_node-0.1.0rc3/src/oac_reference_node.egg-info/top_level.txt +2 -0
- oac_reference_node-0.1.0rc3/tests/test_genesis_conformance.py +317 -0
- oac_reference_node-0.1.0rc3/tests/test_listener.py +102 -0
- oac_reference_node-0.1.0rc3/tests/test_mcp_adapter.py +46 -0
- 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,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())
|