lockermcp 0.1.1__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.
- lockerd/__init__.py +3 -0
- lockerd/__main__.py +17 -0
- lockerd/config.py +61 -0
- lockerd/db.py +264 -0
- lockerd/hashchain.py +69 -0
- lockerd/main.py +428 -0
- lockerd/models.py +97 -0
- lockerd/payments.py +127 -0
- lockermcp/__init__.py +3 -0
- lockermcp/__main__.py +11 -0
- lockermcp/server.py +259 -0
- lockermcp-0.1.1.dist-info/METADATA +404 -0
- lockermcp-0.1.1.dist-info/RECORD +17 -0
- lockermcp-0.1.1.dist-info/WHEEL +5 -0
- lockermcp-0.1.1.dist-info/entry_points.txt +3 -0
- lockermcp-0.1.1.dist-info/licenses/LICENSE +661 -0
- lockermcp-0.1.1.dist-info/top_level.txt +2 -0
lockerd/__init__.py
ADDED
lockerd/__main__.py
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""Run the daemon with ``python -m lockerd`` (honours LOCKER_DB / AUTH_MODE env)."""
|
|
2
|
+
import os
|
|
3
|
+
|
|
4
|
+
import uvicorn
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def main() -> None:
|
|
8
|
+
uvicorn.run(
|
|
9
|
+
"lockerd.main:create_app",
|
|
10
|
+
factory=True,
|
|
11
|
+
host=os.environ.get("LOCKER_HOST", "127.0.0.1"),
|
|
12
|
+
port=int(os.environ.get("LOCKER_PORT", "8000")),
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
if __name__ == "__main__":
|
|
17
|
+
main()
|
lockerd/config.py
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""Configuration and hard limits for the locker daemon."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import os
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
|
|
7
|
+
# Hard limits (memory-safe, per spec)
|
|
8
|
+
MAX_BLOCK_BYTES = 64 * 1024 # max payload per block: 64 KB
|
|
9
|
+
MAX_RESPONSE_BYTES = 64 * 1024 # max payload returned per blocks slice: 64 KB
|
|
10
|
+
MAX_BLOCKS_PER_RESPONSE = 256 # max block count per blocks slice
|
|
11
|
+
DEFAULT_MAX_BLOCKS = 32
|
|
12
|
+
DEFAULT_MAX_BYTES = 256 * 1024 # per-pad byte quota: 256 KB
|
|
13
|
+
MAX_MAX_BLOCKS = 4096 # sanity cap on user-provided max_blocks
|
|
14
|
+
MAX_TTL_SECONDS = 30 * 24 * 3600 # 30 days
|
|
15
|
+
READ_LEASE_SECONDS = 600 # read_once ticket lease window (10 min)
|
|
16
|
+
ZERO_HASH = "0" * 64
|
|
17
|
+
|
|
18
|
+
# Auth modes (canonical)
|
|
19
|
+
AUTH_OPEN = "open" # open/local access, no payment required (default)
|
|
20
|
+
AUTH_TXID = "txid" # tx-hash receipt verification (Base USDC)
|
|
21
|
+
# Legacy aliases (kept for backward compatibility)
|
|
22
|
+
AUTH_LOCAL = AUTH_OPEN
|
|
23
|
+
AUTH_X402 = AUTH_TXID
|
|
24
|
+
|
|
25
|
+
# x402 payment defaults (Base USDC)
|
|
26
|
+
DEFAULT_BASE_RPC_URL = "https://mainnet.base.org"
|
|
27
|
+
DEFAULT_USDC_CONTRACT = "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913"
|
|
28
|
+
DEFAULT_REQUIRED_USDC_UNITS = 2000 # 0.002 USDC (6 decimals)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def normalize_mode(mode: str) -> str:
|
|
32
|
+
"""Map legacy mode labels to canonical values."""
|
|
33
|
+
m = (mode or "").strip().lower()
|
|
34
|
+
if m in ("open", "local", "dev"):
|
|
35
|
+
return AUTH_OPEN
|
|
36
|
+
if m in ("txid", "x402"):
|
|
37
|
+
return AUTH_TXID
|
|
38
|
+
return AUTH_OPEN # unknown -> open (developer-friendly default)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@dataclass
|
|
42
|
+
class Config:
|
|
43
|
+
db_path: str = "locker.db"
|
|
44
|
+
auth_mode: str = AUTH_OPEN
|
|
45
|
+
read_lease_seconds: int = READ_LEASE_SECONDS
|
|
46
|
+
payment_wallet_address: str | None = None
|
|
47
|
+
base_rpc_url: str = DEFAULT_BASE_RPC_URL
|
|
48
|
+
required_usdc_units: int = DEFAULT_REQUIRED_USDC_UNITS
|
|
49
|
+
usdc_contract: str = DEFAULT_USDC_CONTRACT
|
|
50
|
+
|
|
51
|
+
@classmethod
|
|
52
|
+
def from_env(cls) -> "Config":
|
|
53
|
+
return cls(
|
|
54
|
+
db_path=os.environ.get("LOCKER_DB_PATH", "locker.db"),
|
|
55
|
+
auth_mode=normalize_mode(os.environ.get("LOCKER_MODE", AUTH_OPEN)),
|
|
56
|
+
read_lease_seconds=int(os.environ.get("LOCKER_READ_LEASE_SECONDS", READ_LEASE_SECONDS)),
|
|
57
|
+
payment_wallet_address=os.environ.get("PAYMENT_WALLET_ADDRESS") or None,
|
|
58
|
+
base_rpc_url=os.environ.get("BASE_RPC_URL", DEFAULT_BASE_RPC_URL),
|
|
59
|
+
required_usdc_units=int(os.environ.get("REQUIRED_USDC_UNITS", DEFAULT_REQUIRED_USDC_UNITS)),
|
|
60
|
+
usdc_contract=os.environ.get("USDC_CONTRACT", DEFAULT_USDC_CONTRACT),
|
|
61
|
+
)
|
lockerd/db.py
ADDED
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
"""SQLite store (WAL mode) for pads, blocks, and tickets.
|
|
2
|
+
|
|
3
|
+
Methods are intentionally lock-free; the request layer serializes compound
|
|
4
|
+
operations with ``Store.lock``. A single aiosqlite connection is used.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import asyncio
|
|
9
|
+
import sqlite3
|
|
10
|
+
import time
|
|
11
|
+
|
|
12
|
+
import aiosqlite
|
|
13
|
+
|
|
14
|
+
from . import config as cfg
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def now() -> int:
|
|
18
|
+
"""Current unix time (monkeypatchable in tests)."""
|
|
19
|
+
return int(time.time())
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
SCHEMA = """
|
|
23
|
+
CREATE TABLE IF NOT EXISTS pads (
|
|
24
|
+
id TEXT PRIMARY KEY,
|
|
25
|
+
created_at INTEGER NOT NULL,
|
|
26
|
+
expires_at INTEGER NOT NULL,
|
|
27
|
+
max_blocks INTEGER NOT NULL DEFAULT 32,
|
|
28
|
+
max_bytes INTEGER NOT NULL DEFAULT 262144,
|
|
29
|
+
current_bytes INTEGER NOT NULL DEFAULT 0,
|
|
30
|
+
state TEXT CHECK(state IN ('open', 'sealed', 'expired')) NOT NULL DEFAULT 'open',
|
|
31
|
+
write_key_hash TEXT NOT NULL,
|
|
32
|
+
head_hash TEXT NOT NULL DEFAULT '0000000000000000000000000000000000000000000000000000000000000000',
|
|
33
|
+
sealed_at INTEGER
|
|
34
|
+
);
|
|
35
|
+
|
|
36
|
+
CREATE TABLE IF NOT EXISTS blocks (
|
|
37
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
38
|
+
pad_id TEXT NOT NULL REFERENCES pads(id),
|
|
39
|
+
seq INTEGER NOT NULL,
|
|
40
|
+
prev_hash TEXT NOT NULL,
|
|
41
|
+
curr_hash TEXT NOT NULL,
|
|
42
|
+
content_type TEXT NOT NULL DEFAULT 'application/json',
|
|
43
|
+
payload BLOB NOT NULL,
|
|
44
|
+
created_at INTEGER NOT NULL,
|
|
45
|
+
UNIQUE(pad_id, seq)
|
|
46
|
+
);
|
|
47
|
+
|
|
48
|
+
CREATE TABLE IF NOT EXISTS tickets (
|
|
49
|
+
ticket_id TEXT PRIMARY KEY,
|
|
50
|
+
pad_id TEXT NOT NULL REFERENCES pads(id),
|
|
51
|
+
type TEXT CHECK(type IN ('read_once', 'read_unlimited')) NOT NULL,
|
|
52
|
+
redeemed_count INTEGER NOT NULL DEFAULT 0,
|
|
53
|
+
max_reads INTEGER NOT NULL DEFAULT 1,
|
|
54
|
+
lease_started_at INTEGER,
|
|
55
|
+
created_at INTEGER NOT NULL
|
|
56
|
+
);
|
|
57
|
+
|
|
58
|
+
CREATE INDEX IF NOT EXISTS idx_blocks_pad_seq ON blocks(pad_id, seq);
|
|
59
|
+
CREATE INDEX IF NOT EXISTS idx_tickets_pad ON tickets(pad_id);
|
|
60
|
+
|
|
61
|
+
CREATE TABLE IF NOT EXISTS payment_receipts (
|
|
62
|
+
tx_hash TEXT PRIMARY KEY,
|
|
63
|
+
pad_id TEXT NOT NULL REFERENCES pads(id),
|
|
64
|
+
amount_units INTEGER NOT NULL,
|
|
65
|
+
payer_address TEXT,
|
|
66
|
+
created_at INTEGER NOT NULL
|
|
67
|
+
);
|
|
68
|
+
|
|
69
|
+
CREATE INDEX IF NOT EXISTS idx_receipts_pad_id ON payment_receipts(pad_id);
|
|
70
|
+
"""
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
async def connect(path: str) -> aiosqlite.Connection:
|
|
74
|
+
conn = await aiosqlite.connect(path)
|
|
75
|
+
conn.row_factory = aiosqlite.Row
|
|
76
|
+
await conn.execute("PRAGMA journal_mode = WAL")
|
|
77
|
+
await conn.execute("PRAGMA foreign_keys = ON")
|
|
78
|
+
await conn.execute("PRAGMA synchronous = NORMAL")
|
|
79
|
+
for stmt in SCHEMA.split(";"):
|
|
80
|
+
stmt = stmt.strip()
|
|
81
|
+
if stmt:
|
|
82
|
+
await conn.execute(stmt)
|
|
83
|
+
await conn.commit()
|
|
84
|
+
return conn
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
class Store:
|
|
88
|
+
def __init__(self, conn: aiosqlite.Connection, read_lease_seconds: int = cfg.READ_LEASE_SECONDS):
|
|
89
|
+
self.conn = conn
|
|
90
|
+
self.lock = asyncio.Lock()
|
|
91
|
+
self.read_lease_seconds = read_lease_seconds
|
|
92
|
+
|
|
93
|
+
# ---- pads ----
|
|
94
|
+
async def create_pad(self, pad_id, write_key_hash, ttl_seconds, max_blocks):
|
|
95
|
+
t = now()
|
|
96
|
+
await self.conn.execute(
|
|
97
|
+
"INSERT INTO pads (id, created_at, expires_at, max_blocks, max_bytes, write_key_hash)"
|
|
98
|
+
" VALUES (?,?,?,?,?,?)",
|
|
99
|
+
(pad_id, t, t + ttl_seconds, max_blocks, cfg.DEFAULT_MAX_BYTES, write_key_hash),
|
|
100
|
+
)
|
|
101
|
+
await self.conn.commit()
|
|
102
|
+
|
|
103
|
+
async def has_receipt(self, tx_hash):
|
|
104
|
+
cur = await self.conn.execute(
|
|
105
|
+
"SELECT 1 FROM payment_receipts WHERE tx_hash = ?", (tx_hash,)
|
|
106
|
+
)
|
|
107
|
+
row = await cur.fetchone()
|
|
108
|
+
await cur.close()
|
|
109
|
+
return row is not None
|
|
110
|
+
|
|
111
|
+
async def create_pad_with_receipt(self, pad_id, write_key_hash, ttl_seconds,
|
|
112
|
+
max_blocks, tx_hash, amount_units, payer_address):
|
|
113
|
+
"""Atomically create a pad and record its payment receipt.
|
|
114
|
+
|
|
115
|
+
Returns False (leaving no pad behind) if ``tx_hash`` was already redeemed.
|
|
116
|
+
"""
|
|
117
|
+
t = now()
|
|
118
|
+
try:
|
|
119
|
+
await self.conn.execute(
|
|
120
|
+
"INSERT INTO pads (id, created_at, expires_at, max_blocks, max_bytes, write_key_hash)"
|
|
121
|
+
" VALUES (?,?,?,?,?,?)",
|
|
122
|
+
(pad_id, t, t + ttl_seconds, max_blocks, cfg.DEFAULT_MAX_BYTES, write_key_hash),
|
|
123
|
+
)
|
|
124
|
+
await self.conn.execute(
|
|
125
|
+
"INSERT INTO payment_receipts (tx_hash, pad_id, amount_units, payer_address, created_at)"
|
|
126
|
+
" VALUES (?,?,?,?,?)",
|
|
127
|
+
(tx_hash, pad_id, amount_units, payer_address, t),
|
|
128
|
+
)
|
|
129
|
+
await self.conn.commit()
|
|
130
|
+
return True
|
|
131
|
+
except sqlite3.IntegrityError:
|
|
132
|
+
await self.conn.rollback()
|
|
133
|
+
return False
|
|
134
|
+
except Exception:
|
|
135
|
+
await self.conn.rollback()
|
|
136
|
+
raise
|
|
137
|
+
|
|
138
|
+
async def get_pad(self, pad_id):
|
|
139
|
+
cur = await self.conn.execute("SELECT * FROM pads WHERE id = ?", (pad_id,))
|
|
140
|
+
row = await cur.fetchone()
|
|
141
|
+
await cur.close()
|
|
142
|
+
return dict(row) if row else None
|
|
143
|
+
|
|
144
|
+
async def expire_pad_if_needed(self, pad):
|
|
145
|
+
if pad["state"] == "open" and pad["expires_at"] <= now():
|
|
146
|
+
await self.conn.execute(
|
|
147
|
+
"UPDATE pads SET state='expired' WHERE id=? AND state='open'", (pad["id"],)
|
|
148
|
+
)
|
|
149
|
+
await self.conn.commit()
|
|
150
|
+
pad["state"] = "expired"
|
|
151
|
+
return pad
|
|
152
|
+
|
|
153
|
+
async def seal_pad(self, pad_id):
|
|
154
|
+
await self.conn.execute(
|
|
155
|
+
"UPDATE pads SET state='sealed', sealed_at=? WHERE id=?", (now(), pad_id)
|
|
156
|
+
)
|
|
157
|
+
await self.conn.commit()
|
|
158
|
+
|
|
159
|
+
# ---- blocks ----
|
|
160
|
+
async def block_count(self, pad_id):
|
|
161
|
+
cur = await self.conn.execute(
|
|
162
|
+
"SELECT COUNT(*) AS c FROM blocks WHERE pad_id=?", (pad_id,)
|
|
163
|
+
)
|
|
164
|
+
row = await cur.fetchone()
|
|
165
|
+
await cur.close()
|
|
166
|
+
return row["c"]
|
|
167
|
+
|
|
168
|
+
async def append_block(self, pad_id, seq, prev_hash, curr_hash, content_type, payload):
|
|
169
|
+
await self.conn.execute(
|
|
170
|
+
"INSERT INTO blocks (pad_id, seq, prev_hash, curr_hash, content_type, payload, created_at)"
|
|
171
|
+
" VALUES (?,?,?,?,?,?,?)",
|
|
172
|
+
(pad_id, seq, prev_hash, curr_hash, content_type, payload, now()),
|
|
173
|
+
)
|
|
174
|
+
await self.conn.execute(
|
|
175
|
+
"UPDATE pads SET head_hash=?, current_bytes=current_bytes+? WHERE id=?",
|
|
176
|
+
(curr_hash, len(payload), pad_id),
|
|
177
|
+
)
|
|
178
|
+
await self.conn.commit()
|
|
179
|
+
|
|
180
|
+
async def get_blocks(self, pad_id, frm, to):
|
|
181
|
+
cur = await self.conn.execute(
|
|
182
|
+
"SELECT seq, prev_hash, curr_hash, content_type, payload, created_at"
|
|
183
|
+
" FROM blocks WHERE pad_id=? AND seq BETWEEN ? AND ? ORDER BY seq",
|
|
184
|
+
(pad_id, frm, to),
|
|
185
|
+
)
|
|
186
|
+
rows = await cur.fetchall()
|
|
187
|
+
await cur.close()
|
|
188
|
+
return [dict(r) for r in rows]
|
|
189
|
+
|
|
190
|
+
# ---- tickets ----
|
|
191
|
+
async def create_ticket(self, ticket_id, pad_id, ttype, max_reads=1):
|
|
192
|
+
await self.conn.execute(
|
|
193
|
+
"INSERT INTO tickets (ticket_id, pad_id, type, max_reads, created_at)"
|
|
194
|
+
" VALUES (?,?,?,?,?)",
|
|
195
|
+
(ticket_id, pad_id, ttype, max_reads, now()),
|
|
196
|
+
)
|
|
197
|
+
await self.conn.commit()
|
|
198
|
+
|
|
199
|
+
async def check_ticket(self, pad_id, ticket_id):
|
|
200
|
+
"""Return 'ok', 'invalid', or 'exhausted' without consuming a read.
|
|
201
|
+
|
|
202
|
+
``read_once`` tickets carry a read *lease*: the first successful read
|
|
203
|
+
opens a ``read_lease_seconds`` window; reads inside it are allowed and
|
|
204
|
+
reads after it are rejected.
|
|
205
|
+
"""
|
|
206
|
+
cur = await self.conn.execute(
|
|
207
|
+
"SELECT * FROM tickets WHERE ticket_id = ?", (ticket_id,)
|
|
208
|
+
)
|
|
209
|
+
row = await cur.fetchone()
|
|
210
|
+
await cur.close()
|
|
211
|
+
if row is None or row["pad_id"] != pad_id:
|
|
212
|
+
return "invalid"
|
|
213
|
+
if row["type"] == "read_once" and row["lease_started_at"] is not None:
|
|
214
|
+
if now() - row["lease_started_at"] > self.read_lease_seconds:
|
|
215
|
+
return "exhausted"
|
|
216
|
+
return "ok"
|
|
217
|
+
|
|
218
|
+
async def record_read(self, ticket_id):
|
|
219
|
+
"""Record a successful read: open the lease on first read (read_once)."""
|
|
220
|
+
await self.conn.execute(
|
|
221
|
+
"UPDATE tickets SET redeemed_count = redeemed_count + 1,"
|
|
222
|
+
" lease_started_at = COALESCE(lease_started_at, ?)"
|
|
223
|
+
" WHERE ticket_id = ? AND type = 'read_once'",
|
|
224
|
+
(now(), ticket_id),
|
|
225
|
+
)
|
|
226
|
+
await self.conn.commit()
|
|
227
|
+
|
|
228
|
+
# ---- health ----
|
|
229
|
+
async def health_check(self):
|
|
230
|
+
"""Return ``(writable, wal_mode, total, unsealed, sealed)``."""
|
|
231
|
+
cur = await self.conn.execute("PRAGMA query_only")
|
|
232
|
+
row = await cur.fetchone()
|
|
233
|
+
await cur.close()
|
|
234
|
+
read_only = bool(row[0]) if row else False
|
|
235
|
+
|
|
236
|
+
cur = await self.conn.execute("PRAGMA journal_mode")
|
|
237
|
+
row = await cur.fetchone()
|
|
238
|
+
await cur.close()
|
|
239
|
+
wal_mode = ((row[0] or "").lower() if row else "") == "wal"
|
|
240
|
+
|
|
241
|
+
writable = not read_only
|
|
242
|
+
if writable:
|
|
243
|
+
try:
|
|
244
|
+
await self.conn.execute("BEGIN IMMEDIATE")
|
|
245
|
+
await self.conn.execute("ROLLBACK")
|
|
246
|
+
except Exception:
|
|
247
|
+
writable = False
|
|
248
|
+
|
|
249
|
+
cur = await self.conn.execute("SELECT COUNT(*) AS c FROM pads")
|
|
250
|
+
row = await cur.fetchone()
|
|
251
|
+
await cur.close()
|
|
252
|
+
total = row["c"]
|
|
253
|
+
|
|
254
|
+
cur = await self.conn.execute("SELECT COUNT(*) AS c FROM pads WHERE state='open'")
|
|
255
|
+
row = await cur.fetchone()
|
|
256
|
+
await cur.close()
|
|
257
|
+
unsealed = row["c"]
|
|
258
|
+
|
|
259
|
+
cur = await self.conn.execute("SELECT COUNT(*) AS c FROM pads WHERE state='sealed'")
|
|
260
|
+
row = await cur.fetchone()
|
|
261
|
+
await cur.close()
|
|
262
|
+
sealed = row["c"]
|
|
263
|
+
|
|
264
|
+
return writable, wal_mode, total, unsealed, sealed
|
lockerd/hashchain.py
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"""Tamper-evident hash chain and the strict handoff envelope."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import hashlib
|
|
5
|
+
import json
|
|
6
|
+
|
|
7
|
+
ENVELOPE_SCHEMA = "locker.handoff.v1"
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def sha256_hex(data: bytes) -> str:
|
|
11
|
+
return hashlib.sha256(data).hexdigest()
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def compute_hash(prev_hash: str, payload: bytes) -> str:
|
|
15
|
+
"""curr_hash = sha256(prev_hash.encode() + payload_bytes).hexdigest()"""
|
|
16
|
+
return hashlib.sha256(prev_hash.encode("utf-8") + payload).hexdigest()
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _nonempty_str(v) -> bool:
|
|
20
|
+
return isinstance(v, str) and v != ""
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _str_list(v) -> bool:
|
|
24
|
+
return isinstance(v, list) and all(isinstance(x, str) for x in v)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _dict_list(v) -> bool:
|
|
28
|
+
return isinstance(v, list) and all(isinstance(x, dict) for x in v)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _float_or_none(v) -> bool:
|
|
32
|
+
return v is None or (isinstance(v, (int, float)) and not isinstance(v, bool))
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
# Strictly required fields of the locker.handoff.v1 envelope (block 0).
|
|
36
|
+
REQUIRED_FIELDS = {
|
|
37
|
+
"task_id": _nonempty_str,
|
|
38
|
+
"from_agent": _nonempty_str,
|
|
39
|
+
"to_agent": _nonempty_str,
|
|
40
|
+
"constraints": _str_list,
|
|
41
|
+
"artifacts": _dict_list,
|
|
42
|
+
"budget_usd": _float_or_none,
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def envelope_error(payload: bytes) -> str | None:
|
|
47
|
+
"""Return a validation error string if ``payload`` is not a valid
|
|
48
|
+
``locker.handoff.v1`` envelope, else ``None``."""
|
|
49
|
+
try:
|
|
50
|
+
obj = json.loads(payload.decode("utf-8"))
|
|
51
|
+
except (ValueError, UnicodeDecodeError):
|
|
52
|
+
return "payload must be JSON"
|
|
53
|
+
if not isinstance(obj, dict):
|
|
54
|
+
return "payload must be a JSON object"
|
|
55
|
+
if obj.get("schema") != ENVELOPE_SCHEMA:
|
|
56
|
+
return f"schema must be {ENVELOPE_SCHEMA!r}"
|
|
57
|
+
for field, check in REQUIRED_FIELDS.items():
|
|
58
|
+
if field not in obj:
|
|
59
|
+
return f"missing required field {field!r}"
|
|
60
|
+
if not check(obj[field]):
|
|
61
|
+
return f"field {field!r} has an invalid type/value"
|
|
62
|
+
# Optional field: allowed_paths (list[str] or null). Absent/null = unconstrained.
|
|
63
|
+
if obj.get("allowed_paths") is not None and not _str_list(obj["allowed_paths"]):
|
|
64
|
+
return "field 'allowed_paths' has an invalid type/value (expected list[str] or null)"
|
|
65
|
+
return None
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def is_envelope(payload: bytes) -> bool:
|
|
69
|
+
return envelope_error(payload) is None
|