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.
Files changed (77) hide show
  1. synpath/__init__.py +183 -0
  2. synpath/__main__.py +66 -0
  3. synpath/base.py +723 -0
  4. synpath/bucket.py +154 -0
  5. synpath/client.py +356 -0
  6. synpath/engine/__init__.py +37 -0
  7. synpath/engine/__main__.py +354 -0
  8. synpath/engine/alerts.py +170 -0
  9. synpath/engine/engine.py +888 -0
  10. synpath/engine/eod.py +154 -0
  11. synpath/engine/events.py +140 -0
  12. synpath/engine/fair_values.py +117 -0
  13. synpath/engine/feeds.py +220 -0
  14. synpath/engine/journal.py +907 -0
  15. synpath/engine/ledger.py +353 -0
  16. synpath/engine/orders/__init__.py +42 -0
  17. synpath/engine/orders/base.py +441 -0
  18. synpath/engine/orders/day.py +72 -0
  19. synpath/engine/orders/iceberg.py +121 -0
  20. synpath/engine/orders/manager.py +223 -0
  21. synpath/engine/orders/oco.py +255 -0
  22. synpath/engine/orders/peg.py +168 -0
  23. synpath/engine/orders/routed.py +496 -0
  24. synpath/engine/orders/stop.py +240 -0
  25. synpath/engine/orders/taker.py +187 -0
  26. synpath/engine/orders/twap.py +190 -0
  27. synpath/engine/paper.py +532 -0
  28. synpath/engine/reconcile.py +279 -0
  29. synpath/engine/risk.py +403 -0
  30. synpath/engine/router.py +261 -0
  31. synpath/errors.py +98 -0
  32. synpath/history.py +71 -0
  33. synpath/hosted.py +86 -0
  34. synpath/hosted_auth.py +201 -0
  35. synpath/ids.py +61 -0
  36. synpath/kalshi.py +1378 -0
  37. synpath/matching.py +86 -0
  38. synpath/polymarket.py +1004 -0
  39. synpath/polymarket_us.py +989 -0
  40. synpath/remote.py +195 -0
  41. synpath/server/__init__.py +98 -0
  42. synpath/server/__main__.py +118 -0
  43. synpath/server/api.py +439 -0
  44. synpath/server/errors.py +87 -0
  45. synpath/server/local.py +96 -0
  46. synpath/server/models.py +75 -0
  47. synpath/server/serve.py +236 -0
  48. synpath/server/store.py +363 -0
  49. synpath/server/trading.py +764 -0
  50. synpath/trading/__init__.py +79 -0
  51. synpath/trading/__main__.py +69 -0
  52. synpath/trading/base.py +126 -0
  53. synpath/trading/credentials.py +400 -0
  54. synpath/trading/errors.py +94 -0
  55. synpath/trading/init.py +233 -0
  56. synpath/trading/instruments.py +162 -0
  57. synpath/trading/kalshi.py +957 -0
  58. synpath/trading/limiter.py +177 -0
  59. synpath/trading/money.py +172 -0
  60. synpath/trading/polymarket.py +1362 -0
  61. synpath/trading/polymarket_signing.py +478 -0
  62. synpath/trading/polymarket_us.py +705 -0
  63. synpath/trading/polymarket_us_exchange.py +825 -0
  64. synpath/trading/types.py +414 -0
  65. synpath/types.py +608 -0
  66. synpath/ws/__init__.py +55 -0
  67. synpath/ws/base.py +544 -0
  68. synpath/ws/grpc.py +578 -0
  69. synpath/ws/kalshi.py +418 -0
  70. synpath/ws/polymarket.py +430 -0
  71. synpath/ws/polymarket_us.py +299 -0
  72. synpath/ws/polymarket_us_exchange.py +754 -0
  73. synpath-0.1.0.dist-info/METADATA +224 -0
  74. synpath-0.1.0.dist-info/RECORD +77 -0
  75. synpath-0.1.0.dist-info/WHEEL +4 -0
  76. synpath-0.1.0.dist-info/entry_points.txt +2 -0
  77. synpath-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -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]
@@ -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)))
@@ -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