tinyplace 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.
tinyplace/auth.py ADDED
@@ -0,0 +1,93 @@
1
+ from __future__ import annotations
2
+
3
+ import base64
4
+ import secrets
5
+ from dataclasses import dataclass
6
+ from datetime import UTC, datetime
7
+
8
+ from .crypto import base64_url, sha256_hex
9
+ from .signer import Signer
10
+ from .types import Headers
11
+
12
+
13
+ @dataclass(frozen=True)
14
+ class AdminSigningOptions:
15
+ actor: str | None = None
16
+ role: str | None = None
17
+
18
+
19
+ def timestamp() -> str:
20
+ return datetime.now(UTC).isoformat(timespec="milliseconds").replace("+00:00", "Z")
21
+
22
+
23
+ def generate_nonce() -> str:
24
+ return base64.b64encode(secrets.token_bytes(16)).decode("ascii")
25
+
26
+
27
+ def _to_base64(data: bytes) -> str:
28
+ return base64.b64encode(data).decode("ascii")
29
+
30
+
31
+ def build_auth_header(agent_id: str, signature: str, signed_at: str) -> Headers:
32
+ return {"Authorization": f"tiny.place {agent_id}:{signature}:{signed_at}"}
33
+
34
+
35
+ async def sign_request(signer: Signer, body: str) -> Headers:
36
+ signed_at = timestamp()
37
+ signature = await signer.sign(f"{body}{signed_at}".encode("utf-8"))
38
+ return build_auth_header(signer.agent_id, _to_base64(signature), signed_at)
39
+
40
+
41
+ async def sign_admin_request(
42
+ signer: Signer,
43
+ method: str,
44
+ request_uri: str,
45
+ body: str,
46
+ options: AdminSigningOptions | None = None,
47
+ ) -> Headers:
48
+ options = options or AdminSigningOptions()
49
+ signed_at = timestamp()
50
+ nonce = generate_nonce()
51
+ actor = options.actor or signer.agent_id
52
+ role_line = f"\n{options.role}" if options.role else ""
53
+ payload = f"{method}\n{request_uri}\n{signed_at}\n{nonce}\n{sha256_hex(body)}{role_line}"
54
+ signature = await signer.sign(payload.encode("utf-8"))
55
+ role_field = f',role="{options.role}"' if options.role else ""
56
+ return {
57
+ "Authorization": (
58
+ f'TinyPlace-Admin actor="{actor}"{role_field},'
59
+ f'signature="{_to_base64(signature)}"'
60
+ ),
61
+ "X-TinyPlace-Date": signed_at,
62
+ "X-TinyPlace-Nonce": nonce,
63
+ }
64
+
65
+
66
+ async def sign_directory_write(
67
+ signer: Signer,
68
+ public_key_base64: str,
69
+ method: str,
70
+ request_uri: str,
71
+ body: str,
72
+ ) -> Headers:
73
+ signed_at = timestamp()
74
+ nonce = generate_nonce()
75
+ payload = f"{method}\n{request_uri}\n{signed_at}\n{nonce}\n{sha256_hex(body)}"
76
+ signature = await signer.sign(payload.encode("utf-8"))
77
+ return {
78
+ "X-TinyPlace-Date": signed_at,
79
+ "X-TinyPlace-Nonce": nonce,
80
+ "X-TinyPlace-Public-Key": public_key_base64,
81
+ "X-TinyPlace-Signature": _to_base64(signature),
82
+ }
83
+
84
+
85
+ async def sign_canonical_payload(signer: Signer, payload: str) -> str:
86
+ return _to_base64(await signer.sign(payload.encode("utf-8")))
87
+
88
+
89
+ async def sign_fresh_canonical_payload(signer: Signer, payload: str) -> str:
90
+ signed_at = timestamp()
91
+ nonce = generate_nonce()
92
+ signature = await signer.sign(f"{payload}\n{signed_at}\n{nonce}".encode("utf-8"))
93
+ return f"v1:{base64_url(signed_at)}:{base64_url(nonce)}:{_to_base64(signature)}"
tinyplace/client.py ADDED
@@ -0,0 +1,52 @@
1
+ from __future__ import annotations
2
+
3
+ import aiohttp
4
+
5
+ from .api import DirectoryApi, DocsApi, KeysApi, MessagesApi, PaymentsApi, RegistryApi, SearchApi
6
+ from .auth import AdminSigningOptions
7
+ from .http import AuthInvalidHook, HttpClient
8
+ from .signer import Signer
9
+ from .types import Json
10
+
11
+
12
+ class TinyPlaceClient:
13
+ def __init__(
14
+ self,
15
+ *,
16
+ base_url: str,
17
+ signer: Signer | None = None,
18
+ admin_signer: Signer | None = None,
19
+ admin: AdminSigningOptions | None = None,
20
+ session: aiohttp.ClientSession | None = None,
21
+ on_auth_invalid: AuthInvalidHook | None = None,
22
+ ) -> None:
23
+ self.http = HttpClient(
24
+ base_url=base_url,
25
+ signer=signer,
26
+ admin_signer=admin_signer,
27
+ admin=admin,
28
+ session=session,
29
+ on_auth_invalid=on_auth_invalid,
30
+ )
31
+ self.registry = RegistryApi(self.http, signer)
32
+ self.keys = KeysApi(self.http)
33
+ self.messages = MessagesApi(self.http)
34
+ self.directory = DirectoryApi(self.http)
35
+ self.payments = PaymentsApi(self.http, signer)
36
+ self.search = SearchApi(self.http)
37
+ self.docs = DocsApi(self.http)
38
+
39
+ async def __aenter__(self) -> "TinyPlaceClient":
40
+ return self
41
+
42
+ async def __aexit__(self, *_exc: object) -> None:
43
+ await self.close()
44
+
45
+ async def close(self) -> None:
46
+ await self.http.close()
47
+
48
+ async def healthz(self) -> Json:
49
+ return await self.http.get("/healthz")
50
+
51
+ async def spec(self) -> Json:
52
+ return await self.http.get("/spec")
tinyplace/crypto.py ADDED
@@ -0,0 +1,62 @@
1
+ from __future__ import annotations
2
+
3
+ import base64
4
+ import hashlib
5
+ import json
6
+ from typing import Any
7
+
8
+ BASE58_ALPHABET = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz"
9
+
10
+
11
+ def public_key_to_base64(public_key: bytes) -> str:
12
+ return base64.b64encode(public_key).decode("ascii")
13
+
14
+
15
+ def public_key_to_solana_address(public_key: bytes) -> str:
16
+ value = int.from_bytes(public_key, "big")
17
+ encoded = ""
18
+ while value > 0:
19
+ value, digit = divmod(value, 58)
20
+ encoded = BASE58_ALPHABET[digit] + encoded
21
+
22
+ leading_zeroes = 0
23
+ for byte in public_key:
24
+ if byte != 0:
25
+ break
26
+ leading_zeroes += 1
27
+ return ("1" * leading_zeroes) + encoded if encoded else "1"
28
+
29
+
30
+ def derive_crypto_id(public_key: bytes) -> str:
31
+ return public_key_to_solana_address(public_key)
32
+
33
+
34
+ def sha256_hex(data: bytes | str) -> str:
35
+ if isinstance(data, str):
36
+ data = data.encode("utf-8")
37
+ return hashlib.sha256(data).hexdigest()
38
+
39
+
40
+ def canonical_payload(action: str, fields: dict[str, Any]) -> str:
41
+ return _stable_json({"action": action, "fields": fields})
42
+
43
+
44
+ def _stable_json(value: Any) -> str:
45
+ return json.dumps(value, sort_keys=True, separators=(",", ":"), ensure_ascii=False)
46
+
47
+
48
+ def base64_url(value: str) -> str:
49
+ return base64.urlsafe_b64encode(value.encode("utf-8")).decode("ascii").rstrip("=")
50
+
51
+
52
+ def decode_base58(value: str) -> bytes:
53
+ decoded = 0
54
+ for char in value:
55
+ digit = BASE58_ALPHABET.find(char)
56
+ if digit == -1:
57
+ raise ValueError(f"Invalid base58 character: {char}")
58
+ decoded = decoded * 58 + digit
59
+
60
+ raw = decoded.to_bytes((decoded.bit_length() + 7) // 8, "big") if decoded else b""
61
+ leading_zeroes = len(value) - len(value.lstrip("1"))
62
+ return (b"\x00" * leading_zeroes) + raw
tinyplace/http.py ADDED
@@ -0,0 +1,273 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from dataclasses import dataclass
5
+ from typing import Any, Awaitable, Callable
6
+ from urllib.parse import quote, urlencode
7
+
8
+ import aiohttp
9
+
10
+ from .auth import AdminSigningOptions, sign_admin_request, sign_directory_write, sign_request
11
+ from .signer import Signer
12
+ from .types import Headers, Json, Query
13
+
14
+ AuthInvalidHook = Callable[[int, Json], None]
15
+
16
+
17
+ @dataclass(frozen=True)
18
+ class PaymentChallenge:
19
+ scheme: str | None = None
20
+ network: str | None = None
21
+ asset: str | None = None
22
+ amount: str | None = None
23
+ from_: str | None = None
24
+ to: str | None = None
25
+ nonce: str | None = None
26
+ expires_at: str | None = None
27
+ signature: str | None = None
28
+ metadata: dict[str, str] | None = None
29
+
30
+
31
+ @dataclass(frozen=True)
32
+ class PaymentRequiredChallenge:
33
+ payment: dict[str, Any]
34
+ error: str | None = None
35
+
36
+
37
+ class TinyPlaceError(Exception):
38
+ def __init__(
39
+ self,
40
+ status: int,
41
+ body: Json,
42
+ message: str | None = None,
43
+ *,
44
+ headers: Headers | None = None,
45
+ payment_required: PaymentRequiredChallenge | None = None,
46
+ ) -> None:
47
+ super().__init__(message or f"HTTP {status}")
48
+ self.status = status
49
+ self.body = body
50
+ self.headers = headers or {}
51
+ self.payment_required = payment_required or _payment_required_from_body(body)
52
+
53
+
54
+ class HttpClient:
55
+ def __init__(
56
+ self,
57
+ *,
58
+ base_url: str,
59
+ signer: Signer | None = None,
60
+ admin_signer: Signer | None = None,
61
+ admin: AdminSigningOptions | None = None,
62
+ session: aiohttp.ClientSession | None = None,
63
+ on_auth_invalid: AuthInvalidHook | None = None,
64
+ ) -> None:
65
+ self.base_url = base_url.rstrip("/")
66
+ self.signer = signer
67
+ self.public_key_base64 = signer.public_key_base64 if signer else None
68
+ self.admin_signer = admin_signer
69
+ self.admin = admin or AdminSigningOptions()
70
+ self._session = session
71
+ self._owns_session = session is None
72
+ self._on_auth_invalid = on_auth_invalid
73
+
74
+ async def close(self) -> None:
75
+ if self._session and self._owns_session:
76
+ await self._session.close()
77
+ self._session = None
78
+
79
+ def signing_public_key(self) -> str | None:
80
+ return self.public_key_base64
81
+
82
+ async def get(self, path: str, query: Query = None) -> Json:
83
+ return await self._request("GET", path, query=query)
84
+
85
+ async def get_auth(self, path: str, query: Query = None) -> Json:
86
+ return await self._request("GET", path, query=query, auth="signed")
87
+
88
+ async def get_admin(self, path: str, query: Query = None) -> Json:
89
+ return await self._request("GET", path, query=query, auth="admin")
90
+
91
+ async def get_text(self, path: str, query: Query = None) -> str:
92
+ return await self._request("GET", path, query=query, response_type="text")
93
+
94
+ async def get_directory_auth(self, path: str, query: Query = None) -> Json:
95
+ return await self._request("GET", path, query=query, auth="directory")
96
+
97
+ async def get_directory_auth_as(self, path: str, actor: str, query: Query = None) -> Json:
98
+ return await self._request("GET", path, query=query, auth="directory", actor=actor)
99
+
100
+ async def get_agent_auth(self, path: str, query: Query = None) -> Json:
101
+ return await self._request("GET", path, query=query, auth="agent")
102
+
103
+ async def post(self, path: str, body: Json = None) -> Json:
104
+ return await self._request("POST", path, body=body, auth="signed")
105
+
106
+ async def post_public(self, path: str, body: Json = None) -> Json:
107
+ return await self._request("POST", path, body=body)
108
+
109
+ async def post_admin(self, path: str, body: Json = None) -> Json:
110
+ return await self._request("POST", path, body=body, auth="admin")
111
+
112
+ async def post_directory_auth(self, path: str, body: Json = None) -> Json:
113
+ return await self._request("POST", path, body=body, auth="directory")
114
+
115
+ async def post_directory_auth_as(self, path: str, actor: str, body: Json = None) -> Json:
116
+ return await self._request("POST", path, body=body, auth="directory", actor=actor)
117
+
118
+ async def put(self, path: str, body: Json = None) -> Json:
119
+ return await self._request("PUT", path, body=body, auth="signed")
120
+
121
+ async def put_directory_auth(self, path: str, body: Json = None) -> Json:
122
+ return await self._request("PUT", path, body=body, auth="directory")
123
+
124
+ async def put_directory_auth_as(self, path: str, actor: str, body: Json = None) -> Json:
125
+ return await self._request("PUT", path, body=body, auth="directory", actor=actor)
126
+
127
+ async def put_agent_auth(self, path: str, body: Json = None) -> Json:
128
+ return await self._request("PUT", path, body=body, auth="agent")
129
+
130
+ async def delete(self, path: str, body: Json = None) -> Json:
131
+ return await self._request("DELETE", path, body=body, auth="signed")
132
+
133
+ async def delete_public(
134
+ self,
135
+ path: str,
136
+ body: Json = None,
137
+ headers: Headers | None = None,
138
+ ) -> Json:
139
+ return await self._request("DELETE", path, body=body, headers=headers)
140
+
141
+ async def delete_directory_auth(self, path: str, body: Json = None) -> Json:
142
+ return await self._request("DELETE", path, body=body, auth="directory")
143
+
144
+ async def delete_directory_auth_as(self, path: str, actor: str, body: Json = None) -> Json:
145
+ return await self._request("DELETE", path, body=body, auth="directory", actor=actor)
146
+
147
+ async def delete_agent_auth(self, path: str, body: Json = None) -> Json:
148
+ return await self._request("DELETE", path, body=body, auth="agent")
149
+
150
+ async def _request(
151
+ self,
152
+ method: str,
153
+ path: str,
154
+ *,
155
+ query: Query = None,
156
+ body: Json = None,
157
+ auth: str | None = None,
158
+ actor: str | None = None,
159
+ headers: Headers | None = None,
160
+ response_type: str = "json",
161
+ ) -> Json:
162
+ query_string = _build_query(query)
163
+ request_uri = f"{path}{query_string}"
164
+ url = f"{self.base_url}{request_uri}"
165
+ body_text = "" if body is None else json.dumps(body, separators=(",", ":"))
166
+ request_headers = {"Content-Type": "application/json", **(headers or {})}
167
+
168
+ await self._apply_auth(request_headers, auth, method, request_uri, body_text, actor)
169
+ session = self._get_session()
170
+ response = await session.request(
171
+ method,
172
+ url,
173
+ headers=request_headers,
174
+ data=body_text or None,
175
+ )
176
+
177
+ if response.status < 200 or response.status >= 300:
178
+ await self._raise_error(path, response)
179
+
180
+ if response.status == 204:
181
+ return None
182
+ if response_type == "raw":
183
+ return response
184
+ if response_type == "text":
185
+ return await response.text()
186
+ text = await response.text()
187
+ return None if text == "" else json.loads(text)
188
+
189
+ async def _apply_auth(
190
+ self,
191
+ headers: Headers,
192
+ auth: str | None,
193
+ method: str,
194
+ request_uri: str,
195
+ body: str,
196
+ actor: str | None,
197
+ ) -> None:
198
+ if auth == "admin" and self.admin_signer:
199
+ headers.update(
200
+ await sign_admin_request(self.admin_signer, method, request_uri, body, self.admin)
201
+ )
202
+ elif auth in ("directory", "agent") and self.signer and self.public_key_base64:
203
+ headers.update(
204
+ await sign_directory_write(
205
+ self.signer,
206
+ self.public_key_base64,
207
+ method,
208
+ request_uri,
209
+ body,
210
+ )
211
+ )
212
+ headers["X-Agent-ID"] = self.signer.agent_id if auth == "agent" else actor or self.public_key_base64
213
+ elif auth == "signed" and self.signer:
214
+ headers.update(await sign_request(self.signer, body))
215
+
216
+ async def _raise_error(self, path: str, response: Any) -> None:
217
+ text = await response.text()
218
+ try:
219
+ body = json.loads(text)
220
+ except json.JSONDecodeError:
221
+ body = text
222
+ headers = {str(k): str(v) for k, v in getattr(response, "headers", {}).items()}
223
+ if response.status in (401, 403) and self._on_auth_invalid:
224
+ self._on_auth_invalid(response.status, body)
225
+ raise TinyPlaceError(
226
+ response.status,
227
+ body,
228
+ f"HTTP {response.status}: {path}",
229
+ headers=headers,
230
+ payment_required=_payment_required_from_header(headers),
231
+ )
232
+
233
+ def _get_session(self) -> Any:
234
+ if self._session is None:
235
+ self._session = aiohttp.ClientSession()
236
+ return self._session
237
+
238
+
239
+ def encode(value: str) -> str:
240
+ return quote(value, safe="")
241
+
242
+
243
+ def _build_query(query: Query) -> str:
244
+ if not query:
245
+ return ""
246
+ pairs: list[tuple[str, str]] = []
247
+ for key, value in query.items():
248
+ if value is None:
249
+ continue
250
+ if isinstance(value, list):
251
+ pairs.extend((key, str(item)) for item in value)
252
+ else:
253
+ pairs.append((key, str(value)))
254
+ return f"?{urlencode(pairs)}" if pairs else ""
255
+
256
+
257
+ def _payment_required_from_body(body: Json) -> PaymentRequiredChallenge | None:
258
+ if isinstance(body, dict) and isinstance(body.get("payment"), dict):
259
+ return PaymentRequiredChallenge(error=body.get("error"), payment=body["payment"])
260
+ return None
261
+
262
+
263
+ def _payment_required_from_header(headers: Headers) -> PaymentRequiredChallenge | None:
264
+ value = headers.get("X-Payment-Required") or headers.get("x-payment-required")
265
+ if not value:
266
+ return None
267
+ try:
268
+ parsed = json.loads(value)
269
+ except json.JSONDecodeError:
270
+ return None
271
+ if isinstance(parsed, dict) and isinstance(parsed.get("payment"), dict):
272
+ return PaymentRequiredChallenge(error=parsed.get("error"), payment=parsed["payment"])
273
+ return None
tinyplace/signer.py ADDED
@@ -0,0 +1,51 @@
1
+ from __future__ import annotations
2
+
3
+ from abc import ABC, abstractmethod
4
+
5
+ from nacl.signing import SigningKey
6
+
7
+ from .crypto import decode_base58, derive_crypto_id, public_key_to_base64
8
+
9
+
10
+ class Signer(ABC):
11
+ """Abstract signing strategy for agent, directory, and admin auth."""
12
+
13
+ agent_id: str
14
+ public_key_base64: str
15
+
16
+ @abstractmethod
17
+ async def sign(self, data: bytes) -> bytes:
18
+ """Return an Ed25519 signature over ``data``."""
19
+
20
+
21
+ class LocalSigner(Signer):
22
+ """Local Ed25519 signer backed by PyNaCl."""
23
+
24
+ def __init__(self, signing_key: SigningKey) -> None:
25
+ self._signing_key = signing_key
26
+ self.public_key = bytes(signing_key.verify_key)
27
+ self.agent_id = derive_crypto_id(self.public_key)
28
+ self.public_key_base64 = public_key_to_base64(self.public_key)
29
+
30
+ @classmethod
31
+ def generate(cls) -> "LocalSigner":
32
+ return cls(SigningKey.generate())
33
+
34
+ @classmethod
35
+ def from_seed(cls, seed: bytes) -> "LocalSigner":
36
+ if len(seed) != 32:
37
+ raise ValueError(f"Ed25519 seed must be 32 bytes, got {len(seed)}")
38
+ return cls(SigningKey(seed))
39
+
40
+ @classmethod
41
+ def from_solana_secret_key(cls, secret_key: str | bytes) -> "LocalSigner":
42
+ secret = decode_base58(secret_key) if isinstance(secret_key, str) else secret_key
43
+ if len(secret) not in (32, 64):
44
+ raise ValueError(f"Solana secret key must be 32 or 64 bytes, got {len(secret)}")
45
+ signer = cls.from_seed(secret[:32])
46
+ if len(secret) == 64 and signer.public_key != secret[32:]:
47
+ raise ValueError("Solana secret key public key does not match seed")
48
+ return signer
49
+
50
+ async def sign(self, data: bytes) -> bytes:
51
+ return bytes(self._signing_key.sign(data).signature)