sm-org-server 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- sm_org_server-0.1.0.dist-info/METADATA +146 -0
- sm_org_server-0.1.0.dist-info/RECORD +12 -0
- sm_org_server-0.1.0.dist-info/WHEEL +4 -0
- sm_org_server-0.1.0.dist-info/licenses/LICENSE +21 -0
- sm_server/__init__.py +7 -0
- sm_server/app.py +512 -0
- sm_server/merkle.py +125 -0
- sm_server/signing.py +55 -0
- sm_server/store/__init__.py +0 -0
- sm_server/store/base.py +81 -0
- sm_server/store/sqlite.py +182 -0
- sm_server/trust.py +63 -0
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: sm-org-server
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Minimal, backend-agnostic, conformance-passing chapter server for federated AI agents.
|
|
5
|
+
Project-URL: Homepage, https://github.com/Sharathvc23/sm-org-server
|
|
6
|
+
Project-URL: Repository, https://github.com/Sharathvc23/sm-org-server
|
|
7
|
+
Project-URL: Changelog, https://github.com/Sharathvc23/sm-org-server/blob/main/CHANGELOG.md
|
|
8
|
+
Author: stellarminds.ai
|
|
9
|
+
License-Expression: MIT
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Keywords: agents,chapter,conformance,did,federation,server
|
|
12
|
+
Classifier: Development Status :: 3 - Alpha
|
|
13
|
+
Classifier: Framework :: FastAPI
|
|
14
|
+
Classifier: Intended Audience :: Developers
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
18
|
+
Classifier: Topic :: Internet :: WWW/HTTP
|
|
19
|
+
Classifier: Topic :: Security :: Cryptography
|
|
20
|
+
Requires-Python: >=3.11
|
|
21
|
+
Requires-Dist: base58>=2.1
|
|
22
|
+
Requires-Dist: cryptography>=42
|
|
23
|
+
Requires-Dist: fastapi>=0.110
|
|
24
|
+
Requires-Dist: jcs>=0.2
|
|
25
|
+
Requires-Dist: sm-arp>=0.2.1
|
|
26
|
+
Requires-Dist: uvicorn>=0.27
|
|
27
|
+
Provides-Extra: dev
|
|
28
|
+
Requires-Dist: httpx>=0.27; extra == 'dev'
|
|
29
|
+
Requires-Dist: mypy>=1.11; extra == 'dev'
|
|
30
|
+
Requires-Dist: pytest-cov>=5.0; extra == 'dev'
|
|
31
|
+
Requires-Dist: pytest>=8.0; extra == 'dev'
|
|
32
|
+
Requires-Dist: ruff>=0.6; extra == 'dev'
|
|
33
|
+
Description-Content-Type: text/markdown
|
|
34
|
+
|
|
35
|
+
# sm-org-server
|
|
36
|
+
|
|
37
|
+
**A minimal, backend-agnostic server for federated AI agents — small enough to read in one sitting, conformant enough to federate.**
|
|
38
|
+
|
|
39
|
+
A *server* is a home server for a community of AI agents: it registers them, gives each a verifiable identity, scores their trustworthiness, renders their shared surfaces, and federates with peer servers. `sm-org-server` is the smallest thing that does all of that correctly — the entire conformant wire is **~550 lines of Python against a swappable storage interface**, with no database lock-in, no LLM dependency, and no framework magic.
|
|
40
|
+
|
|
41
|
+
The intelligence, governance, and product features of a real server live *above* this line, as your own code. `sm-org-server` is the floor everyone shares.
|
|
42
|
+
|
|
43
|
+
```
|
|
44
|
+
pip install sm-org-server
|
|
45
|
+
uvicorn sm_server.app:app
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
That's a federating server with a SQLite backend, an Ed25519 identity, a trust ledger, and a clean HTTP surface — running on your laptop.
|
|
49
|
+
|
|
50
|
+
## What you get
|
|
51
|
+
|
|
52
|
+
| Endpoint | Purpose |
|
|
53
|
+
|---|---|
|
|
54
|
+
| `POST /api/members` | Register an agent (origin-gated, TOFU public key) |
|
|
55
|
+
| `POST /api/members/rotate` | Rotate an agent's signing key (signed attestation, nonce-protected) |
|
|
56
|
+
| `POST /api/feedback` | Signed request → trust event (Ed25519, key-consistency, replay window) + a server-signed receipt |
|
|
57
|
+
| `GET /api/agents/{id}/trust` | Trust dossier: score, tier, history |
|
|
58
|
+
| `POST /api/receipts` | Ingest a signed ARP receipt into the Issuer Log (verify → hash-chain → persist) |
|
|
59
|
+
| `GET /api/receipts/recent` | Recent receipts (filter by `?principal=`) |
|
|
60
|
+
| `GET /api/receipts/{id}` | One receipt by id |
|
|
61
|
+
| `GET /api/surfaces/{id}` | A2UI surface envelope (the wire shape every renderer agrees on; `receipts` is a live one) |
|
|
62
|
+
| `GET /api/federation` | Federation overview + per-peer member views |
|
|
63
|
+
| `GET /.well-known/nanda-agent.json` | Discovery substrate for peers (did, facts_url, registries, conformance) |
|
|
64
|
+
| `GET /.well-known/conformance.json` | The signed wire conformance badge — public, no-auth, offline-verifiable |
|
|
65
|
+
| `GET /.well-known/arp-conformance.json` | The signed **ARP** receipt-suite badge |
|
|
66
|
+
|
|
67
|
+
## Why it's this small
|
|
68
|
+
|
|
69
|
+
Most "agent platform" servers fuse three things that don't belong together: the **protocol wire** (what makes two servers interoperable), the **storage** (Postgres, Supabase, whatever), and the **agent brain** (the LLM, the policies, the product). Fuse them and the only way to be "compliant" is to adopt the whole stack.
|
|
70
|
+
|
|
71
|
+
`sm-org-server` separates them. It implements **only the wire**, against a `ServerStore` interface you can back with anything. Conformance is then *mechanical*: point a conformance suite at a running instance and it passes or it doesn't — no trust-me-it's-compatible.
|
|
72
|
+
|
|
73
|
+
```
|
|
74
|
+
your product / policies / LLM ← you write this
|
|
75
|
+
┌────────────────────────────────────┐
|
|
76
|
+
│ sm-org-server │ ← the conformant wire (this repo)
|
|
77
|
+
│ register · rotate · trust · feedback│
|
|
78
|
+
│ surfaces · federation · well-known │
|
|
79
|
+
└──────────────┬─────────────────────┘
|
|
80
|
+
│ ServerStore (Protocol)
|
|
81
|
+
SQLite (default) · Postgres · … ← swap freely
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
## Configuration
|
|
85
|
+
|
|
86
|
+
Everything is environment-driven; nothing runtime-specific is baked into the source.
|
|
87
|
+
|
|
88
|
+
| Variable | Default | Meaning |
|
|
89
|
+
|---|---|---|
|
|
90
|
+
| `SERVER_ID` | `sm-org-server` | This server's identifier (the wire `chapter_id`) |
|
|
91
|
+
| `SERVER_PUBLIC_URL` | `https://server.local` | Public base URL (for discovery substrate) |
|
|
92
|
+
| `SERVER_ORIGINS` | `sovereign` | Comma-separated admitted origin vocabulary |
|
|
93
|
+
| `SERVER_NONROTATABLE_ORIGINS` | *(none)* | Origins whose keys are managed and may not self-rotate |
|
|
94
|
+
| `SERVER_SEED`, `SERVER_BADGE_PATH`, `SERVER_ARP_BADGE_PATH` | *(see source)* | ARP seed and badge file locations |
|
|
95
|
+
|
|
96
|
+
> **Naming:** these env vars were `CHAPTER_*` before the server rename; the legacy names are still read as aliases, so existing deployments keep working. The *wire* field stays `chapter_id` (frozen by the protocol).
|
|
97
|
+
|
|
98
|
+
The origin vocabulary is **policy, not protocol**: a deployment declares which provenances it admits. The default is the neutral `sovereign` (self-custodied identity); a managed deployment can add its own install-time origins via config without changing a line of source.
|
|
99
|
+
|
|
100
|
+
## Storage backends
|
|
101
|
+
|
|
102
|
+
The default `SqliteStore` is zero-config and file-backed. Any class satisfying the `ServerStore` Protocol (`sm_server/store/base.py`) is a drop-in — Postgres, Redis-backed, or an in-memory test double. Nothing above the interface knows what the backend is.
|
|
103
|
+
|
|
104
|
+
## Receipts (ARP)
|
|
105
|
+
|
|
106
|
+
A server doesn't just track *that* agents are trusted — it keeps a verifiable record of *what they did*. `sm-org-server` is **ARP-native**: it maintains an **Issuer Log** of [Agency Receipt Protocol](https://github.com/Sharathvc23/sm-arp) receipts — Ed25519-signed, JCS-canonical, hash-chained per issuer (ARP §6.4).
|
|
107
|
+
|
|
108
|
+

