accordsync-server 0.3.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.
@@ -0,0 +1,54 @@
1
+ """The Accord sync server on PostgreSQL, framework-agnostic: push, pull, scopes, compaction."""
2
+
3
+ from accordsync_core import PROTOCOL_VERSION
4
+
5
+ from .app import AccordServer, Response
6
+ from .auth import AuthError, create_verifier
7
+ from .compact import CompactionResult, compact
8
+ from .db import create_pool
9
+ from .define import (
10
+ Access,
11
+ Auth,
12
+ Claims,
13
+ Compaction,
14
+ Limits,
15
+ RateLimit,
16
+ RateLimits,
17
+ ScopedRecord,
18
+ ServerDefinition,
19
+ define_server,
20
+ )
21
+ from .migrations import migrate
22
+ from .ratelimit import Limiter, RateLimiter
23
+ from .sync import FEED_LOCK, BadRequestError, Caller, ForbiddenError, op_hash, pull, push
24
+
25
+ __all__ = [
26
+ "FEED_LOCK",
27
+ "PROTOCOL_VERSION",
28
+ "Access",
29
+ "AccordServer",
30
+ "Auth",
31
+ "AuthError",
32
+ "BadRequestError",
33
+ "Caller",
34
+ "Claims",
35
+ "Compaction",
36
+ "CompactionResult",
37
+ "ForbiddenError",
38
+ "Limiter",
39
+ "Limits",
40
+ "RateLimit",
41
+ "RateLimiter",
42
+ "RateLimits",
43
+ "Response",
44
+ "ScopedRecord",
45
+ "ServerDefinition",
46
+ "compact",
47
+ "create_pool",
48
+ "create_verifier",
49
+ "define_server",
50
+ "migrate",
51
+ "op_hash",
52
+ "pull",
53
+ "push",
54
+ ]
@@ -0,0 +1,50 @@
1
+ """`python -m accordsync_server migrate|compact`.
2
+
3
+ `migrate` needs only ACCORD_DATABASE_URL. `compact` also needs the server definition:
4
+ `--definition module:attribute` (or ACCORD_SERVER) naming a `ServerDefinition`.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import argparse
10
+ import importlib
11
+ import json
12
+ import os
13
+ import sys
14
+
15
+ from .compact import compact
16
+ from .db import create_pool
17
+ from .define import ServerDefinition
18
+ from .migrations import migrate
19
+
20
+
21
+ def load_definition(spec: str) -> ServerDefinition:
22
+ module, _, attr = spec.partition(":")
23
+ value = getattr(importlib.import_module(module), attr or "server")
24
+ if not isinstance(value, ServerDefinition):
25
+ raise SystemExit(f"{spec} is not a ServerDefinition (use define_server(...))")
26
+ return value
27
+
28
+
29
+ def main(argv: list[str] | None = None) -> int:
30
+ parser = argparse.ArgumentParser(prog="python -m accordsync_server")
31
+ parser.add_argument("command", choices=["migrate", "compact"])
32
+ parser.add_argument("--database-url", default=os.environ.get("ACCORD_DATABASE_URL"))
33
+ parser.add_argument("--definition", default=os.environ.get("ACCORD_SERVER"))
34
+ args = parser.parse_args(argv)
35
+ if not args.database_url:
36
+ parser.error("ACCORD_DATABASE_URL (or --database-url) is required")
37
+ if args.command == "migrate":
38
+ ran = migrate(args.database_url)
39
+ print(f"applied {len(ran)} migration(s): {', '.join(ran)}" if ran else "up to date")
40
+ return 0
41
+ if not args.definition:
42
+ parser.error("compact needs --definition module:attribute (or ACCORD_SERVER)")
43
+ definition = load_definition(args.definition)
44
+ with create_pool(args.database_url, size=2) as pool:
45
+ print(json.dumps(compact(pool, definition)))
46
+ return 0
47
+
48
+
49
+ if __name__ == "__main__":
50
+ sys.exit(main())
@@ -0,0 +1,281 @@
1
+ """The framework-agnostic request layer (`app.ts`): one `handle()` call per HTTP request.
2
+
3
+ Framework adapters (FastAPI, Django, WSGI) only translate their request into
4
+ `handle(method, path, query, headers, body)` and the returned `Response` back.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ import logging
11
+ import math
12
+ import time
13
+ from collections.abc import Callable, Iterable, Mapping, Sequence
14
+ from dataclasses import dataclass, field
15
+ from typing import Any
16
+ from urllib.parse import parse_qs
17
+
18
+ from accordsync_core import PROTOCOL_VERSION, AccordError, assert_node, canonical_json
19
+
20
+ from .auth import AuthError, Verifier, create_verifier
21
+ from .define import ServerDefinition
22
+ from .jsnum import js_safe_integer
23
+ from .ratelimit import Limiter, RateLimiter
24
+ from .sync import (
25
+ BadRequestError,
26
+ Caller,
27
+ ForbiddenError,
28
+ Pool,
29
+ SyncContext,
30
+ device_ttl_ms,
31
+ pull,
32
+ push,
33
+ touch_device,
34
+ )
35
+
36
+ log = logging.getLogger("accordsync_server")
37
+
38
+ Query = Mapping[str, Sequence[str]] | str
39
+
40
+
41
+ @dataclass(slots=True)
42
+ class Response:
43
+ status: int
44
+ headers: list[tuple[str, str]] = field(default_factory=list)
45
+ body: bytes = b""
46
+
47
+
48
+ class TooManyRequestsError(Exception):
49
+ def __init__(self, retry_after_ms: int) -> None:
50
+ super().__init__("too many requests")
51
+ self.retry_after_ms = retry_after_ms
52
+
53
+
54
+ def now_ms() -> int:
55
+ return time.time_ns() // 1_000_000
56
+
57
+
58
+ def _json(status: int, body: object, headers: Iterable[tuple[str, str]] = ()) -> Response:
59
+ return Response(
60
+ status,
61
+ [("Content-Type", "application/json"), *headers],
62
+ canonical_json(body).encode("utf-8"),
63
+ )
64
+
65
+
66
+ def _error(status: int, message: str) -> Response:
67
+ return _json(status, {"error": message})
68
+
69
+
70
+ def _reject_constant(name: str) -> object:
71
+ raise ValueError(f"{name} is not JSON")
72
+
73
+
74
+ class AccordServer:
75
+ """Push, pull and health over one connection pool, with auth, limits, CORS and rate limits."""
76
+
77
+ def __init__(
78
+ self,
79
+ definition: ServerDefinition,
80
+ pool: Pool,
81
+ *,
82
+ now: Callable[[], int] = now_ms,
83
+ verifier: Verifier | None = None,
84
+ ) -> None:
85
+ self.definition = definition
86
+ self.pool = pool
87
+ self.ctx = SyncContext(pool, definition, now)
88
+ self._verify = verifier or create_verifier(definition.auth)
89
+ self._limiters: tuple[Limiter, Limiter] | None = None
90
+ self.reset_rate_limits()
91
+
92
+ def reset_rate_limits(self) -> None:
93
+ """Starts every rate-limit bucket full again (the conformance control API's reset)."""
94
+ rl = self.definition.rate_limit
95
+ self._limiters = (
96
+ None
97
+ if rl is None
98
+ else (
99
+ RateLimiter(rl.per_device, self.ctx.now),
100
+ RateLimiter(rl.per_user, self.ctx.now),
101
+ )
102
+ )
103
+
104
+ @property
105
+ def max_body_bytes(self) -> int:
106
+ """Adapters may stop reading a body past this (it gets 413 anyway)."""
107
+ return self.definition.limits.max_body_bytes
108
+
109
+ # ------------------------------------------------------------------ dispatch
110
+
111
+ def handle(
112
+ self,
113
+ method: str,
114
+ path: str,
115
+ query: Query,
116
+ headers: Mapping[str, str],
117
+ body: bytes = b"",
118
+ ) -> Response:
119
+ h = {k.lower(): v for k, v in headers.items()}
120
+ q = parse_qs(query, keep_blank_values=True) if isinstance(query, str) else query
121
+ method = method.upper()
122
+ cors = self._cors(method, h)
123
+ if cors is not None and cors[0]:
124
+ res = Response(204, cors[1])
125
+ else:
126
+ try:
127
+ res = self._route(method, path, q, h, body)
128
+ except AuthError as e:
129
+ res = _error(401, str(e))
130
+ except ForbiddenError as e:
131
+ res = _error(403, str(e))
132
+ except BadRequestError as e:
133
+ res = _error(400, str(e))
134
+ except TooManyRequestsError as e:
135
+ res = _error(429, "too many requests")
136
+ res.headers.append(("Retry-After", str(math.ceil(e.retry_after_ms / 1000))))
137
+ except Exception:
138
+ log.exception("accord: internal error")
139
+ res = _error(500, "internal error")
140
+ if cors is not None:
141
+ res.headers += cors[1]
142
+ res.headers.append(("Accord-Protocol", str(PROTOCOL_VERSION)))
143
+ return res
144
+
145
+ def _cors(self, method: str, h: Mapping[str, str]) -> tuple[bool, list[tuple[str, str]]] | None:
146
+ """(is a preflight, headers to add), or None when CORS is off for this request."""
147
+ origins = self.definition.cors
148
+ if not origins:
149
+ return None
150
+ origin = h.get("origin")
151
+ allowed = origin if origin in origins else None
152
+ out: list[tuple[str, str]] = []
153
+ if allowed:
154
+ out.append(("Access-Control-Allow-Origin", allowed))
155
+ out.append(("Vary", "Origin"))
156
+ if method == "OPTIONS":
157
+ out += [
158
+ ("Access-Control-Max-Age", "600"),
159
+ ("Access-Control-Allow-Methods", "GET,HEAD,PUT,POST,DELETE,PATCH"),
160
+ ("Access-Control-Allow-Headers", "Authorization,Accord-Device,Content-Type"),
161
+ ]
162
+ return True, out
163
+ out.append(("Access-Control-Expose-Headers", "Accord-Protocol"))
164
+ return False, out
165
+
166
+ def _route(
167
+ self,
168
+ method: str,
169
+ path: str,
170
+ q: Mapping[str, Sequence[str]],
171
+ h: Mapping[str, str],
172
+ body: bytes,
173
+ ) -> Response:
174
+ if path == "/health" and method in ("GET", "HEAD"):
175
+ return self.health()
176
+ if path.startswith("/v1/"):
177
+ limit = self.definition.limits.max_body_bytes
178
+ length = js_safe_integer(h.get("content-length", "") or "0")
179
+ if len(body) > limit or (length is not None and length > limit):
180
+ return _error(413, "request body too large")
181
+ if path == "/v1/push" and method == "POST":
182
+ return self._push(h, body)
183
+ if path == "/v1/pull" and method in ("GET", "HEAD"):
184
+ return self._pull(h, q)
185
+ return _error(404, "not found")
186
+
187
+ # ------------------------------------------------------------------ routes
188
+
189
+ def health(self) -> Response:
190
+ try:
191
+ with self.pool.connection(timeout=5) as conn:
192
+ conn.execute("select 1")
193
+ except Exception:
194
+ return _json(503, {"status": "unavailable", "reason": "database unreachable"})
195
+ return _json(200, {"status": "ok", "protocolVersion": PROTOCOL_VERSION})
196
+
197
+ def _caller(self, h: Mapping[str, str]) -> Caller:
198
+ claims = self._verify(h.get("authorization"))
199
+ device_id = h.get("accord-device", "")
200
+ try:
201
+ assert_node(device_id)
202
+ except AccordError:
203
+ raise BadRequestError(
204
+ "Accord-Device header must be a device id ([A-Za-z0-9_-]{1,64})"
205
+ ) from None
206
+ if self._limiters is not None:
207
+ device, user = self._limiters
208
+ wait = max(device.take(device_id), user.take(claims["sub"]))
209
+ if wait > 0:
210
+ raise TooManyRequestsError(wait)
211
+ access = self.definition.access(claims)
212
+ who = Caller(claims["sub"], device_id, tuple(access.read), tuple(access.write))
213
+ touch_device(self.pool, who, device_ttl_ms(self.definition))
214
+ return who
215
+
216
+ def _push(self, h: Mapping[str, str], body: bytes) -> Response:
217
+ who = self._caller(h)
218
+ try:
219
+ data: Any = json.loads(body.decode("utf-8", "replace"), parse_constant=_reject_constant)
220
+ except (ValueError, RecursionError):
221
+ raise BadRequestError("body must be JSON") from None
222
+ if not (isinstance(data, dict) and set(data) == {"ops"} and isinstance(data["ops"], list)):
223
+ raise BadRequestError('body must be { "ops": [...] }')
224
+ return _json(200, push(self.ctx, who, data["ops"]))
225
+
226
+ def _pull(self, h: Mapping[str, str], q: Mapping[str, Sequence[str]]) -> Response:
227
+ who = self._caller(h)
228
+ cursor = js_safe_integer(_first(q, "cursor", "0"))
229
+ limit = js_safe_integer(_first(q, "limit", "500"))
230
+ if cursor is None or cursor < 0:
231
+ raise BadRequestError("cursor must be an integer ≥ 0")
232
+ if limit is None or limit < 1:
233
+ raise BadRequestError("limit must be an integer ≥ 1")
234
+ return _json(200, pull(self.ctx, who, cursor, limit))
235
+
236
+ # ------------------------------------------------------------------ WSGI
237
+
238
+ def wsgi(
239
+ self, environ: Mapping[str, Any], start_response: Callable[..., object]
240
+ ) -> list[bytes]:
241
+ """A WSGI application: `AccordServer(...).wsgi` can be served by any WSGI server."""
242
+ headers = {
243
+ k[5:].replace("_", "-"): str(v) for k, v in environ.items() if k.startswith("HTTP_")
244
+ }
245
+ if environ.get("CONTENT_TYPE"):
246
+ headers["content-type"] = str(environ["CONTENT_TYPE"])
247
+ if environ.get("CONTENT_LENGTH"):
248
+ headers["content-length"] = str(environ["CONTENT_LENGTH"])
249
+ length = js_safe_integer(headers.get("content-length", "0")) or 0
250
+ stream = environ["wsgi.input"]
251
+ # Read at most one byte past the limit: a larger body gets 413 without being buffered.
252
+ body = stream.read(min(max(length, 0), self.max_body_bytes + 1)) if length > 0 else b""
253
+ res = self.handle(
254
+ str(environ.get("REQUEST_METHOD", "GET")),
255
+ str(environ.get("PATH_INFO", "/")) or "/",
256
+ str(environ.get("QUERY_STRING", "")),
257
+ headers,
258
+ body,
259
+ )
260
+ reason = _REASONS.get(res.status, "Unknown")
261
+ start_response(f"{res.status} {reason}", res.headers)
262
+ return [res.body]
263
+
264
+
265
+ def _first(q: Mapping[str, Sequence[str]], name: str, default: str) -> str:
266
+ v = q.get(name)
267
+ return v[0] if v else default
268
+
269
+
270
+ _REASONS = {
271
+ 200: "OK",
272
+ 204: "No Content",
273
+ 400: "Bad Request",
274
+ 401: "Unauthorized",
275
+ 403: "ForbiddenError",
276
+ 404: "Not Found",
277
+ 413: "Payload Too Large",
278
+ 429: "Too Many Requests",
279
+ 500: "Internal Server Error",
280
+ 503: "Service Unavailable",
281
+ }
@@ -0,0 +1,63 @@
1
+ """Verifies `Authorization: Bearer <jwt>` against the app's JWKS (or a dev secret)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from collections.abc import Callable
7
+ from typing import Any
8
+
9
+ import jwt
10
+ from jwt.types import Options
11
+
12
+ from .define import Auth, Claims
13
+
14
+
15
+ class AuthError(Exception):
16
+ """The request is not authenticated: answered 401."""
17
+
18
+
19
+ Verifier = Callable[[str | None], Claims]
20
+
21
+ _BEARER = re.compile(r"Bearer (.+)")
22
+ ASYMMETRIC = ["RS256", "RS384", "RS512", "PS256", "PS384", "PS512", "ES256", "ES384", "ES512"]
23
+ ASYMMETRIC.append("EdDSA")
24
+
25
+
26
+ def create_verifier(auth: Auth) -> Verifier:
27
+ """Like the TypeScript server (jose): `exp` and `nbf` checked when present, `iss` and `aud`
28
+ only when configured, `sub` required. With a JWKS, keys are fetched once and cached."""
29
+ jwks = (
30
+ jwt.PyJWKClient(auth.jwks_url, cache_keys=True, lifespan=auth.jwks_cache_seconds)
31
+ if auth.jwks_url
32
+ else None
33
+ )
34
+ options: Options = {"verify_aud": auth.audience is not None, "verify_iat": False}
35
+
36
+ def verify(authorization: str | None) -> Claims:
37
+ m = _BEARER.fullmatch(authorization or "")
38
+ if not m:
39
+ raise AuthError("missing bearer token")
40
+ token = m[1]
41
+ try:
42
+ if jwks is not None:
43
+ key: Any = jwks.get_signing_key_from_jwt(token).key
44
+ algorithms = ASYMMETRIC
45
+ else:
46
+ key = auth.hs256_secret
47
+ algorithms = ["HS256"]
48
+ payload: dict[str, Any] = jwt.decode(
49
+ token,
50
+ key,
51
+ algorithms=algorithms,
52
+ issuer=auth.issuer,
53
+ audience=auth.audience,
54
+ options=options,
55
+ )
56
+ except (jwt.PyJWTError, ValueError, TypeError) as e:
57
+ raise AuthError(f"invalid token: {e}") from e
58
+ sub = payload.get("sub")
59
+ if not isinstance(sub, str) or sub == "":
60
+ raise AuthError('token has no "sub" claim')
61
+ return payload
62
+
63
+ return verify
@@ -0,0 +1,82 @@
1
+ """Log compaction, from `app/packages/server/src/compact.ts` (ADR-0005, ADR-0008, ADR-0010)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any, TypedDict
6
+
7
+ from accordsync_core import canonical_json, encode_op, parse_op_id
8
+
9
+ from .define import ServerDefinition
10
+ from .sync import FEED_LOCK, Pool, device_ttl_ms, load_record, op_hash
11
+
12
+
13
+ class CompactionResult(TypedDict):
14
+ watermark: int
15
+ """Feed position every live device has applied: only ops at or below it were folded."""
16
+ records: int
17
+ opsFolded: int
18
+ tombstonesPruned: int
19
+
20
+
21
+ def compact(pool: Pool, definition: ServerDefinition) -> CompactionResult:
22
+ """Folds the history of records every live device already has into one snapshot per record.
23
+
24
+ The snapshot takes the position of the last op it replaces, so live devices (past it) never
25
+ receive it; devices starting from zero and records entering a scope get it instead.
26
+ """
27
+ min_ops = definition.compaction.min_ops
28
+ ttl_secs = device_ttl_ms(definition) / 1000
29
+ with pool.connection() as conn, conn.transaction():
30
+ # Exclusive: waits for running pushes (they hold the lock shared) and holds new ones back.
31
+ conn.execute("select pg_advisory_xact_lock(%s)", (FEED_LOCK,))
32
+ conn.execute("select set_config('accord.compaction', 'on', true)")
33
+ w = conn.execute(
34
+ "select coalesce((select min(cursor) from devices"
35
+ " where last_seen > now() - make_interval(secs => %s)), accord_horizon() - 1) as w",
36
+ (ttl_secs,),
37
+ ).fetchone()
38
+ assert w is not None
39
+ watermark = int(w[0])
40
+ candidates: list[tuple[Any, ...]] = conn.execute(
41
+ "select record, max(pos) as last from feed where kind in ('op', 'snapshot')"
42
+ " group by record having max(pos) <= %s"
43
+ " and count(*) filter (where kind = 'op') >= %s",
44
+ (watermark, min_ops),
45
+ ).fetchall()
46
+
47
+ ops_folded = 0
48
+ for record, last in candidates:
49
+ replica = load_record(conn, definition, record)
50
+ ops = replica.ops()
51
+ row = conn.execute("select scopes from records where record = %s", (record,)).fetchone()
52
+ if row is None:
53
+ raise RuntimeError(f"no records row for {record}")
54
+ if ops:
55
+ values = ", ".join(["(%s, %s, %s, %s)"] * len(ops))
56
+ params: list[object] = []
57
+ for op in ops:
58
+ p = parse_op_id(op.op_id)
59
+ params += [op.op_id, p.device, p.seq, op_hash(encode_op(op))]
60
+ conn.execute(
61
+ "insert into compacted_ops (op_id, device, op_seq, op_hash)" # noqa: S608 (placeholders only)
62
+ f" values {values} on conflict do nothing",
63
+ params,
64
+ )
65
+ conn.execute("delete from feed where record = %s and pos <= %s", (record, last))
66
+ conn.execute(
67
+ "insert into feed (pos, kind, record, op, scopes)"
68
+ " values (%s, 'snapshot', %s, %s::jsonb, %s::text[])",
69
+ (last, record, canonical_json(replica.snapshot_record(record).to_json()), row[0]),
70
+ )
71
+ ops_folded += len(ops)
72
+ # Entries a device can no longer retry (it has pushed past them) are no longer needed.
73
+ pruned = conn.execute(
74
+ "delete from compacted_ops c using devices d"
75
+ " where c.device = d.device_id and c.op_seq < d.push_floor"
76
+ ).rowcount
77
+ return {
78
+ "watermark": watermark,
79
+ "records": len(candidates),
80
+ "opsFolded": ops_folded,
81
+ "tombstonesPruned": max(pruned, 0),
82
+ }
@@ -0,0 +1,25 @@
1
+ """The connection pool: psycopg 3, autocommit connections, explicit transactions."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from psycopg_pool import ConnectionPool
6
+
7
+ from .sync import Conn, Pool
8
+
9
+
10
+ def _configure(conn: Conn) -> None:
11
+ # Every statement outside `with conn.transaction()` commits on its own; transactions are
12
+ # always explicit (BEGIN ... COMMIT), so nothing is ever left open between requests.
13
+ conn.autocommit = True
14
+
15
+
16
+ def create_pool(database_url: str, *, size: int = 20, open: bool = True) -> Pool:
17
+ """A pool of up to `size` connections (the TypeScript server's default is 20)."""
18
+ pool: Pool = ConnectionPool(
19
+ database_url,
20
+ min_size=1,
21
+ max_size=size,
22
+ configure=_configure,
23
+ open=open,
24
+ )
25
+ return pool