statewire 0.7.2__tar.gz → 0.8.0__tar.gz
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.
- {statewire-0.7.2 → statewire-0.8.0}/PKG-INFO +1 -1
- {statewire-0.7.2 → statewire-0.8.0}/pyproject.toml +1 -1
- {statewire-0.7.2 → statewire-0.8.0}/src/statewire/api.py +36 -94
- {statewire-0.7.2 → statewire-0.8.0}/src/statewire/client.py +1 -1
- {statewire-0.7.2 → statewire-0.8.0}/src/statewire/ops.py +1 -1
- {statewire-0.7.2 → statewire-0.8.0}/tests/test_authorize.py +60 -16
- {statewire-0.7.2 → statewire-0.8.0}/tests/test_frames.py +2 -2
- {statewire-0.7.2 → statewire-0.8.0}/tests/test_route_auth.py +10 -9
- {statewire-0.7.2 → statewire-0.8.0}/tests/test_ws.py +3 -0
- {statewire-0.7.2 → statewire-0.8.0}/.gitignore +0 -0
- {statewire-0.7.2 → statewire-0.8.0}/README.md +0 -0
- {statewire-0.7.2 → statewire-0.8.0}/examples/__init__.py +0 -0
- {statewire-0.7.2 → statewire-0.8.0}/examples/demo_app.py +0 -0
- {statewire-0.7.2 → statewire-0.8.0}/src/statewire/__init__.py +0 -0
- {statewire-0.7.2 → statewire-0.8.0}/src/statewire/langgraph.py +0 -0
- {statewire-0.7.2 → statewire-0.8.0}/src/statewire/state.py +0 -0
- {statewire-0.7.2 → statewire-0.8.0}/src/statewire/testing.py +0 -0
- {statewire-0.7.2 → statewire-0.8.0}/tests/client_helpers.py +0 -0
- {statewire-0.7.2 → statewire-0.8.0}/tests/statewire_helpers.py +0 -0
- {statewire-0.7.2 → statewire-0.8.0}/tests/test_client.py +0 -0
- {statewire-0.7.2 → statewire-0.8.0}/tests/test_client_ws.py +0 -0
- {statewire-0.7.2 → statewire-0.8.0}/tests/test_commands.py +0 -0
- {statewire-0.7.2 → statewire-0.8.0}/tests/test_context.py +0 -0
- {statewire-0.7.2 → statewire-0.8.0}/tests/test_idle.py +0 -0
- {statewire-0.7.2 → statewire-0.8.0}/tests/test_langgraph.py +0 -0
- {statewire-0.7.2 → statewire-0.8.0}/tests/test_lifespan.py +0 -0
- {statewire-0.7.2 → statewire-0.8.0}/tests/test_meta.py +0 -0
- {statewire-0.7.2 → statewire-0.8.0}/tests/test_observer.py +0 -0
- {statewire-0.7.2 → statewire-0.8.0}/tests/test_start_session.py +0 -0
- {statewire-0.7.2 → statewire-0.8.0}/tests/test_state_proxy.py +0 -0
- {statewire-0.7.2 → statewire-0.8.0}/tests/test_statewire_hostable.py +0 -0
- {statewire-0.7.2 → statewire-0.8.0}/tests/test_stream.py +0 -0
- {statewire-0.7.2 → statewire-0.8.0}/tests/test_testing.py +0 -0
- {statewire-0.7.2 → statewire-0.8.0}/tests/test_writer_lease.py +0 -0
|
@@ -9,8 +9,7 @@ socket). ``POST /frames`` submits follow-up frames under the lease
|
|
|
9
9
|
request header and answers instantly: ``200 {}`` admitted whole, ``429``/
|
|
10
10
|
``409`` the ``{syn}`` refusal body (with/without ``retry``; the 429 also
|
|
11
11
|
carries ``Retry-After`` in whole seconds, rounded up), ``423``
|
|
12
|
-
stale lease, ``400`` malformed (the stream also fins ``error``)
|
|
13
|
-
unauthorized.
|
|
12
|
+
stale lease, ``400`` malformed (the stream also fins ``error``).
|
|
14
13
|
|
|
15
14
|
Admission is per lane, per statement: strictly consecutive seqs, where
|
|
16
15
|
at-or-below the watermark absorbs (a replayed cmd statement re-emits its
|
|
@@ -54,11 +53,13 @@ Handlers that declare a keyword-only ``ctx`` parameter receive a
|
|
|
54
53
|
``StatewireCommandContext``: ``ctx.caller`` is the submitting client's
|
|
55
54
|
handle (holdable across a long run), ``ctx.applied()`` states the
|
|
56
55
|
broadcast state now contains the command's effects.
|
|
57
|
-
``authorize`` gates every
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
56
|
+
``authorize`` (inherited from ``PinnedAPI``) gates every attach and
|
|
57
|
+
every subclass ``@route``, and returns the caller's identity (``None`` =
|
|
58
|
+
anonymous); the server stamps it on the client record per attach — never
|
|
59
|
+
client-supplied — and drops it with the record. Frame submissions under
|
|
60
|
+
a valid lease run with the lease's stamped identity; an invalid or
|
|
61
|
+
missing lease answers ``423``. Handlers read the identity as
|
|
62
|
+
``ctx.caller.identity``: the current session owner, live —
|
|
62
63
|
a newer authorized attach re-stamps it, so per-command trust decisions
|
|
63
64
|
read it before the first await. A client id is a within-thread session
|
|
64
65
|
id, not an authorization boundary: ``authorize`` must gate the thread
|
|
@@ -82,8 +83,9 @@ over WS, holding the connection is the lease.
|
|
|
82
83
|
Subclass ``@route`` handlers are gated behind ``authorize`` automatically:
|
|
83
84
|
the request is authorized before the handler body runs (an ``HTTPException``
|
|
84
85
|
propagates as its status; a WebSocket handshake is denied with close code
|
|
85
|
-
1008), and a handler
|
|
86
|
-
|
|
86
|
+
1008), and a handler consumes the authorized identity with stock FastAPI
|
|
87
|
+
DI: ``identity=Depends(identity)`` with the ``identity`` marker from
|
|
88
|
+
``pinned``.
|
|
87
89
|
|
|
88
90
|
Expected usage::
|
|
89
91
|
|
|
@@ -105,7 +107,6 @@ one atomic envelope, flushed when control returns to the event loop.
|
|
|
105
107
|
|
|
106
108
|
import asyncio
|
|
107
109
|
import contextlib
|
|
108
|
-
import functools
|
|
109
110
|
import inspect
|
|
110
111
|
import json
|
|
111
112
|
import logging
|
|
@@ -113,14 +114,13 @@ import math
|
|
|
113
114
|
import secrets
|
|
114
115
|
import sys
|
|
115
116
|
import time
|
|
116
|
-
import typing
|
|
117
117
|
from dataclasses import dataclass, field
|
|
118
118
|
from typing import Any, AsyncIterator, Awaitable, Callable, Coroutine, Sequence
|
|
119
119
|
|
|
120
120
|
from fastapi import HTTPException, Request, WebSocket, WebSocketException
|
|
121
121
|
from pinned import HostableContext, PinnedAPI
|
|
122
122
|
from pinned.host import invalid_id_reason
|
|
123
|
-
from pinned.routing import route, routes_of
|
|
123
|
+
from pinned.routing import route, routes_of, ungated
|
|
124
124
|
from starlette.requests import HTTPConnection
|
|
125
125
|
from starlette.responses import JSONResponse, Response, StreamingResponse
|
|
126
126
|
from starlette.websockets import WebSocketDisconnect
|
|
@@ -556,54 +556,6 @@ def _reserved_paths() -> set[str]:
|
|
|
556
556
|
return {spec.path for fn in vars(Statewire).values() for spec in routes_of(fn)}
|
|
557
557
|
|
|
558
558
|
|
|
559
|
-
def _authorized_route(fn: Any) -> Any:
|
|
560
|
-
"""Gate a subclass ``@route`` handler behind ``authorize``: the identity is
|
|
561
|
-
injected into an ``identity`` parameter when the handler declares one."""
|
|
562
|
-
is_ws = any(spec.method == "WEBSOCKET" for spec in routes_of(fn))
|
|
563
|
-
conn_name = "websocket" if is_ws else "request"
|
|
564
|
-
sig = inspect.signature(fn)
|
|
565
|
-
injects_identity = "identity" in sig.parameters
|
|
566
|
-
has_conn = conn_name in sig.parameters
|
|
567
|
-
|
|
568
|
-
@functools.wraps(fn)
|
|
569
|
-
async def wrapped(self: "Statewire", **kwargs: Any) -> Any:
|
|
570
|
-
connection = kwargs[conn_name] if has_conn else kwargs.pop(conn_name)
|
|
571
|
-
if is_ws:
|
|
572
|
-
try:
|
|
573
|
-
identity = await self.authorize(connection)
|
|
574
|
-
except HTTPException as exc:
|
|
575
|
-
detail = exc.detail if isinstance(exc.detail, str) else "unauthorized"
|
|
576
|
-
raise WebSocketException(code=1008, reason=detail) from exc
|
|
577
|
-
else:
|
|
578
|
-
identity = await self.authorize(connection)
|
|
579
|
-
if injects_identity:
|
|
580
|
-
kwargs["identity"] = identity
|
|
581
|
-
return await fn(self, **kwargs)
|
|
582
|
-
|
|
583
|
-
# Resolve hints against fn's own module so pinned's signature-driven mount
|
|
584
|
-
# sees real annotations, and expose a FastAPI-injectable connection param.
|
|
585
|
-
conn_type = WebSocket if is_ws else Request
|
|
586
|
-
hints = typing.get_type_hints(fn)
|
|
587
|
-
hints.setdefault(conn_name, conn_type)
|
|
588
|
-
params = [
|
|
589
|
-
p.replace(annotation=hints.get(p.name, p.annotation))
|
|
590
|
-
for p in sig.parameters.values()
|
|
591
|
-
if p.name != "identity"
|
|
592
|
-
]
|
|
593
|
-
if not has_conn:
|
|
594
|
-
params.insert(
|
|
595
|
-
1,
|
|
596
|
-
inspect.Parameter(
|
|
597
|
-
conn_name,
|
|
598
|
-
inspect.Parameter.POSITIONAL_OR_KEYWORD,
|
|
599
|
-
annotation=conn_type,
|
|
600
|
-
),
|
|
601
|
-
)
|
|
602
|
-
wrapped.__signature__ = sig.replace(parameters=params) # type: ignore[attr-defined]
|
|
603
|
-
wrapped.__annotations__ = {k: v for k, v in hints.items() if k != "identity"}
|
|
604
|
-
return wrapped
|
|
605
|
-
|
|
606
|
-
|
|
607
559
|
class Statewire(PinnedAPI):
|
|
608
560
|
stream_queue_size: int = 256
|
|
609
561
|
heartbeat_interval: float = 15.0
|
|
@@ -618,15 +570,13 @@ class Statewire(PinnedAPI):
|
|
|
618
570
|
def __init_subclass__(cls, **kwargs: Any) -> None:
|
|
619
571
|
super().__init_subclass__(**kwargs)
|
|
620
572
|
reserved = _reserved_paths()
|
|
621
|
-
for
|
|
573
|
+
for fn in list(vars(cls).values()):
|
|
622
574
|
for spec in routes_of(fn):
|
|
623
575
|
if spec.path in reserved:
|
|
624
576
|
raise TypeError(
|
|
625
577
|
f"{cls.__name__}.{fn.__name__} declares @route on reserved "
|
|
626
578
|
f"path {spec.path!r}; it is a Statewire protocol endpoint"
|
|
627
579
|
)
|
|
628
|
-
if routes_of(fn):
|
|
629
|
-
setattr(cls, name, _authorized_route(fn))
|
|
630
580
|
# Full-MRO scan so plain mixins (no Statewire base) contribute their
|
|
631
581
|
# @command handlers; the most derived registration of a method wins.
|
|
632
582
|
merged: dict[str, str] = {}
|
|
@@ -927,14 +877,6 @@ class Statewire(PinnedAPI):
|
|
|
927
877
|
|
|
928
878
|
# ─── GET /stream ────────────────────────────────────────
|
|
929
879
|
|
|
930
|
-
async def authorize(self, request: HTTPConnection) -> Any:
|
|
931
|
-
"""Auth seam: override to gate /stream, /commands and /ws; raise
|
|
932
|
-
HTTPException to reject. The return value is the caller's identity —
|
|
933
|
-
any host-chosen object, ``None`` for anonymous — stamped on the client
|
|
934
|
-
record at each attach and each admitted command submission (fresh
|
|
935
|
-
identity wins) and read by handlers via ``ctx.caller.identity``."""
|
|
936
|
-
return None
|
|
937
|
-
|
|
938
880
|
def _register(
|
|
939
881
|
self, client_id: str, identity: Any
|
|
940
882
|
) -> tuple[asyncio.Queue[Any], bytes]:
|
|
@@ -993,14 +935,14 @@ class Statewire(PinnedAPI):
|
|
|
993
935
|
q.put_nowait(item)
|
|
994
936
|
|
|
995
937
|
@route.get("/statewire")
|
|
996
|
-
async def legacy_statewire_meta(self
|
|
938
|
+
async def legacy_statewire_meta(self) -> dict[str, Any]:
|
|
997
939
|
# legacy: superseded by the statewire/commands command
|
|
998
|
-
await self.authorize(request)
|
|
999
940
|
return {
|
|
1000
941
|
"protocol": ops.PROTOCOL_VERSION,
|
|
1001
942
|
"commands": sorted(type(self)._statewire_commands),
|
|
1002
943
|
}
|
|
1003
944
|
|
|
945
|
+
@ungated
|
|
1004
946
|
@route.get("/stream")
|
|
1005
947
|
async def stream(self, request: Request) -> Response:
|
|
1006
948
|
identity = await self.authorize(request)
|
|
@@ -1008,6 +950,7 @@ class Statewire(PinnedAPI):
|
|
|
1008
950
|
q, snapshot = self._register(client_id, identity)
|
|
1009
951
|
return self._stream_response(q, snapshot, self._clients[client_id].lease)
|
|
1010
952
|
|
|
953
|
+
@ungated
|
|
1011
954
|
@route.post("/stream")
|
|
1012
955
|
async def stream_attach(self, request: Request) -> Response:
|
|
1013
956
|
received = time.perf_counter()
|
|
@@ -1132,10 +1075,10 @@ class Statewire(PinnedAPI):
|
|
|
1132
1075
|
|
|
1133
1076
|
# ─── POST /frames ───────────────────────────────────────
|
|
1134
1077
|
|
|
1078
|
+
@ungated
|
|
1135
1079
|
@route.post("/frames")
|
|
1136
1080
|
async def frames(self, request: Request) -> Any:
|
|
1137
1081
|
received = time.perf_counter()
|
|
1138
|
-
identity = await self.authorize(request)
|
|
1139
1082
|
client_id = _client_id_of(request)
|
|
1140
1083
|
raw = await request.body()
|
|
1141
1084
|
try:
|
|
@@ -1157,7 +1100,7 @@ class Statewire(PinnedAPI):
|
|
|
1157
1100
|
lease = request.headers.get(LEASE_HEADER)
|
|
1158
1101
|
if client is None or lease is None or lease != client.lease:
|
|
1159
1102
|
return JSONResponse({"error": "stale-lease"}, status_code=423)
|
|
1160
|
-
outcome, retry = self._admit_frame(client, frame, received
|
|
1103
|
+
outcome, retry = self._admit_frame(client, frame, received)
|
|
1161
1104
|
if outcome == "refuse":
|
|
1162
1105
|
if retry is None:
|
|
1163
1106
|
return JSONResponse(self._refusal_syn(client, retry), status_code=409)
|
|
@@ -1182,7 +1125,7 @@ class Statewire(PinnedAPI):
|
|
|
1182
1125
|
self._evict_streams(client_id, ([ops.encode_envelope({"fin": fin})], True))
|
|
1183
1126
|
|
|
1184
1127
|
def _admit_frame(
|
|
1185
|
-
self, client: _Client, frame: _Frame, received: float
|
|
1128
|
+
self, client: _Client, frame: _Frame, received: float
|
|
1186
1129
|
) -> tuple[str, int | None]:
|
|
1187
1130
|
"""One admission law, both lanes: absorbed statements answer from the
|
|
1188
1131
|
record, then the new suffix is admitted whole or the frame is refused
|
|
@@ -1212,8 +1155,6 @@ class Statewire(PinnedAPI):
|
|
|
1212
1155
|
for statement in cmd_new:
|
|
1213
1156
|
observer.on_admit(statement["method"], "refused")
|
|
1214
1157
|
return "refuse", retry
|
|
1215
|
-
if identity is not _UNSET and (ctx_new or cmd_new):
|
|
1216
|
-
client.identity = identity
|
|
1217
1158
|
for seq, value in ctx_new:
|
|
1218
1159
|
client.context = value
|
|
1219
1160
|
client.ctx_seq = seq
|
|
@@ -1233,10 +1174,10 @@ class Statewire(PinnedAPI):
|
|
|
1233
1174
|
|
|
1234
1175
|
# ─── POST /commands (legacy) ────────────────────────────
|
|
1235
1176
|
|
|
1177
|
+
@ungated
|
|
1236
1178
|
@route.post("/commands")
|
|
1237
1179
|
async def legacy_commands(self, request: Request) -> Any:
|
|
1238
1180
|
received = time.perf_counter()
|
|
1239
|
-
identity = await self.authorize(request)
|
|
1240
1181
|
client_id = _client_id_of(request)
|
|
1241
1182
|
seq = _legacy_command_seq_of(request)
|
|
1242
1183
|
lease = request.headers.get(LEASE_HEADER)
|
|
@@ -1247,7 +1188,7 @@ class Statewire(PinnedAPI):
|
|
|
1247
1188
|
raise HTTPException(status_code=400, detail="body must be JSON") from None
|
|
1248
1189
|
if not isinstance(body, dict):
|
|
1249
1190
|
raise HTTPException(status_code=400, detail="body must be a JSON object")
|
|
1250
|
-
return await self._legacy_submit(client_id, seq, body, lease,
|
|
1191
|
+
return await self._legacy_submit(client_id, seq, body, lease, received)
|
|
1251
1192
|
|
|
1252
1193
|
def _legacy_validate_submission(
|
|
1253
1194
|
self, body: dict[str, Any]
|
|
@@ -1331,10 +1272,9 @@ class Statewire(PinnedAPI):
|
|
|
1331
1272
|
body: Any,
|
|
1332
1273
|
lease: str | None,
|
|
1333
1274
|
received: float,
|
|
1334
|
-
identity: Any = _UNSET,
|
|
1335
1275
|
) -> tuple[str, Any]:
|
|
1336
1276
|
outcome, detail = await self._legacy_admission(
|
|
1337
|
-
client_id, seq, body, lease, received
|
|
1277
|
+
client_id, seq, body, lease, received
|
|
1338
1278
|
)
|
|
1339
1279
|
self._legacy_observe_admit(body, outcome, detail)
|
|
1340
1280
|
return outcome, detail
|
|
@@ -1380,7 +1320,6 @@ class Statewire(PinnedAPI):
|
|
|
1380
1320
|
body: Any,
|
|
1381
1321
|
lease: str | None,
|
|
1382
1322
|
received: float,
|
|
1383
|
-
identity: Any,
|
|
1384
1323
|
) -> tuple[str, Any]:
|
|
1385
1324
|
client = self._clients.get(client_id)
|
|
1386
1325
|
if client is None:
|
|
@@ -1393,10 +1332,6 @@ class Statewire(PinnedAPI):
|
|
|
1393
1332
|
if seq > client.last_seq + 1:
|
|
1394
1333
|
return "gap", client.last_seq + 1
|
|
1395
1334
|
context, slots, rejections = self._legacy_validate_submission(body)
|
|
1396
|
-
# Only an admitted submission may re-stamp; _UNSET keeps the
|
|
1397
|
-
# attach-time identity (WS frames, facade dispatch).
|
|
1398
|
-
if identity is not _UNSET:
|
|
1399
|
-
client.identity = identity
|
|
1400
1335
|
if context is not _UNSET:
|
|
1401
1336
|
client.context = context
|
|
1402
1337
|
if slots:
|
|
@@ -1420,11 +1355,10 @@ class Statewire(PinnedAPI):
|
|
|
1420
1355
|
seq: int,
|
|
1421
1356
|
body: Any,
|
|
1422
1357
|
lease: str | None,
|
|
1423
|
-
identity: Any,
|
|
1424
1358
|
received: float,
|
|
1425
1359
|
) -> Any:
|
|
1426
1360
|
outcome, detail = await self._legacy_admit(
|
|
1427
|
-
client_id, seq, body, lease, received
|
|
1361
|
+
client_id, seq, body, lease, received
|
|
1428
1362
|
)
|
|
1429
1363
|
match outcome:
|
|
1430
1364
|
case "unknown-client":
|
|
@@ -1608,6 +1542,7 @@ class Statewire(PinnedAPI):
|
|
|
1608
1542
|
|
|
1609
1543
|
# ─── WS /ws ─────────────────────────────────────────────
|
|
1610
1544
|
|
|
1545
|
+
@ungated
|
|
1611
1546
|
@route.websocket("/ws")
|
|
1612
1547
|
async def ws(self, websocket: WebSocket) -> None:
|
|
1613
1548
|
client_id = websocket.query_params.get("client", "")
|
|
@@ -1659,7 +1594,7 @@ class Statewire(PinnedAPI):
|
|
|
1659
1594
|
websocket.receive_text(), self.ws_init_timeout
|
|
1660
1595
|
)
|
|
1661
1596
|
except TimeoutError:
|
|
1662
|
-
await
|
|
1597
|
+
await self._ws_deny(websocket, denial)
|
|
1663
1598
|
return None
|
|
1664
1599
|
except WebSocketDisconnect:
|
|
1665
1600
|
return None
|
|
@@ -1669,22 +1604,29 @@ class Statewire(PinnedAPI):
|
|
|
1669
1604
|
try:
|
|
1670
1605
|
frame = _parse_frame(json.loads(raw), first=True)
|
|
1671
1606
|
except (json.JSONDecodeError, UnicodeDecodeError):
|
|
1672
|
-
await
|
|
1607
|
+
await self._ws_deny(websocket, denial)
|
|
1673
1608
|
return None
|
|
1674
1609
|
except _Violation as exc:
|
|
1675
1610
|
await websocket.close(code=1008, reason=exc.reason)
|
|
1676
1611
|
return None
|
|
1677
1612
|
if frame.headers is None:
|
|
1678
|
-
await
|
|
1613
|
+
await self._ws_deny(websocket, denial)
|
|
1679
1614
|
return None
|
|
1680
1615
|
try:
|
|
1681
1616
|
identity = await self.authorize(_headers_connection(websocket, frame.headers))
|
|
1682
1617
|
except HTTPException as exc:
|
|
1683
1618
|
reason = exc.detail if isinstance(exc.detail, str) else "unauthorized"
|
|
1684
|
-
await
|
|
1619
|
+
await self._ws_deny(websocket, reason)
|
|
1685
1620
|
return None
|
|
1686
1621
|
return identity, frame
|
|
1687
1622
|
|
|
1623
|
+
async def _ws_deny(self, websocket: WebSocket, denial: str) -> None:
|
|
1624
|
+
with contextlib.suppress(Exception):
|
|
1625
|
+
await websocket.send_text(
|
|
1626
|
+
json.dumps({"fin": {"reason": "unauthorized", "message": denial}})
|
|
1627
|
+
)
|
|
1628
|
+
await websocket.close(code=4401, reason=denial)
|
|
1629
|
+
|
|
1688
1630
|
async def _ws_pump(
|
|
1689
1631
|
self,
|
|
1690
1632
|
websocket: WebSocket,
|
|
@@ -1816,7 +1758,7 @@ class Statewire(PinnedAPI):
|
|
|
1816
1758
|
identity = await self.authorize(connection)
|
|
1817
1759
|
except HTTPException as exc:
|
|
1818
1760
|
reason = exc.detail if isinstance(exc.detail, str) else "unauthorized"
|
|
1819
|
-
await
|
|
1761
|
+
await self._ws_deny(websocket, reason)
|
|
1820
1762
|
return False
|
|
1821
1763
|
client = self._clients.get(client_id)
|
|
1822
1764
|
if client is not None:
|
|
@@ -990,7 +990,7 @@ class StatewireClient:
|
|
|
990
990
|
if fin is not None:
|
|
991
991
|
self._release_holds()
|
|
992
992
|
reason = fin.get("reason")
|
|
993
|
-
if reason in ("evicted", "error"):
|
|
993
|
+
if reason in ("evicted", "error", "unauthorized"):
|
|
994
994
|
self._episode = reason
|
|
995
995
|
return "reattach"
|
|
996
996
|
if reason == "idle":
|
|
@@ -116,7 +116,7 @@ response header; ``POST /frames`` presents it as the request header and
|
|
|
116
116
|
answers ``200 {}`` (admitted whole, instantly), ``429`` + the syn
|
|
117
117
|
refusal with ``retry`` and a ``Retry-After`` header (whole seconds,
|
|
118
118
|
rounded up), ``409`` + the syn refusal without, ``423`` stale
|
|
119
|
-
lease, ``400`` malformed
|
|
119
|
+
lease, ``400`` malformed. One frame in flight per
|
|
120
120
|
lane over HTTP; a retry may grow (re-include the unadmitted statements
|
|
121
121
|
and continue consecutively), never drop. ``statewire/`` is the reserved
|
|
122
122
|
method namespace; ``statewire/commands`` answers with the sorted
|
|
@@ -1,4 +1,11 @@
|
|
|
1
|
-
from statewire_helpers import
|
|
1
|
+
from statewire_helpers import (
|
|
2
|
+
post_command,
|
|
3
|
+
post_frame,
|
|
4
|
+
replayed_res,
|
|
5
|
+
statewire_client,
|
|
6
|
+
stream_of,
|
|
7
|
+
ws_of,
|
|
8
|
+
)
|
|
2
9
|
from fastapi import HTTPException, Request
|
|
3
10
|
|
|
4
11
|
from statewire import Statewire, command
|
|
@@ -31,14 +38,14 @@ async def test_stream_attach_without_token_is_401_and_registers_nothing():
|
|
|
31
38
|
assert instances[-1]._subscribers == {}
|
|
32
39
|
|
|
33
40
|
|
|
34
|
-
async def
|
|
41
|
+
async def test_command_under_a_valid_lease_runs_without_reauthorization():
|
|
35
42
|
async with statewire_client(Guarded) as (app, client):
|
|
36
43
|
async with stream_of(app, headers={"x-token": "secret"}) as stream:
|
|
37
44
|
await stream.next_event()
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
45
|
+
before = len(runs)
|
|
46
|
+
response = await post_command(client, {"method": "inc", "params": []}, seq=1)
|
|
47
|
+
assert response.status_code == 200
|
|
48
|
+
assert len(runs) == before + 1
|
|
42
49
|
|
|
43
50
|
|
|
44
51
|
async def test_authorized_requests_succeed():
|
|
@@ -151,7 +158,7 @@ async def test_forged_client_id_with_bad_lease_cannot_overwrite_identity():
|
|
|
151
158
|
assert instance.caller.identity == "alice"
|
|
152
159
|
|
|
153
160
|
|
|
154
|
-
async def
|
|
161
|
+
async def test_submissions_never_restamp_identity():
|
|
155
162
|
async with statewire_client(Identified) as (app, client):
|
|
156
163
|
async with stream_of(app, headers={"x-user": "alice"}) as stream:
|
|
157
164
|
await stream.next_event()
|
|
@@ -174,16 +181,44 @@ async def test_unadmitted_lease_valid_submissions_do_not_restamp_identity():
|
|
|
174
181
|
)
|
|
175
182
|
assert gap.status_code == 409
|
|
176
183
|
assert instance.caller.identity == "alice"
|
|
177
|
-
|
|
178
|
-
# its seq and restamps the identity
|
|
179
|
-
rejected = await post_command(
|
|
184
|
+
admitted = await post_command(
|
|
180
185
|
client,
|
|
181
|
-
{"method": "
|
|
186
|
+
{"method": "whoami", "params": []},
|
|
182
187
|
seq=2,
|
|
183
188
|
headers={"x-user": "mallory"},
|
|
184
189
|
)
|
|
185
|
-
assert
|
|
186
|
-
assert instance.caller.identity == "
|
|
190
|
+
assert admitted.status_code == 200
|
|
191
|
+
assert instance.caller.identity == "alice"
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
async def test_frames_ride_the_attach_identity_even_when_authorize_would_reject():
|
|
195
|
+
class GuardedIdentity(Statewire):
|
|
196
|
+
async def lifespan(self):
|
|
197
|
+
self.state = {}
|
|
198
|
+
yield
|
|
199
|
+
|
|
200
|
+
async def authorize(self, request):
|
|
201
|
+
user = request.headers.get("x-user")
|
|
202
|
+
if user is None:
|
|
203
|
+
raise HTTPException(status_code=401, detail="unauthorized")
|
|
204
|
+
return user
|
|
205
|
+
|
|
206
|
+
@command
|
|
207
|
+
async def whoami(self, *, ctx):
|
|
208
|
+
return {"identity": ctx.caller.identity}
|
|
209
|
+
|
|
210
|
+
async with statewire_client(GuardedIdentity) as (app, client):
|
|
211
|
+
async with stream_of(app, headers={"x-user": "alice"}) as stream:
|
|
212
|
+
await stream.next_event()
|
|
213
|
+
frame = {"cmd": [{"method": "whoami", "params": [], "seq": 1}]}
|
|
214
|
+
# No x-user header: an attach with these credentials would be 401.
|
|
215
|
+
response = await post_frame(client, frame)
|
|
216
|
+
assert response.status_code == 200
|
|
217
|
+
env = await stream.next_event()
|
|
218
|
+
while not any(c.get("type") == "result" for c in env.get("cmd", [])):
|
|
219
|
+
env = await stream.next_event()
|
|
220
|
+
(verdict,) = [c for c in env["cmd"] if c.get("type") == "result"]
|
|
221
|
+
assert verdict["payload"] == {"identity": "alice"}
|
|
187
222
|
|
|
188
223
|
|
|
189
224
|
async def test_identity_is_not_settable_via_context_updates():
|
|
@@ -211,33 +246,42 @@ async def test_ws_headers_frame_authorizes_and_releases_the_snapshot():
|
|
|
211
246
|
assert (await ws.command({"method": "inc", "params": []}))["ack"] == 1
|
|
212
247
|
|
|
213
248
|
|
|
214
|
-
async def
|
|
249
|
+
async def test_ws_unauthorized_headers_frame_fins_and_closes_4401():
|
|
215
250
|
async with statewire_client(Guarded) as (app, client):
|
|
216
251
|
async with ws_of(app) as ws:
|
|
217
252
|
assert ws.accepted
|
|
218
253
|
await ws.send_frame({"headers": {"x-token": "wrong"}})
|
|
254
|
+
assert await ws.next_frame() == {
|
|
255
|
+
"fin": {"reason": "unauthorized", "message": "unauthorized"}
|
|
256
|
+
}
|
|
219
257
|
assert await ws.next_frame() is None
|
|
220
258
|
assert ws.close_code == 4401
|
|
221
259
|
assert ws.close_reason == "unauthorized"
|
|
222
260
|
assert instances[-1]._subscribers == {}
|
|
223
261
|
|
|
224
262
|
|
|
225
|
-
async def
|
|
263
|
+
async def test_ws_init_timeout_fins_and_closes_unauthenticated_sockets_4401():
|
|
226
264
|
class Quick(Guarded):
|
|
227
265
|
ws_init_timeout = 0.05
|
|
228
266
|
|
|
229
267
|
async with statewire_client(Quick) as (app, client):
|
|
230
268
|
async with ws_of(app) as ws:
|
|
231
269
|
assert ws.accepted
|
|
270
|
+
assert await ws.next_frame() == {
|
|
271
|
+
"fin": {"reason": "unauthorized", "message": "unauthorized"}
|
|
272
|
+
}
|
|
232
273
|
assert await ws.next_frame() is None
|
|
233
274
|
assert ws.close_code == 4401
|
|
234
275
|
|
|
235
276
|
|
|
236
|
-
async def
|
|
277
|
+
async def test_ws_non_headers_first_frame_on_a_guarded_host_fins_and_closes_4401():
|
|
237
278
|
async with statewire_client(Guarded) as (app, client):
|
|
238
279
|
async with ws_of(app) as ws:
|
|
239
280
|
assert ws.accepted
|
|
240
281
|
await ws.send_frame({"method": "inc", "params": [], "seq": 1})
|
|
282
|
+
assert await ws.next_frame() == {
|
|
283
|
+
"fin": {"reason": "unauthorized", "message": "unauthorized"}
|
|
284
|
+
}
|
|
241
285
|
assert await ws.next_frame() is None
|
|
242
286
|
assert ws.close_code == 4401
|
|
243
287
|
|
|
@@ -305,7 +305,7 @@ async def test_frames_non_array_params_settles_rejected():
|
|
|
305
305
|
]
|
|
306
306
|
|
|
307
307
|
|
|
308
|
-
async def
|
|
308
|
+
async def test_frames_under_a_valid_lease_skip_authorize():
|
|
309
309
|
from fastapi import HTTPException
|
|
310
310
|
|
|
311
311
|
class Guarded(make_counter()):
|
|
@@ -319,7 +319,7 @@ async def test_frames_unauthorized_is_401():
|
|
|
319
319
|
) as stream:
|
|
320
320
|
await stream.next_event()
|
|
321
321
|
response = await post_frame(client, {"cmd": [cmd_statement(1)]})
|
|
322
|
-
assert response.status_code ==
|
|
322
|
+
assert response.status_code == 200
|
|
323
323
|
|
|
324
324
|
|
|
325
325
|
# ─── Ctx lane ───────────────────────────────────────────────
|
|
@@ -2,14 +2,15 @@
|
|
|
2
2
|
|
|
3
3
|
- an unauthorized request answers with the authorize HTTPException's status
|
|
4
4
|
and the handler never runs,
|
|
5
|
-
- a handler
|
|
6
|
-
- a handler
|
|
7
|
-
- FastAPI body/query parameters on the handler keep working
|
|
5
|
+
- a handler consumes the identity via ``Depends(identity)``,
|
|
6
|
+
- a handler declaring no dependency is still gated,
|
|
7
|
+
- FastAPI body/query parameters on the handler keep working,
|
|
8
8
|
- a WebSocket subclass route denies the handshake when authorize rejects.
|
|
9
9
|
"""
|
|
10
10
|
|
|
11
11
|
from statewire_helpers import WSStream, statewire_client
|
|
12
|
-
from fastapi import HTTPException, Request
|
|
12
|
+
from fastapi import Depends, HTTPException, Request, WebSocket
|
|
13
|
+
from pinned import identity
|
|
13
14
|
from pinned.routing import route
|
|
14
15
|
|
|
15
16
|
from statewire import Statewire
|
|
@@ -28,7 +29,7 @@ class Domain(Statewire):
|
|
|
28
29
|
return request.headers.get("x-user")
|
|
29
30
|
|
|
30
31
|
@route.get("/whoami")
|
|
31
|
-
async def whoami(self, identity):
|
|
32
|
+
async def whoami(self, identity=Depends(identity)):
|
|
32
33
|
return {"identity": identity}
|
|
33
34
|
|
|
34
35
|
@route.post("/bump")
|
|
@@ -37,11 +38,11 @@ class Domain(Statewire):
|
|
|
37
38
|
return {"count": self.state["count"] + by, "echo": payload}
|
|
38
39
|
|
|
39
40
|
@route.get("/peek")
|
|
40
|
-
async def peek(self, request: Request, identity):
|
|
41
|
+
async def peek(self, request: Request, identity=Depends(identity)):
|
|
41
42
|
return {"identity": identity, "path": request.url.path}
|
|
42
43
|
|
|
43
44
|
@route.websocket("/tap")
|
|
44
|
-
async def tap(self, websocket, identity):
|
|
45
|
+
async def tap(self, websocket: WebSocket, identity=Depends(identity)):
|
|
45
46
|
await websocket.accept()
|
|
46
47
|
await websocket.send_json({"tapped": identity})
|
|
47
48
|
await websocket.close()
|
|
@@ -55,7 +56,7 @@ async def test_unauthorized_route_answers_the_authorize_status():
|
|
|
55
56
|
assert len(handled) == before
|
|
56
57
|
|
|
57
58
|
|
|
58
|
-
async def
|
|
59
|
+
async def test_depends_identity_receives_the_authorized_identity():
|
|
59
60
|
async with statewire_client(Domain) as (app, client):
|
|
60
61
|
response = await client.get(
|
|
61
62
|
"/threads/t1/whoami", headers={"x-token": "secret", "x-user": "alice"}
|
|
@@ -73,7 +74,7 @@ async def test_gated_handler_without_identity_still_runs_authorized():
|
|
|
73
74
|
assert response.json() == {"count": 1, "echo": {"k": "v"}}
|
|
74
75
|
|
|
75
76
|
|
|
76
|
-
async def
|
|
77
|
+
async def test_body_and_query_params_keep_working():
|
|
77
78
|
async with statewire_client(Domain) as (app, client):
|
|
78
79
|
response = await client.post(
|
|
79
80
|
"/threads/t1/bump",
|
|
@@ -439,6 +439,9 @@ async def test_authorize_rejection_closes_4401_and_registers_nothing():
|
|
|
439
439
|
async with statewire_client(Guarded) as (app, client):
|
|
440
440
|
async with ws_of(app) as ws:
|
|
441
441
|
assert ws.accepted
|
|
442
|
+
assert await ws.next_frame() == {
|
|
443
|
+
"fin": {"reason": "unauthorized", "message": "unauthorized"}
|
|
444
|
+
}
|
|
442
445
|
assert await ws.next_frame() is None
|
|
443
446
|
assert ws.close_code == 4401
|
|
444
447
|
assert ws.close_reason == "unauthorized"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|