|
|
109
|
+
|
|
110
|
+
> The live `receipts` surface, rendered from a real `GET /api/surfaces/receipts` envelope: [docs/figures/receipts-surface.html](docs/figures/receipts-surface.html).
|
|
111
|
+
|
|
112
|
+
- **It ingests.** `POST /api/receipts` verifies a receipt (structure → signature → authority → hash chain) and persists it. A receipt is self-authenticating — its signature binds the issuer no matter who posts it — so the server trusts the envelope, not the transport. Verification fails → nothing is written.
|
|
113
|
+
- **It emits.** The server is a first-class issuer too: recording feedback also signs an `attestation_issued` receipt endorsing the member (`issuer=server → counterparty=member`) — the edge a reputation layer reads. Emission is the default, not an add-on.
|
|
114
|
+
- **It's swappable.** Receipts persist through the same `ServerStore` seam as members, so a Postgres-backed server keeps them in Postgres — not a side file.
|
|
115
|
+
|
|
116
|
+
The receipt envelope itself — build / sign / verify / canonical-bytes / chain — is the one canonical [`sm_arp`](https://github.com/Sharathvc23/sm-arp) library, shared with every other runtime, so the wire cannot drift. The live `GET /api/surfaces/receipts` A2UI surface renders the log.
|
|
117
|
+
|
|
118
|
+
```python
|
|
119
|
+
from sm_arp import Identity, build_action, issue_receipt
|
|
120
|
+
me = Identity.generate()
|
|
121
|
+
r = issue_receipt(me, principal_did=me.did,
|
|
122
|
+
action=build_action(category="data_shared", human_summary="shared my calendar"))
|
|
123
|
+
# POST r to /api/receipts → {"accepted": true, "chain_link": "sha256:…"}
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
## Conformance
|
|
127
|
+
|
|
128
|
+
`sm-org-server` ships **two** signed badges — Ed25519-signed records of which suites it passed, each pinned to that suite's vector digest:
|
|
129
|
+
|
|
130
|
+
- `.nanda/conformance.json` — the **wire** suite, served at **`GET /.well-known/conformance.json`** (`SERVER_BADGE_PATH` overrides).
|
|
131
|
+
- `.nanda/arp-conformance.json` — the **ARP receipt** suite (a distinct corpus → a distinct badge), served at **`GET /.well-known/arp-conformance.json`** (`SERVER_ARP_BADGE_PATH` overrides). Generated *mechanically* by `scripts/gen_arp_badge.py`, which drives the live ingest surface with the canonical receipt vectors and counts what it actually accepts/rejects.
|
|
132
|
+
|
|
133
|
+
Both are public, unauthenticated, offline-verifiable, and advertised via pointers in the well-known doc; absent → the endpoint 404s. See the [conformance toolkit](https://github.com/Sharathvc23/sm-conformance) for how badges are produced and verified.
|
|
134
|
+
|
|
135
|
+
## Development
|
|
136
|
+
|
|
137
|
+
```
|
|
138
|
+
pip install -e '.[dev]'
|
|
139
|
+
ruff check sm_server tests
|
|
140
|
+
mypy sm_server
|
|
141
|
+
pytest # 65 tests, ≥80% coverage gate
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
## License
|
|
145
|
+
|
|
146
|
+
MIT © stellarminds.ai. See [LICENSE](LICENSE).
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
sm_server/__init__.py,sha256=3ndARwRuZBBAAvlm7kLSUWVDknhg5gU5HQdLu4QO9eA,265
|
|
2
|
+
sm_server/app.py,sha256=yGnXpAgGJ-akNLIyEKxORRQd1A6T7xoK6Ra330QGOTQ,22793
|
|
3
|
+
sm_server/merkle.py,sha256=xRtHlaVByX9EB5U20dUWqUwv66qn-AxJoa_T6ou17iI,4456
|
|
4
|
+
sm_server/signing.py,sha256=D-soL08duwR7uWgs16Or8ymgZtuz_eBruocwkuS4zeU,2078
|
|
5
|
+
sm_server/trust.py,sha256=sQqwSQl0_VP0jNgfCgSbh0NxL7FMKG1DDDyJsgs3pRc,2194
|
|
6
|
+
sm_server/store/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
7
|
+
sm_server/store/base.py,sha256=WfhYnH8dhYmzZBYXjBFaJpAIwIRMFtyqv_Zox1oXlpY,3175
|
|
8
|
+
sm_server/store/sqlite.py,sha256=2yH67n122qTikiU9MlpGnCTLXYvwhWSqylMREIiqArk,7185
|
|
9
|
+
sm_org_server-0.1.0.dist-info/METADATA,sha256=C2yEPCbV1rfZ3NCeM5JfZ5A8-VIU8k5Cv6uAj9yBxyQ,9144
|
|
10
|
+
sm_org_server-0.1.0.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
|
|
11
|
+
sm_org_server-0.1.0.dist-info/licenses/LICENSE,sha256=mo4bEHboox6Cvk1DuRHhTstvfo1NRFPs5QqRfvrufBc,1072
|
|
12
|
+
sm_org_server-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 stellarminds.ai
|
|
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.
|
sm_server/__init__.py
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
"""sm-org-server — the minimal, backend-agnostic, conformance-passing server.
|
|
2
|
+
|
|
3
|
+
The protocol surface a server MUST implement, against a swappable ServerStore.
|
|
4
|
+
The agent intelligence and governance live above this, as product or plugins.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
__version__ = "0.1.0"
|
sm_server/app.py
ADDED
|
@@ -0,0 +1,512 @@
|
|
|
1
|
+
"""The conformant protocol surface — minimal, backend-agnostic.
|
|
2
|
+
|
|
3
|
+
Implements exactly what `conformance/server/` requires of a server, against the
|
|
4
|
+
`ServerStore` interface. No LLM, no think-cycle, no specific database — the
|
|
5
|
+
agent's intelligence and governance live above this, as product or plugins.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import base64
|
|
11
|
+
import json
|
|
12
|
+
import os
|
|
13
|
+
import time
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
from fastapi import FastAPI, HTTPException, Request
|
|
17
|
+
from fastapi.responses import JSONResponse
|
|
18
|
+
from sm_arp import Identity as ArpIdentity
|
|
19
|
+
from sm_arp import build_action, issue_receipt, verify_receipt
|
|
20
|
+
|
|
21
|
+
from sm_server import merkle, signing, trust
|
|
22
|
+
from sm_server.store.base import Member, ServerStore
|
|
23
|
+
from sm_server.store.sqlite import SqliteStore
|
|
24
|
+
|
|
25
|
+
ROTATION_WINDOW_S = 300
|
|
26
|
+
SIGNED_REQUEST_WINDOW_S = 300
|
|
27
|
+
|
|
28
|
+
# Origin vocabulary is policy, not protocol: the core ships neutral and a deployment
|
|
29
|
+
# declares which origins it admits (and which may not self-rotate keys, e.g. managed
|
|
30
|
+
# install-time identities) via config. No runtime-specific name is baked into source.
|
|
31
|
+
DEFAULT_ORIGINS = ("sovereign",)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _env(*names: str, default: str | None = None) -> str | None:
|
|
35
|
+
"""First set value among ``names`` (new name first, legacy aliases after).
|
|
36
|
+
|
|
37
|
+
Env vars were renamed ``CHAPTER_*`` → ``SERVER_*`` with the brand; the legacy
|
|
38
|
+
names are still read so already-deployed operators don't break.
|
|
39
|
+
"""
|
|
40
|
+
for name in names:
|
|
41
|
+
value = os.environ.get(name)
|
|
42
|
+
if value is not None:
|
|
43
|
+
return value
|
|
44
|
+
return default
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _origins_from_env(var: str, legacy: str, default: tuple[str, ...]) -> set[str]:
|
|
48
|
+
raw = _env(var, legacy, default="") or ""
|
|
49
|
+
declared = {o.strip() for o in raw.split(",") if o.strip()}
|
|
50
|
+
return declared or set(default)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _surface(page_id: str, *components: dict[str, object]) -> dict[str, object]:
|
|
54
|
+
"""A v0.10 A2UI surface envelope — the wire shape every renderer agrees on."""
|
|
55
|
+
return {
|
|
56
|
+
"createSurface": {"surfaceId": page_id},
|
|
57
|
+
"updateComponents": {
|
|
58
|
+
"surfaceId": page_id,
|
|
59
|
+
"root": components[0]["id"] if components else "root",
|
|
60
|
+
"components": list(components),
|
|
61
|
+
},
|
|
62
|
+
"version": "0.10",
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _receipts_surface(receipts: list[dict[str, object]]) -> dict[str, object]:
|
|
67
|
+
"""A live A2UI view of the Issuer Log — the server's accepted receipts."""
|
|
68
|
+
comps: list[dict[str, object]] = [
|
|
69
|
+
{"id": "h", "component": "Heading", "text": "Issuer Log", "level": 1},
|
|
70
|
+
]
|
|
71
|
+
if not receipts:
|
|
72
|
+
comps.append({"id": "empty", "component": "Text", "text": "No receipts yet."})
|
|
73
|
+
for idx, r in enumerate(receipts):
|
|
74
|
+
action = r.get("action")
|
|
75
|
+
action = action if isinstance(action, dict) else {}
|
|
76
|
+
line = f"{r.get('issued_at', '')} · {action.get('category', '')} · {action.get('human_summary', '')}"
|
|
77
|
+
comps.append({"id": f"r{idx}", "component": "Text", "text": line})
|
|
78
|
+
return _surface("receipts", *comps)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
AUTH_GATED_SURFACES = {"today"}
|
|
82
|
+
DYNAMIC_SURFACES = {"receipts"}
|
|
83
|
+
|
|
84
|
+
PUBLIC_SURFACES = {
|
|
85
|
+
"docs": _surface(
|
|
86
|
+
"docs",
|
|
87
|
+
{"id": "h", "component": "Heading", "text": "Server documentation", "level": 1},
|
|
88
|
+
{"id": "body", "component": "Markdown", "text": "## Joining\n\nRegister, then sign your requests."},
|
|
89
|
+
),
|
|
90
|
+
"chronicle": _surface(
|
|
91
|
+
"chronicle",
|
|
92
|
+
{"id": "h", "component": "Heading", "text": "Server chronicle", "level": 1},
|
|
93
|
+
{"id": "body", "component": "Text", "text": "A running record of server activity."},
|
|
94
|
+
),
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _ordered_issuer_log(store: ServerStore) -> list[dict[str, object]]:
|
|
99
|
+
"""The whole Issuer Log in one canonical, total order.
|
|
100
|
+
|
|
101
|
+
Shared by the checkpoint and its proofs so leaf indices line up. Fetched
|
|
102
|
+
*uncapped* (``receipt_count()`` as the limit) so the checkpoint commits to
|
|
103
|
+
the entire log, never a silent prefix; ordered by ``(issued_at, receipt_id)``
|
|
104
|
+
— total and stable, independent of the store's own row order.
|
|
105
|
+
"""
|
|
106
|
+
receipts = store.list_receipts(store.receipt_count())
|
|
107
|
+
return sorted(receipts, key=lambda r: (str(r.get("issued_at", "")), str(r.get("receipt_id", ""))))
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def create_app(
|
|
111
|
+
store: ServerStore | None = None,
|
|
112
|
+
chapter_id: str | None = None,
|
|
113
|
+
origins: set[str] | None = None,
|
|
114
|
+
nonrotatable_origins: set[str] | None = None,
|
|
115
|
+
) -> FastAPI:
|
|
116
|
+
store = store or SqliteStore()
|
|
117
|
+
# `chapter_id` is the frozen wire field (the server's identifier on the wire); the
|
|
118
|
+
# env var that sets it is the new `SERVER_ID` (legacy `CHAPTER_ID` still honored).
|
|
119
|
+
chapter_id = chapter_id or _env("SERVER_ID", "CHAPTER_ID", default="sm-org-server") or "sm-org-server"
|
|
120
|
+
_public_url = _env("SERVER_PUBLIC_URL", "CHAPTER_PUBLIC_URL", default="https://server.local") or ""
|
|
121
|
+
base_url = _public_url.rstrip("/")
|
|
122
|
+
valid_origins = (
|
|
123
|
+
origins
|
|
124
|
+
if origins is not None
|
|
125
|
+
else _origins_from_env("SERVER_ORIGINS", "CHAPTER_ORIGINS", DEFAULT_ORIGINS)
|
|
126
|
+
)
|
|
127
|
+
nonrotatable = (
|
|
128
|
+
nonrotatable_origins
|
|
129
|
+
if nonrotatable_origins is not None
|
|
130
|
+
else _origins_from_env("SERVER_NONROTATABLE_ORIGINS", "CHAPTER_NONROTATABLE_ORIGINS", ())
|
|
131
|
+
)
|
|
132
|
+
# The server's own ARP signing identity — it issues receipts for server
|
|
133
|
+
# actions, not just stores members'. Stable across restarts when SERVER_SEED
|
|
134
|
+
# (base64 of a 32-byte Ed25519 seed) is set; ephemeral otherwise.
|
|
135
|
+
_seed = _env("SERVER_SEED", "CHAPTER_SEED")
|
|
136
|
+
server_identity = ArpIdentity.from_seed(base64.b64decode(_seed)) if _seed else ArpIdentity.generate()
|
|
137
|
+
server_did = server_identity.did
|
|
138
|
+
|
|
139
|
+
# Signed conformance badges, served publicly (the canonical URLs across all
|
|
140
|
+
# servers). Read once; absent → endpoint 404s and the well-known doc omits the
|
|
141
|
+
# pointer. The wire badge attests the server protocol suite; the ARP badge
|
|
142
|
+
# attests the receipt suite (a distinct corpus, hence a distinct file).
|
|
143
|
+
def _load_badge(env_var: str, legacy_var: str, default: str) -> dict[str, object] | None:
|
|
144
|
+
path = Path(_env(env_var, legacy_var, default=default) or default)
|
|
145
|
+
try:
|
|
146
|
+
return json.loads(path.read_text()) if path.exists() else None
|
|
147
|
+
except (OSError, ValueError):
|
|
148
|
+
return None
|
|
149
|
+
|
|
150
|
+
conformance_badge_doc = _load_badge("SERVER_BADGE_PATH", "CHAPTER_BADGE_PATH", ".nanda/conformance.json")
|
|
151
|
+
arp_badge_doc = _load_badge(
|
|
152
|
+
"SERVER_ARP_BADGE_PATH", "CHAPTER_ARP_BADGE_PATH", ".nanda/arp-conformance.json"
|
|
153
|
+
)
|
|
154
|
+
app = FastAPI(title="sm-org-server")
|
|
155
|
+
|
|
156
|
+
def emit_server_receipt(
|
|
157
|
+
principal_did: str,
|
|
158
|
+
*,
|
|
159
|
+
category: str,
|
|
160
|
+
human_summary: str,
|
|
161
|
+
counterparty_did: str | None = None,
|
|
162
|
+
counterparty_label: str | None = None,
|
|
163
|
+
) -> dict[str, object]:
|
|
164
|
+
"""Sign and persist a receipt for a server action, into the Issuer Log.
|
|
165
|
+
|
|
166
|
+
The server is a first-class ARP issuer: actions it takes (attesting an
|
|
167
|
+
interaction, endorsing a member) produce signed receipts the same way a
|
|
168
|
+
member's do. The edge (issuer=server → counterparty) is what the
|
|
169
|
+
reputation layer reads, so this is how a server's endorsement counts.
|
|
170
|
+
"""
|
|
171
|
+
action = build_action(
|
|
172
|
+
category=category,
|
|
173
|
+
human_summary=human_summary,
|
|
174
|
+
counterparty_did=counterparty_did,
|
|
175
|
+
counterparty_label=counterparty_label,
|
|
176
|
+
)
|
|
177
|
+
receipt: dict[str, object] = issue_receipt(
|
|
178
|
+
server_identity, principal_did=principal_did, action=action
|
|
179
|
+
)
|
|
180
|
+
store.append_receipt(receipt)
|
|
181
|
+
return receipt
|
|
182
|
+
|
|
183
|
+
@app.get("/health")
|
|
184
|
+
def health() -> dict[str, object]:
|
|
185
|
+
return {"status": "ok", "agent_id": chapter_id, "members": store.member_count(), "federation": 0}
|
|
186
|
+
|
|
187
|
+
@app.get("/api/version")
|
|
188
|
+
def version() -> dict[str, object]:
|
|
189
|
+
return {
|
|
190
|
+
"chapter_id": chapter_id,
|
|
191
|
+
"protocol_versions": ["0.2", "0.3"],
|
|
192
|
+
"preferred_version": "0.3",
|
|
193
|
+
"a2ui_versions": ["0.9"],
|
|
194
|
+
"preferred_a2ui_version": "0.9",
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
@app.post("/api/members")
|
|
198
|
+
async def register(req: Request) -> JSONResponse:
|
|
199
|
+
body = await req.json()
|
|
200
|
+
agent_id, name = body.get("agent_id"), body.get("name")
|
|
201
|
+
if not agent_id or not name:
|
|
202
|
+
return JSONResponse({"error": "agent_id and name required"}, status_code=400)
|
|
203
|
+
origin = body.get("origin")
|
|
204
|
+
if origin not in valid_origins:
|
|
205
|
+
return JSONResponse(
|
|
206
|
+
{"error": f"invalid origin: must be one of {sorted(valid_origins)}"},
|
|
207
|
+
status_code=200,
|
|
208
|
+
)
|
|
209
|
+
existing = store.get_member(agent_id)
|
|
210
|
+
if existing is not None and existing.origin != origin:
|
|
211
|
+
return JSONResponse(
|
|
212
|
+
{"error": "origin mismatch", "existing_origin": existing.origin}, status_code=200
|
|
213
|
+
)
|
|
214
|
+
public_key = body.get("public_key", "")
|
|
215
|
+
try:
|
|
216
|
+
did_key = signing.derive_did_key(base64.b64decode(public_key)) if public_key else ""
|
|
217
|
+
except Exception:
|
|
218
|
+
did_key = ""
|
|
219
|
+
store.put_member(Member(agent_id, name, origin, public_key, did_key))
|
|
220
|
+
return JSONResponse(
|
|
221
|
+
{"agent_id": agent_id, "registered": True, "chapter": chapter_id, "origin": origin}
|
|
222
|
+
)
|
|
223
|
+
|
|
224
|
+
@app.post("/api/members/rotate")
|
|
225
|
+
async def rotate(req: Request) -> JSONResponse:
|
|
226
|
+
a = await req.json()
|
|
227
|
+
agent_id = a.get("agent_id")
|
|
228
|
+
member = store.get_member(agent_id)
|
|
229
|
+
if member is None:
|
|
230
|
+
return JSONResponse({"error": "unknown agent — cannot rotate an unregistered key"})
|
|
231
|
+
# §4.2 ordered checks; rejections return {"error": ...} with HTTP 200.
|
|
232
|
+
if member.origin in nonrotatable:
|
|
233
|
+
return JSONResponse(
|
|
234
|
+
{"error": f"origin '{member.origin}' may not self-rotate keys (managed identity)"}
|
|
235
|
+
)
|
|
236
|
+
if a.get("chapter_id") != chapter_id:
|
|
237
|
+
return JSONResponse({"error": f"chapter_id mismatch (attestation is for {a.get('chapter_id')})"})
|
|
238
|
+
ts = a.get("timestamp")
|
|
239
|
+
try:
|
|
240
|
+
if abs(int(time.time()) - int(ts)) > ROTATION_WINDOW_S:
|
|
241
|
+
return JSONResponse({"error": f"timestamp out of window (max {ROTATION_WINDOW_S}s)"})
|
|
242
|
+
except (TypeError, ValueError):
|
|
243
|
+
return JSONResponse({"error": f"timestamp out of window (max {ROTATION_WINDOW_S}s)"})
|
|
244
|
+
new_pk_b64 = a.get("new_public_key_b64", "")
|
|
245
|
+
if new_pk_b64 == member.public_key:
|
|
246
|
+
return JSONResponse({"error": "new public key must differ from old"})
|
|
247
|
+
try:
|
|
248
|
+
new_raw = base64.b64decode(new_pk_b64)
|
|
249
|
+
except Exception:
|
|
250
|
+
new_raw = b""
|
|
251
|
+
if len(new_raw) != 32:
|
|
252
|
+
return JSONResponse({"error": f"new pubkey wrong length: {len(new_raw)} (Ed25519 needs 32)"})
|
|
253
|
+
nonce = a.get("nonce", "")
|
|
254
|
+
canonical = f"ROTATE:{a.get('chapter_id')}:{agent_id}:{new_pk_b64}:{ts}:{nonce}"
|
|
255
|
+
try:
|
|
256
|
+
ok = signing.verify(
|
|
257
|
+
base64.b64decode(member.public_key),
|
|
258
|
+
base64.b64decode(a.get("signature", "")),
|
|
259
|
+
canonical.encode(),
|
|
260
|
+
)
|
|
261
|
+
except Exception:
|
|
262
|
+
ok = False
|
|
263
|
+
if not ok:
|
|
264
|
+
return JSONResponse({"error": "invalid signature (old key did not sign this attestation)"})
|
|
265
|
+
if not store.consume_nonce(chapter_id, nonce):
|
|
266
|
+
return JSONResponse({"error": "nonce replay"})
|
|
267
|
+
new_did = a.get("new_did_key") or signing.derive_did_key(new_raw)
|
|
268
|
+
store.rotate_key(agent_id, new_pk_b64, new_did)
|
|
269
|
+
return JSONResponse({"agent_id": agent_id, "rotated": True, "new_public_key_b64": new_pk_b64})
|
|
270
|
+
|
|
271
|
+
@app.get("/api/members")
|
|
272
|
+
def list_members(limit: int = 50) -> dict[str, object]:
|
|
273
|
+
members = store.list_members(limit)
|
|
274
|
+
return {
|
|
275
|
+
"members": [
|
|
276
|
+
{"agent_id": m.agent_id, "name": m.name, "origin": m.origin, "did_key": m.did_key}
|
|
277
|
+
for m in members
|
|
278
|
+
],
|
|
279
|
+
"total": store.member_count(),
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
@app.post("/api/feedback")
|
|
283
|
+
async def feedback(req: Request) -> JSONResponse:
|
|
284
|
+
# Signed-request verification (spec/0.2 §3.1). The body is signed verbatim,
|
|
285
|
+
# so we read the raw bytes — re-serialising would change the canonical string.
|
|
286
|
+
raw = await req.body()
|
|
287
|
+
h = req.headers
|
|
288
|
+
agent_id = h.get("x-agent-id")
|
|
289
|
+
did_key_hdr = h.get("x-agent-did-key")
|
|
290
|
+
ts = h.get("x-agent-timestamp")
|
|
291
|
+
sig_b64 = h.get("x-agent-signature")
|
|
292
|
+
if not (agent_id and did_key_hdr and ts and sig_b64):
|
|
293
|
+
raise HTTPException(status_code=401, detail="missing v0.2 signed-request headers")
|
|
294
|
+
try:
|
|
295
|
+
if abs(int(time.time()) - int(ts)) > SIGNED_REQUEST_WINDOW_S:
|
|
296
|
+
raise HTTPException(status_code=401, detail="timestamp out of window")
|
|
297
|
+
except (TypeError, ValueError):
|
|
298
|
+
raise HTTPException(status_code=401, detail="malformed timestamp") from None
|
|
299
|
+
try:
|
|
300
|
+
claimed_pub = signing.parse_did_key(did_key_hdr)
|
|
301
|
+
sig = base64.b64decode(sig_b64)
|
|
302
|
+
except Exception:
|
|
303
|
+
raise HTTPException(status_code=401, detail="malformed did-key or signature") from None
|
|
304
|
+
canonical = f"{raw.decode('utf-8', 'strict')}:{agent_id}:{ts}".encode()
|
|
305
|
+
|
|
306
|
+
member = store.get_member(agent_id)
|
|
307
|
+
if member is not None:
|
|
308
|
+
# Registered: the presented key MUST be the one on file (TOFU idempotence).
|
|
309
|
+
if claimed_pub != base64.b64decode(member.public_key):
|
|
310
|
+
raise HTTPException(status_code=401, detail="key_mismatch")
|
|
311
|
+
if not signing.verify(claimed_pub, sig, canonical):
|
|
312
|
+
raise HTTPException(status_code=401, detail="invalid signature")
|
|
313
|
+
else:
|
|
314
|
+
# Trust on first use: record the presented key as authoritative.
|
|
315
|
+
if not signing.verify(claimed_pub, sig, canonical):
|
|
316
|
+
raise HTTPException(status_code=401, detail="invalid signature")
|
|
317
|
+
pub_b64 = base64.b64encode(claimed_pub).decode()
|
|
318
|
+
store.put_member(Member(agent_id, agent_id, "sovereign", pub_b64, did_key_hdr))
|
|
319
|
+
|
|
320
|
+
useful = trust.EVENT_DELTAS["intent_response_useful"]
|
|
321
|
+
store.record_trust_event(agent_id, "intent_response_useful", useful)
|
|
322
|
+
# The server attests the interaction with a signed receipt — its
|
|
323
|
+
# endorsement of the member (issuer=server → counterparty=member),
|
|
324
|
+
# which is what the reputation layer reads. Emission is default, not opt-in.
|
|
325
|
+
receipt = emit_server_receipt(
|
|
326
|
+
server_did,
|
|
327
|
+
category="attestation_issued",
|
|
328
|
+
human_summary=f"Attested a useful intent response from member {agent_id}"[:280],
|
|
329
|
+
counterparty_did=did_key_hdr,
|
|
330
|
+
counterparty_label=agent_id,
|
|
331
|
+
)
|
|
332
|
+
return JSONResponse({"recorded": True, "agent_id": agent_id, "receipt_id": receipt["receipt_id"]})
|
|
333
|
+
|
|
334
|
+
@app.get("/api/agents/{agent_id}/trust")
|
|
335
|
+
def trust_dossier(agent_id: str) -> JSONResponse:
|
|
336
|
+
if store.get_member(agent_id) is None:
|
|
337
|
+
raise HTTPException(status_code=404, detail="unknown agent")
|
|
338
|
+
score = store.trust_score(agent_id)
|
|
339
|
+
row = trust.tier_for(score)
|
|
340
|
+
return JSONResponse(
|
|
341
|
+
{
|
|
342
|
+
"agent_id": agent_id,
|
|
343
|
+
"score": score,
|
|
344
|
+
"tier": {
|
|
345
|
+
"name": row["tier"],
|
|
346
|
+
"min_trust": row["score_min"],
|
|
347
|
+
"can_federate": row["federate"],
|
|
348
|
+
"open_calls_max": row["open_calls_max"],
|
|
349
|
+
"ttl_days": row["ttl_days"],
|
|
350
|
+
},
|
|
351
|
+
"next_tier_min": trust.next_tier_min(score),
|
|
352
|
+
"history": store.trust_history(agent_id),
|
|
353
|
+
}
|
|
354
|
+
)
|
|
355
|
+
|
|
356
|
+
@app.get("/api/admin/trust/drift")
|
|
357
|
+
def trust_drift() -> dict[str, object]:
|
|
358
|
+
# Score is the authoritative SUM of deltas, so stored == recomputed by construction.
|
|
359
|
+
return {"drift_count": 0, "drifts": []}
|
|
360
|
+
|
|
361
|
+
@app.post("/api/receipts")
|
|
362
|
+
async def ingest_receipt(req: Request) -> JSONResponse:
|
|
363
|
+
"""Verify and persist one ARP receipt to the Issuer Log.
|
|
364
|
+
|
|
365
|
+
The receipt is self-authenticating — its Ed25519 signature over the
|
|
366
|
+
canonical bytes binds it to ``issuer_did`` no matter who POSTs it — so
|
|
367
|
+
the server trusts the envelope, not the transport. `sm_arp` owns the
|
|
368
|
+
verification; on failure nothing is written.
|
|
369
|
+
"""
|
|
370
|
+
try:
|
|
371
|
+
receipt = await req.json()
|
|
372
|
+
except Exception:
|
|
373
|
+
return JSONResponse({"error": "body is not valid JSON"}, status_code=400)
|
|
374
|
+
if not isinstance(receipt, dict):
|
|
375
|
+
return JSONResponse({"error": "receipt must be a JSON object"}, status_code=400)
|
|
376
|
+
|
|
377
|
+
# Resolve the per-issuer predecessor for the hash chain (§6.4), if declared.
|
|
378
|
+
prev = receipt.get("previous_receipt_hash")
|
|
379
|
+
prior = None
|
|
380
|
+
if prev:
|
|
381
|
+
issuer = receipt.get("issuer_did")
|
|
382
|
+
if isinstance(issuer, str) and isinstance(prev, str):
|
|
383
|
+
prior = store.get_receipt_by_chain_link(issuer, prev)
|
|
384
|
+
|
|
385
|
+
result = verify_receipt(receipt, mode="strict", prior=prior, check_chain=bool(prev))
|
|
386
|
+
if not result.ok:
|
|
387
|
+
# A receipt that fails verification is a bad payload, not a missing-auth
|
|
388
|
+
# request — mirror /api/members/rotate: HTTP 200 with an {"error": ...} body.
|
|
389
|
+
return JSONResponse({"error": f"{result.stage}: {result.detail}"})
|
|
390
|
+
|
|
391
|
+
link = store.append_receipt(receipt)
|
|
392
|
+
return JSONResponse({"accepted": True, "receipt_id": receipt["receipt_id"], "chain_link": link})
|
|
393
|
+
|
|
394
|
+
@app.get("/api/receipts/recent")
|
|
395
|
+
def recent_receipts(limit: int = 50, principal: str | None = None) -> dict[str, object]:
|
|
396
|
+
receipts = (
|
|
397
|
+
store.list_receipts_for_principal(principal, limit) if principal else store.list_receipts(limit)
|
|
398
|
+
)
|
|
399
|
+
return {"receipts": receipts, "total": store.receipt_count()}
|
|
400
|
+
|
|
401
|
+
@app.get("/api/receipts/{receipt_id}")
|
|
402
|
+
def get_receipt(receipt_id: str) -> JSONResponse:
|
|
403
|
+
receipt = store.get_receipt(receipt_id)
|
|
404
|
+
if receipt is None:
|
|
405
|
+
raise HTTPException(status_code=404, detail="unknown receipt")
|
|
406
|
+
return JSONResponse(receipt)
|
|
407
|
+
|
|
408
|
+
@app.get("/api/checkpoint")
|
|
409
|
+
def checkpoint() -> JSONResponse:
|
|
410
|
+
"""A signed RFC 6962 Merkle checkpoint over the whole Issuer Log.
|
|
411
|
+
|
|
412
|
+
The receipt hash chain proves *forward* tamper-evidence within an issuer;
|
|
413
|
+
this proves *membership* across the whole log: anyone holding a receipt,
|
|
414
|
+
its inclusion proof, and this signed root can verify it offline.
|
|
415
|
+
"""
|
|
416
|
+
receipts = _ordered_issuer_log(store)
|
|
417
|
+
return JSONResponse(merkle.build_checkpoint(receipts, identity=server_identity))
|
|
418
|
+
|
|
419
|
+
@app.get("/api/checkpoint/proof/{receipt_id}")
|
|
420
|
+
def checkpoint_proof(receipt_id: str) -> JSONResponse:
|
|
421
|
+
"""RFC 6962 inclusion proof for one receipt against the current root.
|
|
422
|
+
|
|
423
|
+
Taken over the same ordering as ``/api/checkpoint`` so ``leaf_index``
|
|
424
|
+
lines up with the signed root.
|
|
425
|
+
"""
|
|
426
|
+
receipts = _ordered_issuer_log(store)
|
|
427
|
+
index = next((i for i, r in enumerate(receipts) if r.get("receipt_id") == receipt_id), None)
|
|
428
|
+
if index is None:
|
|
429
|
+
raise HTTPException(status_code=404, detail="unknown receipt")
|
|
430
|
+
leaves = merkle.checkpoint_leaves(receipts)
|
|
431
|
+
path = merkle.inclusion_proof(leaves, index)
|
|
432
|
+
root = merkle.merkle_root(leaves)
|
|
433
|
+
return JSONResponse(
|
|
434
|
+
{
|
|
435
|
+
"receipt_id": receipt_id,
|
|
436
|
+
"leaf_index": index,
|
|
437
|
+
"tree_size": len(receipts),
|
|
438
|
+
"merkle_root": "sha256:" + root.hex(),
|
|
439
|
+
"audit_path": ["sha256:" + h.hex() for h in path],
|
|
440
|
+
}
|
|
441
|
+
)
|
|
442
|
+
|
|
443
|
+
@app.get("/api/surfaces/{page_id}")
|
|
444
|
+
def surface(page_id: str) -> JSONResponse:
|
|
445
|
+
if page_id in AUTH_GATED_SURFACES:
|
|
446
|
+
# Per-principal surface (e.g. /today): no signed identity, no projection.
|
|
447
|
+
raise HTTPException(status_code=403, detail="surface requires a signed request")
|
|
448
|
+
if page_id in DYNAMIC_SURFACES:
|
|
449
|
+
return JSONResponse(_receipts_surface(store.list_receipts(20)))
|
|
450
|
+
envelope = PUBLIC_SURFACES.get(page_id)
|
|
451
|
+
if envelope is None:
|
|
452
|
+
raise HTTPException(status_code=404, detail="unknown surface")
|
|
453
|
+
return JSONResponse(envelope)
|
|
454
|
+
|
|
455
|
+
@app.get("/.well-known/nanda-agent.json")
|
|
456
|
+
def well_known() -> dict[str, object]:
|
|
457
|
+
doc: dict[str, object] = {
|
|
458
|
+
"agent_id": chapter_id,
|
|
459
|
+
"did": server_did,
|
|
460
|
+
"facts_url": f"{base_url}/.well-known/agent-facts.json",
|
|
461
|
+
"a2a_url": f"{base_url}/api",
|
|
462
|
+
"checkpoint": f"{base_url}/api/checkpoint",
|
|
463
|
+
"registries": {},
|
|
464
|
+
"protocol_versions": ["0.2", "0.3"],
|
|
465
|
+
}
|
|
466
|
+
if conformance_badge_doc is not None:
|
|
467
|
+
doc["conformance"] = f"{base_url}/.well-known/conformance.json"
|
|
468
|
+
if arp_badge_doc is not None:
|
|
469
|
+
doc["arp_conformance"] = f"{base_url}/.well-known/arp-conformance.json"
|
|
470
|
+
return doc
|
|
471
|
+
|
|
472
|
+
@app.get("/.well-known/conformance.json")
|
|
473
|
+
def conformance() -> JSONResponse:
|
|
474
|
+
"""The server's signed conformance badge — public, no auth, offline-verifiable."""
|
|
475
|
+
if conformance_badge_doc is None:
|
|
476
|
+
raise HTTPException(status_code=404, detail="no conformance badge published")
|
|
477
|
+
return JSONResponse(conformance_badge_doc)
|
|
478
|
+
|
|
479
|
+
@app.get("/.well-known/arp-conformance.json")
|
|
480
|
+
def arp_conformance() -> JSONResponse:
|
|
481
|
+
"""The server's signed ARP receipt-suite badge — public, offline-verifiable."""
|
|
482
|
+
if arp_badge_doc is None:
|
|
483
|
+
raise HTTPException(status_code=404, detail="no ARP conformance badge published")
|
|
484
|
+
return JSONResponse(arp_badge_doc)
|
|
485
|
+
|
|
486
|
+
@app.get("/api/federation")
|
|
487
|
+
def federation() -> dict[str, object]:
|
|
488
|
+
# v0.1 is a solo, federation-ready server: no peers wired yet, shape is final.
|
|
489
|
+
servers: dict[str, object] = {}
|
|
490
|
+
return {
|
|
491
|
+
"self": {"agent_id": chapter_id, "did": server_did},
|
|
492
|
+
"chapters": servers,
|
|
493
|
+
"total": len(servers),
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
@app.get("/api/federation/{peer_id}/members")
|
|
497
|
+
def federation_members(peer_id: str) -> JSONResponse:
|
|
498
|
+
if peer_id == chapter_id:
|
|
499
|
+
members = store.list_members(1000)
|
|
500
|
+
return JSONResponse(
|
|
501
|
+
{
|
|
502
|
+
"chapter": peer_id,
|
|
503
|
+
"members": [{"agent_id": m.agent_id, "did_key": m.did_key} for m in members],
|
|
504
|
+
"total": len(members),
|
|
505
|
+
}
|
|
506
|
+
)
|
|
507
|
+
raise HTTPException(status_code=404, detail=f"unknown peer: {peer_id}")
|
|
508
|
+
|
|
509
|
+
return app
|
|
510
|
+
|
|
511
|
+
|
|
512
|
+
app = create_app()
|
sm_server/merkle.py
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
"""RFC 6962 Merkle tree + a signed checkpoint over the ARP Issuer Log.
|
|
2
|
+
|
|
3
|
+
The per-issuer hash chain (ARP §6.4) gives *forward* tamper-evidence inside one
|
|
4
|
+
issuer's receipts. This adds the *reverse* seam across the whole log: the server
|
|
5
|
+
signs a checkpoint anchoring an RFC 6962 Merkle tree over every receipt it holds,
|
|
6
|
+
and any holder of a receipt + its inclusion proof + the signed root can verify
|
|
7
|
+
membership offline — without the rest of the log, and without trusting the
|
|
8
|
+
server's say-so.
|
|
9
|
+
|
|
10
|
+
Pure functions over raw leaf bytes; domain-separated leaf/node hashes per
|
|
11
|
+
RFC 6962 §2.1. The leaf is the JCS-canonical bytes of the full signed receipt,
|
|
12
|
+
so the checkpoint commits to the exact receipt a verifier reconstructs.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import base64
|
|
18
|
+
import hashlib
|
|
19
|
+
from typing import Any
|
|
20
|
+
|
|
21
|
+
from sm_arp import Identity, canonical_bytes, now_iso
|
|
22
|
+
|
|
23
|
+
CHECKPOINT_VERSION = "aae-checkpoint/0.1"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
# ── RFC 6962 §2.1: domain-separated hashes ──────────────────────────────
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _leaf_hash(data: bytes) -> bytes:
|
|
30
|
+
return hashlib.sha256(b"\x00" + data).digest()
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _node_hash(left: bytes, right: bytes) -> bytes:
|
|
34
|
+
return hashlib.sha256(b"\x01" + left + right).digest()
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _largest_pow2_lt(n: int) -> int:
|
|
38
|
+
"""Largest power of two strictly less than n (n > 1)."""
|
|
39
|
+
k = 1
|
|
40
|
+
while k * 2 < n:
|
|
41
|
+
k *= 2
|
|
42
|
+
return k
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def merkle_root(leaves: list[bytes]) -> bytes:
|
|
46
|
+
"""RFC 6962 Merkle Tree Hash over raw leaf data."""
|
|
47
|
+
n = len(leaves)
|
|
48
|
+
if n == 0:
|
|
49
|
+
return hashlib.sha256(b"").digest()
|
|
50
|
+
if n == 1:
|
|
51
|
+
return _leaf_hash(leaves[0])
|
|
52
|
+
k = _largest_pow2_lt(n)
|
|
53
|
+
return _node_hash(merkle_root(leaves[:k]), merkle_root(leaves[k:]))
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def inclusion_proof(leaves: list[bytes], m: int) -> list[bytes]:
|
|
57
|
+
"""RFC 6962 audit path for leaf index ``m`` in the tree over ``leaves``."""
|
|
58
|
+
n = len(leaves)
|
|
59
|
+
if not 0 <= m < n:
|
|
60
|
+
raise IndexError(f"leaf index {m} out of range for size {n}")
|
|
61
|
+
if n == 1:
|
|
62
|
+
return []
|
|
63
|
+
k = _largest_pow2_lt(n)
|
|
64
|
+
if m < k:
|
|
65
|
+
return inclusion_proof(leaves[:k], m) + [merkle_root(leaves[k:])]
|
|
66
|
+
return inclusion_proof(leaves[k:], m - k) + [merkle_root(leaves[:k])]
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def verify_inclusion(
|
|
70
|
+
*, leaf: bytes, leaf_index: int, tree_size: int, proof: list[bytes], root: bytes
|
|
71
|
+
) -> bool:
|
|
72
|
+
"""RFC 6962 inclusion-proof verification (the Trillian reference algorithm)."""
|
|
73
|
+
if leaf_index >= tree_size:
|
|
74
|
+
return False
|
|
75
|
+
fn, sn = leaf_index, tree_size - 1
|
|
76
|
+
r = _leaf_hash(leaf)
|
|
77
|
+
for p in proof:
|
|
78
|
+
if fn == sn or (fn & 1):
|
|
79
|
+
r = _node_hash(p, r)
|
|
80
|
+
while fn != 0 and (fn & 1) == 0:
|
|
81
|
+
fn >>= 1
|
|
82
|
+
sn >>= 1
|
|
83
|
+
else:
|
|
84
|
+
r = _node_hash(r, p)
|
|
85
|
+
fn >>= 1
|
|
86
|
+
sn >>= 1
|
|
87
|
+
return sn == 0 and r == root
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
# ── Checkpoint envelope (aae-checkpoint/0.1) ────────────────────────────
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def checkpoint_leaves(receipts: list[dict[str, Any]]) -> list[bytes]:
|
|
94
|
+
"""Canonical leaf bytes — JCS over the FULL receipt (including signature).
|
|
95
|
+
|
|
96
|
+
Receipts MUST be passed in the committed order; the checkpoint root and
|
|
97
|
+
every inclusion proof are taken over this same ordered leaf list.
|
|
98
|
+
"""
|
|
99
|
+
return [canonical_bytes(r, include_signature=True) for r in receipts]
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def build_checkpoint(receipts: list[dict[str, Any]], *, identity: Identity) -> dict[str, Any]:
|
|
103
|
+
"""Sign an RFC 6962 Merkle checkpoint committing to ``receipts`` in order.
|
|
104
|
+
|
|
105
|
+
The payload is signed over its JCS-canonical bytes by ``identity`` (the
|
|
106
|
+
server's own ARP key). ``canonical_bytes(payload, include_signature=True)``
|
|
107
|
+
is exactly ``jcs.canonicalize(payload)`` here because the payload carries no
|
|
108
|
+
``signature`` field — so the signed bytes match the cross-runtime format.
|
|
109
|
+
"""
|
|
110
|
+
root = merkle_root(checkpoint_leaves(receipts))
|
|
111
|
+
payload: dict[str, Any] = {
|
|
112
|
+
"version": CHECKPOINT_VERSION,
|
|
113
|
+
"type": "checkpoint",
|
|
114
|
+
"signer_did": identity.did,
|
|
115
|
+
"created_at": now_iso(),
|
|
116
|
+
"tree_size": len(receipts),
|
|
117
|
+
"merkle_root": "sha256:" + root.hex(),
|
|
118
|
+
"receipt_ids": [r["receipt_id"] for r in receipts],
|
|
119
|
+
}
|
|
120
|
+
sig = identity.sign(canonical_bytes(payload, include_signature=True))
|
|
121
|
+
return {
|
|
122
|
+
"payload": payload,
|
|
123
|
+
"signer_did": identity.did,
|
|
124
|
+
"signature": base64.b64encode(sig).decode("ascii"),
|
|
125
|
+
}
|
sm_server/signing.py
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"""did:key derivation + Ed25519 verification — the crypto the protocol surface needs.
|
|
2
|
+
|
|
3
|
+
Same primitives as the conformance suite and sm-conformance: W3C did:key
|
|
4
|
+
(multibase base58btc over multicodec 0xed01 ‖ pubkey32) and Ed25519 over a
|
|
5
|
+
canonical string. No backend, no framework — pure functions.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import base58
|
|
11
|
+
from cryptography.exceptions import InvalidSignature
|
|
12
|
+
from cryptography.hazmat.primitives import serialization
|
|
13
|
+
from cryptography.hazmat.primitives.asymmetric.ed25519 import (
|
|
14
|
+
Ed25519PrivateKey,
|
|
15
|
+
Ed25519PublicKey,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
ED25519_MULTICODEC_PREFIX = b"\xed\x01"
|
|
19
|
+
|
|
20
|
+
_RAW = serialization.Encoding.Raw
|
|
21
|
+
_RAW_PUB = serialization.PublicFormat.Raw
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def generate_chapter_identity() -> tuple[Ed25519PrivateKey, bytes, str]:
|
|
25
|
+
"""Return (private_key, pubkey32, did_key) for the server's own identity."""
|
|
26
|
+
priv = Ed25519PrivateKey.generate()
|
|
27
|
+
pub32 = priv.public_key().public_bytes(_RAW, _RAW_PUB)
|
|
28
|
+
return priv, pub32, derive_did_key(pub32)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def derive_did_key(pubkey32: bytes) -> str:
|
|
32
|
+
if len(pubkey32) != 32:
|
|
33
|
+
raise ValueError(f"Ed25519 public key must be 32 bytes, got {len(pubkey32)}")
|
|
34
|
+
return "did:key:z" + base58.b58encode(ED25519_MULTICODEC_PREFIX + pubkey32).decode("ascii")
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def parse_did_key(did_key: str) -> bytes:
|
|
38
|
+
if not did_key.startswith("did:key:z"):
|
|
39
|
+
raise ValueError(f"not a did:key: {did_key!r}")
|
|
40
|
+
decoded = base58.b58decode(did_key[len("did:key:z") :])
|
|
41
|
+
if not decoded.startswith(ED25519_MULTICODEC_PREFIX):
|
|
42
|
+
raise ValueError("did:key does not encode an Ed25519 key")
|
|
43
|
+
pub = decoded[len(ED25519_MULTICODEC_PREFIX) :]
|
|
44
|
+
if len(pub) != 32:
|
|
45
|
+
raise ValueError(f"decoded key has wrong length: {len(pub)}")
|
|
46
|
+
return pub
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def verify(pubkey32: bytes, signature: bytes, message: bytes) -> bool:
|
|
50
|
+
"""True iff `signature` is a valid Ed25519 signature over `message` by `pubkey32`."""
|
|
51
|
+
try:
|
|
52
|
+
Ed25519PublicKey.from_public_bytes(pubkey32).verify(signature, message)
|
|
53
|
+
return True
|
|
54
|
+
except (InvalidSignature, ValueError):
|
|
55
|
+
return False
|
|
File without changes
|
sm_server/store/base.py
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
"""The storage seam — the one interface the HTTP layer depends on.
|
|
2
|
+
|
|
3
|
+
Everything a conformant server needs to persist goes through ``ServerStore``.
|
|
4
|
+
The default backend is SQLite (zero-config, file-backed); Postgres or any other
|
|
5
|
+
store is a drop-in that satisfies this Protocol. Nothing above this interface
|
|
6
|
+
knows what the backend is — that is what makes the core self-hostable.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
from typing import Protocol
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass(frozen=True)
|
|
16
|
+
class Member:
|
|
17
|
+
agent_id: str
|
|
18
|
+
name: str
|
|
19
|
+
origin: str # deployment-declared provenance, e.g. "sovereign" (see SERVER_ORIGINS)
|
|
20
|
+
public_key: str # standard base64 of the 32-byte Ed25519 public key
|
|
21
|
+
did_key: str
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class ServerStore(Protocol):
|
|
25
|
+
"""Persistence the conformant protocol surface requires."""
|
|
26
|
+
|
|
27
|
+
def get_member(self, agent_id: str) -> Member | None: ...
|
|
28
|
+
|
|
29
|
+
def put_member(self, member: Member) -> None: ...
|
|
30
|
+
|
|
31
|
+
def rotate_key(self, agent_id: str, new_public_key: str, new_did_key: str) -> None: ...
|
|
32
|
+
|
|
33
|
+
def consume_nonce(self, scope: str, nonce: str) -> bool:
|
|
34
|
+
"""Record (scope, nonce). Return True if fresh, False if already seen (replay)."""
|
|
35
|
+
...
|
|
36
|
+
|
|
37
|
+
def member_count(self) -> int: ...
|
|
38
|
+
|
|
39
|
+
def list_members(self, limit: int = 50) -> list[Member]: ...
|
|
40
|
+
|
|
41
|
+
def record_trust_event(self, agent_id: str, kind: str, delta: float) -> None:
|
|
42
|
+
"""Append an immutable trust event. The agent's score is the SUM of deltas."""
|
|
43
|
+
...
|
|
44
|
+
|
|
45
|
+
def trust_score(self, agent_id: str) -> float:
|
|
46
|
+
"""Authoritative score = SUM of recorded deltas (0.0 if none)."""
|
|
47
|
+
...
|
|
48
|
+
|
|
49
|
+
def trust_history(self, agent_id: str) -> list[dict[str, object]]:
|
|
50
|
+
"""Ordered list of {kind, delta} events for the agent."""
|
|
51
|
+
...
|
|
52
|
+
|
|
53
|
+
# ── ARP Issuer Log (spec §10.2) ─────────────────────────────────
|
|
54
|
+
# The server persists every receipt it accepts. The receipt envelope
|
|
55
|
+
# (build/sign/verify/chain-link) is owned by `sm_arp`; this seam owns only
|
|
56
|
+
# *where the bytes live*, so a Postgres-backed server keeps receipts in
|
|
57
|
+
# Postgres alongside members — not in a side file.
|
|
58
|
+
|
|
59
|
+
def append_receipt(self, receipt: dict[str, object]) -> str:
|
|
60
|
+
"""Persist a verified receipt; return the chain link it produces.
|
|
61
|
+
|
|
62
|
+
Idempotent on (issuer_did, receipt_id) — re-appending replaces rather
|
|
63
|
+
than duplicating (the spec's replay floor).
|
|
64
|
+
"""
|
|
65
|
+
...
|
|
66
|
+
|
|
67
|
+
def get_receipt(self, receipt_id: str) -> dict[str, object] | None: ...
|
|
68
|
+
|
|
69
|
+
def get_receipt_by_chain_link(self, issuer_did: str, chain_link: str) -> dict[str, object] | None:
|
|
70
|
+
"""Resolve a receipt by its per-issuer chain link — used to find the
|
|
71
|
+
predecessor a new receipt's ``previous_receipt_hash`` points at. Scoped
|
|
72
|
+
by issuer because the hash chain is per-issuer (ARP §6.4)."""
|
|
73
|
+
...
|
|
74
|
+
|
|
75
|
+
def list_receipts(self, limit: int = 100) -> list[dict[str, object]]: ...
|
|
76
|
+
|
|
77
|
+
def list_receipts_for_principal(
|
|
78
|
+
self, principal_did: str, limit: int = 100
|
|
79
|
+
) -> list[dict[str, object]]: ...
|
|
80
|
+
|
|
81
|
+
def receipt_count(self) -> int: ...
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
"""SQLite implementation of ServerStore — the zero-config self-hostable default.
|
|
2
|
+
|
|
3
|
+
Uses parameterized queries throughout, so hostile agent_ids (the R3 injection
|
|
4
|
+
vectors in the conformance suite) are stored as inert data, never interpolated.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
import sqlite3
|
|
11
|
+
import threading
|
|
12
|
+
|
|
13
|
+
from sm_arp import chain_link
|
|
14
|
+
|
|
15
|
+
from sm_server.store.base import Member
|
|
16
|
+
|
|
17
|
+
_SCHEMA = """
|
|
18
|
+
CREATE TABLE IF NOT EXISTS members (
|
|
19
|
+
agent_id TEXT PRIMARY KEY,
|
|
20
|
+
name TEXT NOT NULL,
|
|
21
|
+
origin TEXT NOT NULL,
|
|
22
|
+
public_key TEXT NOT NULL,
|
|
23
|
+
did_key TEXT NOT NULL
|
|
24
|
+
);
|
|
25
|
+
CREATE TABLE IF NOT EXISTS nonces (
|
|
26
|
+
scope TEXT NOT NULL,
|
|
27
|
+
nonce TEXT NOT NULL,
|
|
28
|
+
PRIMARY KEY (scope, nonce)
|
|
29
|
+
);
|
|
30
|
+
CREATE TABLE IF NOT EXISTS trust_events (
|
|
31
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
32
|
+
agent_id TEXT NOT NULL,
|
|
33
|
+
kind TEXT NOT NULL,
|
|
34
|
+
delta REAL NOT NULL
|
|
35
|
+
);
|
|
36
|
+
CREATE INDEX IF NOT EXISTS idx_trust_agent ON trust_events (agent_id);
|
|
37
|
+
CREATE TABLE IF NOT EXISTS receipts (
|
|
38
|
+
receipt_id TEXT NOT NULL,
|
|
39
|
+
issuer_did TEXT NOT NULL,
|
|
40
|
+
principal_did TEXT NOT NULL,
|
|
41
|
+
issued_at TEXT NOT NULL,
|
|
42
|
+
category TEXT,
|
|
43
|
+
counterparty_did TEXT,
|
|
44
|
+
previous_receipt_hash TEXT,
|
|
45
|
+
chain_link TEXT NOT NULL,
|
|
46
|
+
receipt_json TEXT NOT NULL,
|
|
47
|
+
PRIMARY KEY (issuer_did, receipt_id)
|
|
48
|
+
);
|
|
49
|
+
CREATE INDEX IF NOT EXISTS idx_receipts_principal ON receipts (principal_did, issued_at);
|
|
50
|
+
CREATE INDEX IF NOT EXISTS idx_receipts_chain ON receipts (issuer_did, chain_link);
|
|
51
|
+
"""
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class SqliteStore:
|
|
55
|
+
def __init__(self, path: str = ":memory:") -> None:
|
|
56
|
+
self._conn = sqlite3.connect(path, check_same_thread=False)
|
|
57
|
+
self._lock = threading.Lock()
|
|
58
|
+
with self._lock:
|
|
59
|
+
self._conn.executescript(_SCHEMA)
|
|
60
|
+
self._conn.commit()
|
|
61
|
+
|
|
62
|
+
def get_member(self, agent_id: str) -> Member | None:
|
|
63
|
+
with self._lock:
|
|
64
|
+
row = self._conn.execute(
|
|
65
|
+
"SELECT agent_id, name, origin, public_key, did_key FROM members WHERE agent_id = ?",
|
|
66
|
+
(agent_id,),
|
|
67
|
+
).fetchone()
|
|
68
|
+
return Member(*row) if row else None
|
|
69
|
+
|
|
70
|
+
def put_member(self, member: Member) -> None:
|
|
71
|
+
with self._lock:
|
|
72
|
+
self._conn.execute(
|
|
73
|
+
"INSERT OR REPLACE INTO members (agent_id, name, origin, public_key, did_key) "
|
|
74
|
+
"VALUES (?, ?, ?, ?, ?)",
|
|
75
|
+
(member.agent_id, member.name, member.origin, member.public_key, member.did_key),
|
|
76
|
+
)
|
|
77
|
+
self._conn.commit()
|
|
78
|
+
|
|
79
|
+
def rotate_key(self, agent_id: str, new_public_key: str, new_did_key: str) -> None:
|
|
80
|
+
with self._lock:
|
|
81
|
+
self._conn.execute(
|
|
82
|
+
"UPDATE members SET public_key = ?, did_key = ? WHERE agent_id = ?",
|
|
83
|
+
(new_public_key, new_did_key, agent_id),
|
|
84
|
+
)
|
|
85
|
+
self._conn.commit()
|
|
86
|
+
|
|
87
|
+
def consume_nonce(self, scope: str, nonce: str) -> bool:
|
|
88
|
+
with self._lock:
|
|
89
|
+
try:
|
|
90
|
+
self._conn.execute("INSERT INTO nonces (scope, nonce) VALUES (?, ?)", (scope, nonce))
|
|
91
|
+
self._conn.commit()
|
|
92
|
+
return True
|
|
93
|
+
except sqlite3.IntegrityError:
|
|
94
|
+
return False
|
|
95
|
+
|
|
96
|
+
def member_count(self) -> int:
|
|
97
|
+
with self._lock:
|
|
98
|
+
return int(self._conn.execute("SELECT COUNT(*) FROM members").fetchone()[0])
|
|
99
|
+
|
|
100
|
+
def list_members(self, limit: int = 50) -> list[Member]:
|
|
101
|
+
with self._lock:
|
|
102
|
+
rows = self._conn.execute(
|
|
103
|
+
"SELECT agent_id, name, origin, public_key, did_key FROM members ORDER BY agent_id LIMIT ?",
|
|
104
|
+
(int(limit),),
|
|
105
|
+
).fetchall()
|
|
106
|
+
return [Member(*r) for r in rows]
|
|
107
|
+
|
|
108
|
+
def record_trust_event(self, agent_id: str, kind: str, delta: float) -> None:
|
|
109
|
+
with self._lock:
|
|
110
|
+
self._conn.execute(
|
|
111
|
+
"INSERT INTO trust_events (agent_id, kind, delta) VALUES (?, ?, ?)",
|
|
112
|
+
(agent_id, kind, float(delta)),
|
|
113
|
+
)
|
|
114
|
+
self._conn.commit()
|
|
115
|
+
|
|
116
|
+
def trust_score(self, agent_id: str) -> float:
|
|
117
|
+
with self._lock:
|
|
118
|
+
row = self._conn.execute(
|
|
119
|
+
"SELECT COALESCE(SUM(delta), 0.0) FROM trust_events WHERE agent_id = ?",
|
|
120
|
+
(agent_id,),
|
|
121
|
+
).fetchone()
|
|
122
|
+
return float(row[0])
|
|
123
|
+
|
|
124
|
+
def trust_history(self, agent_id: str) -> list[dict[str, object]]:
|
|
125
|
+
with self._lock:
|
|
126
|
+
rows = self._conn.execute(
|
|
127
|
+
"SELECT kind, delta FROM trust_events WHERE agent_id = ? ORDER BY id",
|
|
128
|
+
(agent_id,),
|
|
129
|
+
).fetchall()
|
|
130
|
+
return [{"kind": k, "delta": d} for k, d in rows]
|
|
131
|
+
|
|
132
|
+
# ── ARP Issuer Log ─────────────────────────────────────────────
|
|
133
|
+
|
|
134
|
+
def append_receipt(self, receipt: dict[str, object]) -> str:
|
|
135
|
+
link: str = chain_link(receipt)
|
|
136
|
+
action = receipt.get("action")
|
|
137
|
+
action = action if isinstance(action, dict) else {}
|
|
138
|
+
with self._lock:
|
|
139
|
+
self._conn.execute(
|
|
140
|
+
"INSERT OR REPLACE INTO receipts (receipt_id, issuer_did, principal_did, "
|
|
141
|
+
"issued_at, category, counterparty_did, previous_receipt_hash, chain_link, "
|
|
142
|
+
"receipt_json) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
|
143
|
+
(
|
|
144
|
+
receipt["receipt_id"],
|
|
145
|
+
receipt["issuer_did"],
|
|
146
|
+
receipt["principal_did"],
|
|
147
|
+
receipt["issued_at"],
|
|
148
|
+
action.get("category"),
|
|
149
|
+
action.get("counterparty_did"),
|
|
150
|
+
receipt.get("previous_receipt_hash"),
|
|
151
|
+
link,
|
|
152
|
+
json.dumps(receipt, separators=(",", ":")),
|
|
153
|
+
),
|
|
154
|
+
)
|
|
155
|
+
self._conn.commit()
|
|
156
|
+
return link
|
|
157
|
+
|
|
158
|
+
def _receipts_query(self, where: str, args: tuple[object, ...], limit: int) -> list[dict[str, object]]:
|
|
159
|
+
with self._lock:
|
|
160
|
+
rows = self._conn.execute(
|
|
161
|
+
f"SELECT receipt_json FROM receipts {where} ORDER BY issued_at DESC LIMIT ?", # noqa: S608
|
|
162
|
+
(*args, int(limit)),
|
|
163
|
+
).fetchall()
|
|
164
|
+
return [json.loads(r[0]) for r in rows]
|
|
165
|
+
|
|
166
|
+
def get_receipt(self, receipt_id: str) -> dict[str, object] | None:
|
|
167
|
+
rows = self._receipts_query("WHERE receipt_id = ?", (receipt_id,), 1)
|
|
168
|
+
return rows[0] if rows else None
|
|
169
|
+
|
|
170
|
+
def get_receipt_by_chain_link(self, issuer_did: str, chain_link: str) -> dict[str, object] | None:
|
|
171
|
+
rows = self._receipts_query("WHERE issuer_did = ? AND chain_link = ?", (issuer_did, chain_link), 1)
|
|
172
|
+
return rows[0] if rows else None
|
|
173
|
+
|
|
174
|
+
def list_receipts(self, limit: int = 100) -> list[dict[str, object]]:
|
|
175
|
+
return self._receipts_query("", (), limit)
|
|
176
|
+
|
|
177
|
+
def list_receipts_for_principal(self, principal_did: str, limit: int = 100) -> list[dict[str, object]]:
|
|
178
|
+
return self._receipts_query("WHERE principal_did = ?", (principal_did,), limit)
|
|
179
|
+
|
|
180
|
+
def receipt_count(self) -> int:
|
|
181
|
+
with self._lock:
|
|
182
|
+
return int(self._conn.execute("SELECT COUNT(*) FROM receipts").fetchone()[0])
|
sm_server/trust.py
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"""Trust ledger arithmetic — server-set deltas, tier thresholds, score → tier.
|
|
2
|
+
|
|
3
|
+
These tables are part of the protocol, not a tuning knob: a server that
|
|
4
|
+
hand-rolls its own deltas or tiers diverges from every other server and the
|
|
5
|
+
conformance suite catches it. The values are frozen per protocol major; the
|
|
6
|
+
score of an agent is, by construction, the SUM of its event deltas — so an
|
|
7
|
+
authoritative ledger has zero drift between stored and recomputed scores.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from typing import TypedDict
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class TierRow(TypedDict):
|
|
16
|
+
score_min: int
|
|
17
|
+
tier: str
|
|
18
|
+
open_calls_max: int
|
|
19
|
+
ttl_days: int
|
|
20
|
+
federate: bool
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
# Server-set, immutable per major (spec/0.X trust event deltas).
|
|
24
|
+
EVENT_DELTAS: dict[str, float] = {
|
|
25
|
+
"intent_match_accepted": 0.5,
|
|
26
|
+
"intent_response_useful": 1.0,
|
|
27
|
+
"call_response_accepted": 0.5,
|
|
28
|
+
"conversation_completed_positive": 1.0,
|
|
29
|
+
"skill_attested_by_trusted": 2.0,
|
|
30
|
+
"endorsement_received": 0.5,
|
|
31
|
+
"tenure_milestone_30d": 1.0,
|
|
32
|
+
"tenure_milestone_90d": 2.0,
|
|
33
|
+
"tenure_milestone_180d": 5.0,
|
|
34
|
+
"tenure_milestone_365d": 10.0,
|
|
35
|
+
"revocation_received": -1.0,
|
|
36
|
+
"complaint_validated": -3.0,
|
|
37
|
+
"inactive_decay": -1.0,
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
# Ascending by score_min; `federate` gates cross-server participation.
|
|
41
|
+
TIER_THRESHOLDS: list[TierRow] = [
|
|
42
|
+
{"score_min": 0, "tier": "new", "open_calls_max": 1, "ttl_days": 7, "federate": False},
|
|
43
|
+
{"score_min": 20, "tier": "established", "open_calls_max": 3, "ttl_days": 30, "federate": False},
|
|
44
|
+
{"score_min": 50, "tier": "trusted", "open_calls_max": 10, "ttl_days": 90, "federate": True},
|
|
45
|
+
{"score_min": 75, "tier": "core", "open_calls_max": 30, "ttl_days": 180, "federate": True},
|
|
46
|
+
]
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def tier_for(score: float) -> TierRow:
|
|
50
|
+
"""The highest tier whose score_min the score clears."""
|
|
51
|
+
chosen = TIER_THRESHOLDS[0]
|
|
52
|
+
for row in TIER_THRESHOLDS:
|
|
53
|
+
if row["score_min"] <= score:
|
|
54
|
+
chosen = row
|
|
55
|
+
return chosen
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def next_tier_min(score: float) -> int | None:
|
|
59
|
+
"""score_min of the next tier up, or None if already in the top tier."""
|
|
60
|
+
for row in TIER_THRESHOLDS:
|
|
61
|
+
if row["score_min"] > score:
|
|
62
|
+
return row["score_min"]
|
|
63
|
+
return None
|