synpath 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.
- synpath/__init__.py +183 -0
- synpath/__main__.py +66 -0
- synpath/base.py +723 -0
- synpath/bucket.py +154 -0
- synpath/client.py +356 -0
- synpath/engine/__init__.py +37 -0
- synpath/engine/__main__.py +354 -0
- synpath/engine/alerts.py +170 -0
- synpath/engine/engine.py +888 -0
- synpath/engine/eod.py +154 -0
- synpath/engine/events.py +140 -0
- synpath/engine/fair_values.py +117 -0
- synpath/engine/feeds.py +220 -0
- synpath/engine/journal.py +907 -0
- synpath/engine/ledger.py +353 -0
- synpath/engine/orders/__init__.py +42 -0
- synpath/engine/orders/base.py +441 -0
- synpath/engine/orders/day.py +72 -0
- synpath/engine/orders/iceberg.py +121 -0
- synpath/engine/orders/manager.py +223 -0
- synpath/engine/orders/oco.py +255 -0
- synpath/engine/orders/peg.py +168 -0
- synpath/engine/orders/routed.py +496 -0
- synpath/engine/orders/stop.py +240 -0
- synpath/engine/orders/taker.py +187 -0
- synpath/engine/orders/twap.py +190 -0
- synpath/engine/paper.py +532 -0
- synpath/engine/reconcile.py +279 -0
- synpath/engine/risk.py +403 -0
- synpath/engine/router.py +261 -0
- synpath/errors.py +98 -0
- synpath/history.py +71 -0
- synpath/hosted.py +86 -0
- synpath/hosted_auth.py +201 -0
- synpath/ids.py +61 -0
- synpath/kalshi.py +1378 -0
- synpath/matching.py +86 -0
- synpath/polymarket.py +1004 -0
- synpath/polymarket_us.py +989 -0
- synpath/remote.py +195 -0
- synpath/server/__init__.py +98 -0
- synpath/server/__main__.py +118 -0
- synpath/server/api.py +439 -0
- synpath/server/errors.py +87 -0
- synpath/server/local.py +96 -0
- synpath/server/models.py +75 -0
- synpath/server/serve.py +236 -0
- synpath/server/store.py +363 -0
- synpath/server/trading.py +764 -0
- synpath/trading/__init__.py +79 -0
- synpath/trading/__main__.py +69 -0
- synpath/trading/base.py +126 -0
- synpath/trading/credentials.py +400 -0
- synpath/trading/errors.py +94 -0
- synpath/trading/init.py +233 -0
- synpath/trading/instruments.py +162 -0
- synpath/trading/kalshi.py +957 -0
- synpath/trading/limiter.py +177 -0
- synpath/trading/money.py +172 -0
- synpath/trading/polymarket.py +1362 -0
- synpath/trading/polymarket_signing.py +478 -0
- synpath/trading/polymarket_us.py +705 -0
- synpath/trading/polymarket_us_exchange.py +825 -0
- synpath/trading/types.py +414 -0
- synpath/types.py +608 -0
- synpath/ws/__init__.py +55 -0
- synpath/ws/base.py +544 -0
- synpath/ws/grpc.py +578 -0
- synpath/ws/kalshi.py +418 -0
- synpath/ws/polymarket.py +430 -0
- synpath/ws/polymarket_us.py +299 -0
- synpath/ws/polymarket_us_exchange.py +754 -0
- synpath-0.1.0.dist-info/METADATA +224 -0
- synpath-0.1.0.dist-info/RECORD +77 -0
- synpath-0.1.0.dist-info/WHEEL +4 -0
- synpath-0.1.0.dist-info/entry_points.txt +2 -0
- synpath-0.1.0.dist-info/licenses/LICENSE +21 -0
synpath/server/models.py
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"""Wire shapes the HTTP layer adds on top of the library's types.
|
|
2
|
+
|
|
3
|
+
Everything else on the wire is a library type serialized as-is. These three
|
|
4
|
+
exist because HTTP needs to say things an in-process call does not: where the
|
|
5
|
+
next page starts, what went wrong, and what a venue can do.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from typing import Any, Generic, TypeVar
|
|
10
|
+
|
|
11
|
+
from pydantic import BaseModel, Field
|
|
12
|
+
|
|
13
|
+
T = TypeVar("T")
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class PageResponse(BaseModel, Generic[T]):
|
|
17
|
+
"""A page of results plus where the next one starts.
|
|
18
|
+
|
|
19
|
+
Lists are enveloped and single resources are not. The envelope earns its
|
|
20
|
+
place only when there is a cursor to carry; wrapping a single market in
|
|
21
|
+
`{"data": ...}` would be ceremony that every generated client has to unwrap
|
|
22
|
+
for nothing.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
data: list[T]
|
|
26
|
+
next_cursor: str | None = Field(
|
|
27
|
+
default=None,
|
|
28
|
+
description=(
|
|
29
|
+
"Pass as `cursor` to fetch the next page. `null` means this venue "
|
|
30
|
+
"returned no continuation — for a search result that is normal, "
|
|
31
|
+
"since search is not paged."
|
|
32
|
+
),
|
|
33
|
+
)
|
|
34
|
+
count: int = Field(description="Rows in `data`, for convenience.")
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class ErrorDetail(BaseModel):
|
|
38
|
+
code: str = Field(description="Machine-readable, snake_case: `insufficient_funds`, `risk_rejected`, "
|
|
39
|
+
"`unauthorized`, `validation_error`, ...")
|
|
40
|
+
message: str = Field(description="For people. Do not branch on it; branch on `code`.")
|
|
41
|
+
details: dict[str, Any] = Field(
|
|
42
|
+
default_factory=dict,
|
|
43
|
+
description=(
|
|
44
|
+
"Always `venue` (the venue that answered, or null) and `retryable` (true when the request never got a "
|
|
45
|
+
"verdict: timeout, rate limit, venue down). A risk refusal adds `rule`; a malformed request adds "
|
|
46
|
+
"`errors`, one entry per field; a rate limit may add `retry_after`."
|
|
47
|
+
),
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class ErrorBody(BaseModel):
|
|
52
|
+
"""What every non-2xx response carries, on every route of both apps:
|
|
53
|
+
`{"error": {"code", "message", "details"}}`."""
|
|
54
|
+
|
|
55
|
+
error: ErrorDetail
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class VenueInfo(BaseModel):
|
|
59
|
+
"""What one venue is and what it can do.
|
|
60
|
+
|
|
61
|
+
A client should read `has` before calling rather than discovering a gap
|
|
62
|
+
through a 501. The values mirror the library's `Exchange.has` exactly:
|
|
63
|
+
`true`, `false` or `"partial"`.
|
|
64
|
+
"""
|
|
65
|
+
|
|
66
|
+
id: str
|
|
67
|
+
name: str
|
|
68
|
+
book_model: str = Field(
|
|
69
|
+
description=(
|
|
70
|
+
"`shared_complement` when both sides of a market read one book "
|
|
71
|
+
"(Kalshi), `native_per_outcome` when each side owns its own "
|
|
72
|
+
"(Polymarket)."
|
|
73
|
+
)
|
|
74
|
+
)
|
|
75
|
+
has: dict[str, object]
|
synpath/server/serve.py
ADDED
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
"""`synpath serve`: everything a self-hosted synpath runs, in one process.
|
|
2
|
+
|
|
3
|
+
```bash
|
|
4
|
+
synpath serve # every venue whose credentials are in the environment or .env
|
|
5
|
+
synpath serve --config engine.toml # the venues, risk rules and journal the file names
|
|
6
|
+
```
|
|
7
|
+
|
|
8
|
+
One process, one event loop:
|
|
9
|
+
|
|
10
|
+
* the **engine**, with every loop it publishes (`Engine.background()`): the
|
|
11
|
+
journal's lease, the in-doubt sweep, the poll, the managed-order clock;
|
|
12
|
+
* the **feeds** (`synpath.engine.feeds`): each venue's market and account
|
|
13
|
+
streams, so stops see prices and parents hear their children fill;
|
|
14
|
+
* **reconciliation**, the **end-of-day** close, **halt/resume** requests
|
|
15
|
+
written by `synpath halt`, and a periodic status line;
|
|
16
|
+
* the **HTTP API**: the read app at `/` and the trading app at `/trading`,
|
|
17
|
+
with `/trading/ws/events`.
|
|
18
|
+
|
|
19
|
+
With no configuration it trades every venue whose credentials it finds and
|
|
20
|
+
still serves market data if it finds none. The first start on a new control
|
|
21
|
+
database creates an owner access token; that token makes every other.
|
|
22
|
+
`SIGINT`/`SIGTERM` stop the HTTP server, apply the halt policy (cancel
|
|
23
|
+
everything resting, by default), close the streams, release the lease.
|
|
24
|
+
"""
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
import argparse
|
|
28
|
+
import asyncio
|
|
29
|
+
import logging
|
|
30
|
+
import sys
|
|
31
|
+
from dataclasses import dataclass, field
|
|
32
|
+
from typing import Any
|
|
33
|
+
|
|
34
|
+
from . import local
|
|
35
|
+
|
|
36
|
+
log = logging.getLogger("synpath.serve")
|
|
37
|
+
|
|
38
|
+
VENUES = ("kalshi", "polymarket", "polymarket_us")
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def venues_config(config: dict[str, Any], credentials: dict[str, Any]) -> dict[str, Any]:
|
|
42
|
+
"""The configuration's venues if it names any; otherwise every venue
|
|
43
|
+
whose credentials are present. Nothing to write for the common case."""
|
|
44
|
+
if config.get("venues"):
|
|
45
|
+
return config
|
|
46
|
+
return {**config, "venues": {venue: {} for venue in VENUES if credentials.get(venue) is not None}}
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@dataclass
|
|
50
|
+
class Stack:
|
|
51
|
+
engine: Any
|
|
52
|
+
feeds: Any | None
|
|
53
|
+
store: Any
|
|
54
|
+
app: Any
|
|
55
|
+
reconciler: Any
|
|
56
|
+
eod: Any
|
|
57
|
+
config: dict[str, Any]
|
|
58
|
+
owner_key: str | None = None
|
|
59
|
+
"""The owner access token created on this start; `None` if the control database
|
|
60
|
+
already had users."""
|
|
61
|
+
local_key: str | None = None
|
|
62
|
+
"""The access token on record in the local registry for this server's address."""
|
|
63
|
+
extra_tasks: dict[str, Any] = field(default_factory=dict)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
async def build(
|
|
67
|
+
*,
|
|
68
|
+
config: dict[str, Any] | None = None,
|
|
69
|
+
journal: str = "synpath.db",
|
|
70
|
+
control: str = "synpath-control.db",
|
|
71
|
+
dotenv: str | None = None,
|
|
72
|
+
streams: bool = True,
|
|
73
|
+
adapters: dict[str, Any] | None = None,
|
|
74
|
+
stream_map: dict[str, Any] | None = None,
|
|
75
|
+
host: str = "127.0.0.1",
|
|
76
|
+
port: int = 8000,
|
|
77
|
+
home_dir: str | None = None,
|
|
78
|
+
) -> Stack:
|
|
79
|
+
"""Assemble and start everything except the HTTP server. `adapters` and
|
|
80
|
+
`stream_map` let a test inject paper venues and fake streams."""
|
|
81
|
+
from ..engine.__main__ import build_adapters, risk_from
|
|
82
|
+
from ..engine.engine import Engine, EngineConfig
|
|
83
|
+
from ..engine.eod import EndOfDay
|
|
84
|
+
from ..engine.feeds import Feeds, default_streams
|
|
85
|
+
from ..engine.reconcile import Reconciler
|
|
86
|
+
from ..trading.credentials import load_credentials
|
|
87
|
+
from .api import create_app
|
|
88
|
+
from .store import ControlStore
|
|
89
|
+
from .trading import create_trading_app
|
|
90
|
+
|
|
91
|
+
config = dict(config or {})
|
|
92
|
+
credentials = load_credentials(dotenv=dotenv) if adapters is None or (streams and stream_map is None) else {}
|
|
93
|
+
if adapters is None:
|
|
94
|
+
config = venues_config(config, credentials)
|
|
95
|
+
adapters = build_adapters(config, dotenv=dotenv) if config.get("venues") else {}
|
|
96
|
+
if not adapters:
|
|
97
|
+
log.warning("synpath serve: no venue credentials found; serving market data only. "
|
|
98
|
+
"Run `synpath doctor` to see what is missing.")
|
|
99
|
+
engine_config = EngineConfig(
|
|
100
|
+
journal_path=config.get("journal", journal),
|
|
101
|
+
poll_interval_s=float(config.get("poll_interval_s", 5)),
|
|
102
|
+
reconcile_interval_s=float(config.get("reconcile_interval_s", 60)),
|
|
103
|
+
sweep_interval_s=float(config.get("sweep_interval_s", 10)),
|
|
104
|
+
in_doubt_timeout_s=float(config.get("in_doubt_timeout_s", 20)),
|
|
105
|
+
halt_policy=config.get("halt_policy", "cancel"),
|
|
106
|
+
)
|
|
107
|
+
engine = Engine(adapters, engine_config, risk=risk_from(config))
|
|
108
|
+
feeds = None
|
|
109
|
+
if streams and config.get("streams", True):
|
|
110
|
+
feeds = Feeds(engine, stream_map if stream_map is not None else default_streams(adapters, credentials))
|
|
111
|
+
reconciler = Reconciler(engine, orphan_policy=config.get("orphan_policy", "report"))
|
|
112
|
+
eod = EndOfDay(engine, hour_utc=int(config.get("eod_hour_utc", 0)))
|
|
113
|
+
|
|
114
|
+
await engine.start()
|
|
115
|
+
store = await ControlStore(config.get("control", control)).open()
|
|
116
|
+
owner_key = None
|
|
117
|
+
if not await store.has_users():
|
|
118
|
+
_, issued = await store.bootstrap("owner")
|
|
119
|
+
owner_key = issued.secret
|
|
120
|
+
# Leave the token where clients on this machine find it (synpath.server.local).
|
|
121
|
+
stack_key = local.remember(host, port, control=config.get("control", control),
|
|
122
|
+
journal=engine_config.journal_path, key=owner_key, home_dir=home_dir)
|
|
123
|
+
if feeds is not None:
|
|
124
|
+
await feeds.start()
|
|
125
|
+
|
|
126
|
+
app = create_app()
|
|
127
|
+
app.mount("/trading", create_trading_app(engine, store))
|
|
128
|
+
app.state.trading_path = "/trading"
|
|
129
|
+
return Stack(engine=engine, feeds=feeds, store=store, app=app, reconciler=reconciler, eod=eod,
|
|
130
|
+
config=config, owner_key=owner_key, local_key=stack_key)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def background(stack: Stack) -> dict[str, Any]:
|
|
134
|
+
"""Every coroutine the stack needs running beside the HTTP server."""
|
|
135
|
+
from ..engine.__main__ import _control_loop, _reconcile_loop, _status_loop
|
|
136
|
+
|
|
137
|
+
engine = stack.engine
|
|
138
|
+
loops: dict[str, Any] = dict(engine.background())
|
|
139
|
+
if stack.feeds is not None:
|
|
140
|
+
loops.update(stack.feeds.background())
|
|
141
|
+
loops["reconcile"] = _reconcile_loop(engine, stack.reconciler, engine.config.reconcile_interval_s)
|
|
142
|
+
loops["control"] = _control_loop(engine, asyncio.Event())
|
|
143
|
+
loops["eod"] = stack.eod.loop()
|
|
144
|
+
loops["status"] = _status_loop(engine, float(stack.config.get("status_interval_s", 60)))
|
|
145
|
+
return loops
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
async def shutdown(stack: Stack) -> None:
|
|
149
|
+
engine = stack.engine
|
|
150
|
+
if stack.config.get("halt_on_exit", True) and engine.running:
|
|
151
|
+
try:
|
|
152
|
+
await engine.halt("the server is shutting down", policy=engine.config.halt_policy)
|
|
153
|
+
except Exception:
|
|
154
|
+
log.exception("synpath serve: halting on exit failed")
|
|
155
|
+
if stack.feeds is not None:
|
|
156
|
+
await stack.feeds.close()
|
|
157
|
+
await engine.stop()
|
|
158
|
+
await stack.store.close()
|
|
159
|
+
for adapter in engine.adapters.values():
|
|
160
|
+
try:
|
|
161
|
+
await adapter.close()
|
|
162
|
+
except Exception:
|
|
163
|
+
pass
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
async def serve(args: argparse.Namespace) -> int:
|
|
167
|
+
import uvicorn
|
|
168
|
+
|
|
169
|
+
from ..engine.__main__ import load_config
|
|
170
|
+
from ..engine.journal import LeaseLost
|
|
171
|
+
|
|
172
|
+
logging.basicConfig(level=getattr(logging, args.log_level.upper(), logging.INFO),
|
|
173
|
+
format="%(asctime)s %(levelname)s %(message)s")
|
|
174
|
+
config = load_config(args.config) if args.config else {}
|
|
175
|
+
try:
|
|
176
|
+
stack = await build(config=config, journal=args.journal, control=args.control, dotenv=args.dotenv,
|
|
177
|
+
streams=not args.no_streams, host=args.host, port=args.port)
|
|
178
|
+
except LeaseLost as exc:
|
|
179
|
+
print(str(exc), file=sys.stderr)
|
|
180
|
+
return 3
|
|
181
|
+
if args.host in local.LOOPBACK:
|
|
182
|
+
# On this machine the token is found, not typed: the library reads the
|
|
183
|
+
# registry. Only say where it is.
|
|
184
|
+
if stack.owner_key:
|
|
185
|
+
print(f"\n A new control database. Its owner access token is in {local.registry_path()} (readable by you only);\n"
|
|
186
|
+
" synpath.Client(server=...) and the TypeScript client on this machine use it automatically.\n"
|
|
187
|
+
" To reach this server from elsewhere, send that token as `Authorization: Bearer <token>`.\n")
|
|
188
|
+
elif stack.owner_key:
|
|
189
|
+
print("\n A new control database: this is the owner access token. It is shown once.\n"
|
|
190
|
+
f" {stack.owner_key}\n"
|
|
191
|
+
" Send it as `Authorization: Bearer <token>` to /trading; it can issue every other token.\n")
|
|
192
|
+
if args.host not in ("127.0.0.1", "localhost", "::1"):
|
|
193
|
+
print(f"warning: binding to {args.host}. The market-data routes at / have no authentication; "
|
|
194
|
+
"/trading requires an access token. Put a proxy with TLS in front of anything public.")
|
|
195
|
+
|
|
196
|
+
server = uvicorn.Server(uvicorn.Config(stack.app, host=args.host, port=args.port, log_level=args.log_level.lower()))
|
|
197
|
+
loop = asyncio.get_running_loop()
|
|
198
|
+
tasks = [loop.create_task(coro, name=f"synpath-{name}") for name, coro in background(stack).items()]
|
|
199
|
+
|
|
200
|
+
def _died(task: asyncio.Task) -> None:
|
|
201
|
+
if not task.cancelled() and task.exception() is not None:
|
|
202
|
+
log.error("synpath serve: %s failed: %s; stopping", task.get_name(), task.exception())
|
|
203
|
+
server.should_exit = True
|
|
204
|
+
for task in tasks:
|
|
205
|
+
task.add_done_callback(_died)
|
|
206
|
+
|
|
207
|
+
engine = stack.engine
|
|
208
|
+
print(f"synpath serving on http://{args.host}:{args.port} (trading at /trading), "
|
|
209
|
+
f"venues: {', '.join(sorted(engine.adapters)) or 'none'}, journal: {engine.config.journal_path}")
|
|
210
|
+
try:
|
|
211
|
+
await server.serve()
|
|
212
|
+
finally:
|
|
213
|
+
for task in tasks:
|
|
214
|
+
task.cancel()
|
|
215
|
+
await asyncio.gather(*tasks, return_exceptions=True)
|
|
216
|
+
await shutdown(stack)
|
|
217
|
+
print("synpath stopped; the lease is released")
|
|
218
|
+
return 0
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def parser() -> argparse.ArgumentParser:
|
|
222
|
+
p = argparse.ArgumentParser(prog="synpath serve", description="Run the engine, the venues' streams and the "
|
|
223
|
+
"HTTP API (market data at /, trading at /trading) in one process.")
|
|
224
|
+
p.add_argument("--host", default="127.0.0.1", help="default: 127.0.0.1")
|
|
225
|
+
p.add_argument("--port", type=int, default=8000, help="default: 8000")
|
|
226
|
+
p.add_argument("--config", default=None, help="TOML or JSON: venues, risk, journal. Optional")
|
|
227
|
+
p.add_argument("--journal", default="synpath.db", help="the engine's journal. Default: synpath.db")
|
|
228
|
+
p.add_argument("--control", default="synpath-control.db", help="users and keys. Default: synpath-control.db")
|
|
229
|
+
p.add_argument("--dotenv", default=None, help="a .env file with venue credentials")
|
|
230
|
+
p.add_argument("--no-streams", action="store_true", help="do not subscribe the venues' streams; poll only")
|
|
231
|
+
p.add_argument("--log-level", default="info")
|
|
232
|
+
return p
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def main(argv: list[str] | None = None) -> int:
|
|
236
|
+
return asyncio.run(serve(parser().parse_args(argv)))
|
synpath/server/store.py
ADDED
|
@@ -0,0 +1,363 @@
|
|
|
1
|
+
"""Who may do what, and a record of every time that changed.
|
|
2
|
+
|
|
3
|
+
The read API needs no accounts. The moment the server can place an order it
|
|
4
|
+
needs to know who is asking, what they are allowed to touch, and -- when
|
|
5
|
+
somebody later asks why a key could trade an account it should not have --
|
|
6
|
+
what the permissions were at the time and who changed them.
|
|
7
|
+
|
|
8
|
+
Three tables and one rule:
|
|
9
|
+
|
|
10
|
+
**Keys are stored as hashes.** A key is shown once, when it is issued. What
|
|
11
|
+
is kept is `sha256` of it, so a stolen database cannot be used to trade. Keys
|
|
12
|
+
carry a prefix in clear (`sk_live_9f2a…`) purely so a human can tell two
|
|
13
|
+
keys apart in a list.
|
|
14
|
+
|
|
15
|
+
**Grants are per subaccount.** A grant is `(user, account, permission)`
|
|
16
|
+
where the account is a `venue:name` key or `*`, and the permission is one of
|
|
17
|
+
`view`, `trade`, `manage_credentials`, `manage_members`. Nothing is implied:
|
|
18
|
+
a user who may trade may not grant, and a user who may grant may not trade.
|
|
19
|
+
|
|
20
|
+
**The audit table is append-only, enforced by the database.** Every insert,
|
|
21
|
+
update or delete on the keys or grants tables fires a trigger that writes
|
|
22
|
+
the actor, the request, and the row before and after. Triggers on the audit
|
|
23
|
+
table itself raise on update and delete, so no route, no bug and no hand at
|
|
24
|
+
a SQL prompt can quietly change history -- and there is no route that tries.
|
|
25
|
+
"""
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
import hashlib
|
|
29
|
+
import json
|
|
30
|
+
import os
|
|
31
|
+
import secrets
|
|
32
|
+
import time
|
|
33
|
+
from dataclasses import dataclass, field
|
|
34
|
+
from typing import Any, Iterable, Literal
|
|
35
|
+
|
|
36
|
+
Permission = Literal["view", "trade", "manage_credentials", "manage_members"]
|
|
37
|
+
PERMISSIONS: tuple[Permission, ...] = ("view", "trade", "manage_credentials", "manage_members")
|
|
38
|
+
ALL_ACCOUNTS = "*"
|
|
39
|
+
|
|
40
|
+
SCHEMA = """
|
|
41
|
+
CREATE TABLE IF NOT EXISTS users (
|
|
42
|
+
id TEXT PRIMARY KEY,
|
|
43
|
+
name TEXT NOT NULL,
|
|
44
|
+
created_ts INTEGER NOT NULL,
|
|
45
|
+
disabled INTEGER NOT NULL DEFAULT 0
|
|
46
|
+
);
|
|
47
|
+
|
|
48
|
+
CREATE TABLE IF NOT EXISTS api_keys (
|
|
49
|
+
id TEXT PRIMARY KEY,
|
|
50
|
+
user_id TEXT NOT NULL REFERENCES users(id),
|
|
51
|
+
prefix TEXT NOT NULL,
|
|
52
|
+
hash TEXT NOT NULL UNIQUE,
|
|
53
|
+
label TEXT,
|
|
54
|
+
created_ts INTEGER NOT NULL,
|
|
55
|
+
last_used_ts INTEGER,
|
|
56
|
+
revoked_ts INTEGER
|
|
57
|
+
);
|
|
58
|
+
CREATE INDEX IF NOT EXISTS api_keys_user ON api_keys(user_id);
|
|
59
|
+
|
|
60
|
+
CREATE TABLE IF NOT EXISTS grants (
|
|
61
|
+
id TEXT PRIMARY KEY,
|
|
62
|
+
user_id TEXT NOT NULL REFERENCES users(id),
|
|
63
|
+
account TEXT NOT NULL,
|
|
64
|
+
permission TEXT NOT NULL,
|
|
65
|
+
granted_ts INTEGER NOT NULL,
|
|
66
|
+
UNIQUE (user_id, account, permission)
|
|
67
|
+
);
|
|
68
|
+
CREATE INDEX IF NOT EXISTS grants_user ON grants(user_id);
|
|
69
|
+
|
|
70
|
+
CREATE TABLE IF NOT EXISTS audit (
|
|
71
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
72
|
+
ts INTEGER NOT NULL,
|
|
73
|
+
actor TEXT,
|
|
74
|
+
request TEXT,
|
|
75
|
+
table_name TEXT NOT NULL,
|
|
76
|
+
action TEXT NOT NULL,
|
|
77
|
+
row_id TEXT,
|
|
78
|
+
before TEXT,
|
|
79
|
+
after TEXT
|
|
80
|
+
);
|
|
81
|
+
CREATE INDEX IF NOT EXISTS audit_ts ON audit(ts);
|
|
82
|
+
|
|
83
|
+
-- One row, rewritten at the start of every request that changes anything, so
|
|
84
|
+
-- the triggers can record who asked and under which request.
|
|
85
|
+
CREATE TABLE IF NOT EXISTS audit_context (
|
|
86
|
+
id INTEGER PRIMARY KEY CHECK (id = 1),
|
|
87
|
+
actor TEXT,
|
|
88
|
+
request TEXT
|
|
89
|
+
);
|
|
90
|
+
INSERT INTO audit_context(id, actor, request) VALUES(1, NULL, NULL)
|
|
91
|
+
ON CONFLICT(id) DO NOTHING;
|
|
92
|
+
"""
|
|
93
|
+
|
|
94
|
+
TRIGGERS = """
|
|
95
|
+
CREATE TRIGGER IF NOT EXISTS audit_grants_insert AFTER INSERT ON grants BEGIN
|
|
96
|
+
INSERT INTO audit(ts, actor, request, table_name, action, row_id, before, after)
|
|
97
|
+
VALUES (CAST(strftime('%s','now') AS INTEGER) * 1000,
|
|
98
|
+
(SELECT actor FROM audit_context WHERE id = 1),
|
|
99
|
+
(SELECT request FROM audit_context WHERE id = 1),
|
|
100
|
+
'grants', 'insert', NEW.id, NULL,
|
|
101
|
+
json_object('user_id', NEW.user_id, 'account', NEW.account, 'permission', NEW.permission));
|
|
102
|
+
END;
|
|
103
|
+
|
|
104
|
+
CREATE TRIGGER IF NOT EXISTS audit_grants_update AFTER UPDATE ON grants BEGIN
|
|
105
|
+
INSERT INTO audit(ts, actor, request, table_name, action, row_id, before, after)
|
|
106
|
+
VALUES (CAST(strftime('%s','now') AS INTEGER) * 1000,
|
|
107
|
+
(SELECT actor FROM audit_context WHERE id = 1),
|
|
108
|
+
(SELECT request FROM audit_context WHERE id = 1),
|
|
109
|
+
'grants', 'update', NEW.id,
|
|
110
|
+
json_object('user_id', OLD.user_id, 'account', OLD.account, 'permission', OLD.permission),
|
|
111
|
+
json_object('user_id', NEW.user_id, 'account', NEW.account, 'permission', NEW.permission));
|
|
112
|
+
END;
|
|
113
|
+
|
|
114
|
+
CREATE TRIGGER IF NOT EXISTS audit_grants_delete AFTER DELETE ON grants BEGIN
|
|
115
|
+
INSERT INTO audit(ts, actor, request, table_name, action, row_id, before, after)
|
|
116
|
+
VALUES (CAST(strftime('%s','now') AS INTEGER) * 1000,
|
|
117
|
+
(SELECT actor FROM audit_context WHERE id = 1),
|
|
118
|
+
(SELECT request FROM audit_context WHERE id = 1),
|
|
119
|
+
'grants', 'delete', OLD.id,
|
|
120
|
+
json_object('user_id', OLD.user_id, 'account', OLD.account, 'permission', OLD.permission), NULL);
|
|
121
|
+
END;
|
|
122
|
+
|
|
123
|
+
CREATE TRIGGER IF NOT EXISTS audit_keys_insert AFTER INSERT ON api_keys BEGIN
|
|
124
|
+
INSERT INTO audit(ts, actor, request, table_name, action, row_id, before, after)
|
|
125
|
+
VALUES (CAST(strftime('%s','now') AS INTEGER) * 1000,
|
|
126
|
+
(SELECT actor FROM audit_context WHERE id = 1),
|
|
127
|
+
(SELECT request FROM audit_context WHERE id = 1),
|
|
128
|
+
'api_keys', 'insert', NEW.id, NULL,
|
|
129
|
+
json_object('user_id', NEW.user_id, 'prefix', NEW.prefix, 'label', NEW.label));
|
|
130
|
+
END;
|
|
131
|
+
|
|
132
|
+
CREATE TRIGGER IF NOT EXISTS audit_keys_revoke AFTER UPDATE OF revoked_ts ON api_keys
|
|
133
|
+
WHEN NEW.revoked_ts IS NOT NULL AND OLD.revoked_ts IS NULL BEGIN
|
|
134
|
+
INSERT INTO audit(ts, actor, request, table_name, action, row_id, before, after)
|
|
135
|
+
VALUES (CAST(strftime('%s','now') AS INTEGER) * 1000,
|
|
136
|
+
(SELECT actor FROM audit_context WHERE id = 1),
|
|
137
|
+
(SELECT request FROM audit_context WHERE id = 1),
|
|
138
|
+
'api_keys', 'revoke', NEW.id,
|
|
139
|
+
json_object('user_id', OLD.user_id, 'prefix', OLD.prefix, 'revoked_ts', OLD.revoked_ts),
|
|
140
|
+
json_object('user_id', NEW.user_id, 'prefix', NEW.prefix, 'revoked_ts', NEW.revoked_ts));
|
|
141
|
+
END;
|
|
142
|
+
|
|
143
|
+
-- History is written once. These are the reason there is no route that
|
|
144
|
+
-- edits it: even a mistaken one cannot.
|
|
145
|
+
CREATE TRIGGER IF NOT EXISTS audit_is_append_only_update BEFORE UPDATE ON audit BEGIN
|
|
146
|
+
SELECT RAISE(ABORT, 'the audit log is append-only');
|
|
147
|
+
END;
|
|
148
|
+
|
|
149
|
+
CREATE TRIGGER IF NOT EXISTS audit_is_append_only_delete BEFORE DELETE ON audit BEGIN
|
|
150
|
+
SELECT RAISE(ABORT, 'the audit log is append-only');
|
|
151
|
+
END;
|
|
152
|
+
"""
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def now_ms() -> int:
|
|
156
|
+
return int(time.time() * 1000)
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def hash_key(key: str) -> str:
|
|
160
|
+
return hashlib.sha256(key.encode()).hexdigest()
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def new_key(prefix: str = "sk") -> str:
|
|
164
|
+
"""A key with enough entropy that the hash needs no salt."""
|
|
165
|
+
return f"{prefix}_{secrets.token_urlsafe(32)}"
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
@dataclass(frozen=True, slots=True)
|
|
169
|
+
class User:
|
|
170
|
+
id: str
|
|
171
|
+
name: str
|
|
172
|
+
created_ts: int
|
|
173
|
+
disabled: bool = False
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
@dataclass(frozen=True, slots=True)
|
|
177
|
+
class IssuedKey:
|
|
178
|
+
"""What issuing a key returns. `secret` is shown once and never stored."""
|
|
179
|
+
|
|
180
|
+
id: str
|
|
181
|
+
user_id: str
|
|
182
|
+
prefix: str
|
|
183
|
+
secret: str
|
|
184
|
+
label: str | None = None
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
@dataclass(frozen=True, slots=True)
|
|
188
|
+
class Grant:
|
|
189
|
+
id: str
|
|
190
|
+
user_id: str
|
|
191
|
+
account: str
|
|
192
|
+
permission: Permission
|
|
193
|
+
granted_ts: int
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
@dataclass(frozen=True, slots=True)
|
|
197
|
+
class Principal:
|
|
198
|
+
"""Who is asking, and what they may touch."""
|
|
199
|
+
|
|
200
|
+
user_id: str
|
|
201
|
+
name: str
|
|
202
|
+
key_id: str
|
|
203
|
+
grants: tuple[Grant, ...] = ()
|
|
204
|
+
|
|
205
|
+
def may(self, permission: Permission, account: str | None = None) -> bool:
|
|
206
|
+
for grant in self.grants:
|
|
207
|
+
if grant.permission != permission:
|
|
208
|
+
continue
|
|
209
|
+
if grant.account == ALL_ACCOUNTS or account is None or grant.account == account:
|
|
210
|
+
return True
|
|
211
|
+
return False
|
|
212
|
+
|
|
213
|
+
def accounts(self, permission: Permission) -> set[str]:
|
|
214
|
+
return {g.account for g in self.grants if g.permission == permission}
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
class ControlStore:
|
|
218
|
+
"""Users, keys, grants and the audit log."""
|
|
219
|
+
|
|
220
|
+
def __init__(self, path: str | os.PathLike[str] = "synpath-control.db"):
|
|
221
|
+
self.path = str(path)
|
|
222
|
+
self._db: Any = None
|
|
223
|
+
|
|
224
|
+
async def open(self) -> "ControlStore":
|
|
225
|
+
try:
|
|
226
|
+
import aiosqlite
|
|
227
|
+
except ImportError as exc: # pragma: no cover - depends on the environment
|
|
228
|
+
raise ImportError("the server's account store needs aiosqlite: pip install synpath") from exc
|
|
229
|
+
connection = aiosqlite.connect(self.path, isolation_level=None)
|
|
230
|
+
connection.daemon = True
|
|
231
|
+
self._db = await connection
|
|
232
|
+
self._db.row_factory = aiosqlite.Row
|
|
233
|
+
await self._db.execute("PRAGMA journal_mode=WAL")
|
|
234
|
+
await self._db.execute("PRAGMA foreign_keys=ON")
|
|
235
|
+
await self._db.executescript(SCHEMA)
|
|
236
|
+
await self._db.executescript(TRIGGERS)
|
|
237
|
+
return self
|
|
238
|
+
|
|
239
|
+
async def close(self) -> None:
|
|
240
|
+
if self._db is not None:
|
|
241
|
+
await self._db.close()
|
|
242
|
+
self._db = None
|
|
243
|
+
|
|
244
|
+
async def __aenter__(self) -> "ControlStore":
|
|
245
|
+
return await self.open()
|
|
246
|
+
|
|
247
|
+
async def __aexit__(self, *exc: Any) -> None:
|
|
248
|
+
await self.close()
|
|
249
|
+
|
|
250
|
+
# -- who is acting --------------------------------------------------------
|
|
251
|
+
|
|
252
|
+
async def acting_as(self, actor: str | None, request: str | None = None) -> None:
|
|
253
|
+
"""Name the actor for the audit rows the next writes will produce."""
|
|
254
|
+
await self._db.execute("UPDATE audit_context SET actor=?, request=? WHERE id=1", (actor, request))
|
|
255
|
+
|
|
256
|
+
# -- users and keys -------------------------------------------------------
|
|
257
|
+
|
|
258
|
+
async def create_user(self, name: str, *, user_id: str | None = None) -> User:
|
|
259
|
+
user = User(id=user_id or f"u_{secrets.token_hex(8)}", name=name, created_ts=now_ms())
|
|
260
|
+
await self._db.execute("INSERT INTO users(id, name, created_ts, disabled) VALUES(?,?,?,0)",
|
|
261
|
+
(user.id, user.name, user.created_ts))
|
|
262
|
+
return user
|
|
263
|
+
|
|
264
|
+
async def issue_key(self, user_id: str, *, label: str | None = None) -> IssuedKey:
|
|
265
|
+
secret = new_key()
|
|
266
|
+
key_id = f"k_{secrets.token_hex(8)}"
|
|
267
|
+
await self._db.execute(
|
|
268
|
+
"INSERT INTO api_keys(id, user_id, prefix, hash, label, created_ts) VALUES(?,?,?,?,?,?)",
|
|
269
|
+
(key_id, user_id, secret[:12], hash_key(secret), label, now_ms()),
|
|
270
|
+
)
|
|
271
|
+
return IssuedKey(id=key_id, user_id=user_id, prefix=secret[:12], secret=secret, label=label)
|
|
272
|
+
|
|
273
|
+
async def revoke_key(self, key_id: str) -> bool:
|
|
274
|
+
cursor = await self._db.execute(
|
|
275
|
+
"UPDATE api_keys SET revoked_ts=? WHERE id=? AND revoked_ts IS NULL", (now_ms(), key_id),
|
|
276
|
+
)
|
|
277
|
+
return cursor.rowcount > 0
|
|
278
|
+
|
|
279
|
+
async def principal(self, secret: str) -> Principal | None:
|
|
280
|
+
"""Who a key belongs to, with the grants it carries right now."""
|
|
281
|
+
async with self._db.execute(
|
|
282
|
+
"SELECT k.id AS key_id, k.user_id, u.name, u.disabled FROM api_keys k JOIN users u ON u.id = k.user_id "
|
|
283
|
+
"WHERE k.hash=? AND k.revoked_ts IS NULL",
|
|
284
|
+
(hash_key(secret),),
|
|
285
|
+
) as cursor:
|
|
286
|
+
row = await cursor.fetchone()
|
|
287
|
+
if row is None or row["disabled"]:
|
|
288
|
+
return None
|
|
289
|
+
await self._db.execute("UPDATE api_keys SET last_used_ts=? WHERE id=?", (now_ms(), row["key_id"]))
|
|
290
|
+
grants = await self.grants(row["user_id"])
|
|
291
|
+
return Principal(user_id=row["user_id"], name=row["name"], key_id=row["key_id"], grants=tuple(grants))
|
|
292
|
+
|
|
293
|
+
async def keys(self, user_id: str) -> list[dict[str, Any]]:
|
|
294
|
+
async with self._db.execute(
|
|
295
|
+
"SELECT id, prefix, label, created_ts, last_used_ts, revoked_ts FROM api_keys WHERE user_id=? ORDER BY created_ts",
|
|
296
|
+
(user_id,),
|
|
297
|
+
) as cursor:
|
|
298
|
+
return [dict(row) async for row in cursor]
|
|
299
|
+
|
|
300
|
+
# -- grants ---------------------------------------------------------------
|
|
301
|
+
|
|
302
|
+
async def grant(self, user_id: str, account: str, permission: Permission) -> Grant:
|
|
303
|
+
if permission not in PERMISSIONS:
|
|
304
|
+
raise ValueError(f"{permission!r} is not a permission; known: {PERMISSIONS}")
|
|
305
|
+
row = Grant(id=f"g_{secrets.token_hex(8)}", user_id=user_id, account=account, permission=permission,
|
|
306
|
+
granted_ts=now_ms())
|
|
307
|
+
await self._db.execute(
|
|
308
|
+
"INSERT INTO grants(id, user_id, account, permission, granted_ts) VALUES(?,?,?,?,?) "
|
|
309
|
+
"ON CONFLICT(user_id, account, permission) DO NOTHING",
|
|
310
|
+
(row.id, row.user_id, row.account, row.permission, row.granted_ts),
|
|
311
|
+
)
|
|
312
|
+
return row
|
|
313
|
+
|
|
314
|
+
async def revoke(self, user_id: str, account: str, permission: Permission) -> bool:
|
|
315
|
+
cursor = await self._db.execute(
|
|
316
|
+
"DELETE FROM grants WHERE user_id=? AND account=? AND permission=?", (user_id, account, permission),
|
|
317
|
+
)
|
|
318
|
+
return cursor.rowcount > 0
|
|
319
|
+
|
|
320
|
+
async def grants(self, user_id: str) -> list[Grant]:
|
|
321
|
+
async with self._db.execute(
|
|
322
|
+
"SELECT id, user_id, account, permission, granted_ts FROM grants WHERE user_id=? ORDER BY granted_ts",
|
|
323
|
+
(user_id,),
|
|
324
|
+
) as cursor:
|
|
325
|
+
return [Grant(row["id"], row["user_id"], row["account"], row["permission"], row["granted_ts"])
|
|
326
|
+
async for row in cursor]
|
|
327
|
+
|
|
328
|
+
# -- the audit log --------------------------------------------------------
|
|
329
|
+
|
|
330
|
+
async def audit(self, *, since_id: int = 0, limit: int = 200, table: str | None = None) -> list[dict[str, Any]]:
|
|
331
|
+
sql = "SELECT id, ts, actor, request, table_name, action, row_id, before, after FROM audit WHERE id > ?"
|
|
332
|
+
args: list[Any] = [since_id]
|
|
333
|
+
if table:
|
|
334
|
+
sql += " AND table_name = ?"
|
|
335
|
+
args.append(table)
|
|
336
|
+
sql += " ORDER BY id LIMIT ?"
|
|
337
|
+
args.append(limit)
|
|
338
|
+
async with self._db.execute(sql, args) as cursor:
|
|
339
|
+
rows = [dict(row) async for row in cursor]
|
|
340
|
+
for row in rows:
|
|
341
|
+
for key in ("before", "after"):
|
|
342
|
+
if row[key]:
|
|
343
|
+
row[key] = json.loads(row[key])
|
|
344
|
+
return rows
|
|
345
|
+
|
|
346
|
+
async def has_users(self) -> bool:
|
|
347
|
+
"""Whether anyone has been created yet: false on a new control database."""
|
|
348
|
+
async with self._db.execute("SELECT 1 FROM users LIMIT 1") as cursor:
|
|
349
|
+
return await cursor.fetchone() is not None
|
|
350
|
+
|
|
351
|
+
async def bootstrap(self, name: str = "owner") -> tuple[User, IssuedKey]:
|
|
352
|
+
"""The first user, with every permission on every account.
|
|
353
|
+
|
|
354
|
+
Called once, by the operator starting the server, so there is a key to
|
|
355
|
+
make the next key with.
|
|
356
|
+
"""
|
|
357
|
+
user = await self.create_user(name)
|
|
358
|
+
await self.acting_as(f"bootstrap:{user.id}", "bootstrap")
|
|
359
|
+
for permission in PERMISSIONS:
|
|
360
|
+
await self.grant(user.id, ALL_ACCOUNTS, permission)
|
|
361
|
+
key = await self.issue_key(user.id, label="bootstrap")
|
|
362
|
+
await self.acting_as(None, None)
|
|
363
|
+
return user, key
|