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.
Files changed (34) hide show
  1. {statewire-0.7.2 → statewire-0.8.0}/PKG-INFO +1 -1
  2. {statewire-0.7.2 → statewire-0.8.0}/pyproject.toml +1 -1
  3. {statewire-0.7.2 → statewire-0.8.0}/src/statewire/api.py +36 -94
  4. {statewire-0.7.2 → statewire-0.8.0}/src/statewire/client.py +1 -1
  5. {statewire-0.7.2 → statewire-0.8.0}/src/statewire/ops.py +1 -1
  6. {statewire-0.7.2 → statewire-0.8.0}/tests/test_authorize.py +60 -16
  7. {statewire-0.7.2 → statewire-0.8.0}/tests/test_frames.py +2 -2
  8. {statewire-0.7.2 → statewire-0.8.0}/tests/test_route_auth.py +10 -9
  9. {statewire-0.7.2 → statewire-0.8.0}/tests/test_ws.py +3 -0
  10. {statewire-0.7.2 → statewire-0.8.0}/.gitignore +0 -0
  11. {statewire-0.7.2 → statewire-0.8.0}/README.md +0 -0
  12. {statewire-0.7.2 → statewire-0.8.0}/examples/__init__.py +0 -0
  13. {statewire-0.7.2 → statewire-0.8.0}/examples/demo_app.py +0 -0
  14. {statewire-0.7.2 → statewire-0.8.0}/src/statewire/__init__.py +0 -0
  15. {statewire-0.7.2 → statewire-0.8.0}/src/statewire/langgraph.py +0 -0
  16. {statewire-0.7.2 → statewire-0.8.0}/src/statewire/state.py +0 -0
  17. {statewire-0.7.2 → statewire-0.8.0}/src/statewire/testing.py +0 -0
  18. {statewire-0.7.2 → statewire-0.8.0}/tests/client_helpers.py +0 -0
  19. {statewire-0.7.2 → statewire-0.8.0}/tests/statewire_helpers.py +0 -0
  20. {statewire-0.7.2 → statewire-0.8.0}/tests/test_client.py +0 -0
  21. {statewire-0.7.2 → statewire-0.8.0}/tests/test_client_ws.py +0 -0
  22. {statewire-0.7.2 → statewire-0.8.0}/tests/test_commands.py +0 -0
  23. {statewire-0.7.2 → statewire-0.8.0}/tests/test_context.py +0 -0
  24. {statewire-0.7.2 → statewire-0.8.0}/tests/test_idle.py +0 -0
  25. {statewire-0.7.2 → statewire-0.8.0}/tests/test_langgraph.py +0 -0
  26. {statewire-0.7.2 → statewire-0.8.0}/tests/test_lifespan.py +0 -0
  27. {statewire-0.7.2 → statewire-0.8.0}/tests/test_meta.py +0 -0
  28. {statewire-0.7.2 → statewire-0.8.0}/tests/test_observer.py +0 -0
  29. {statewire-0.7.2 → statewire-0.8.0}/tests/test_start_session.py +0 -0
  30. {statewire-0.7.2 → statewire-0.8.0}/tests/test_state_proxy.py +0 -0
  31. {statewire-0.7.2 → statewire-0.8.0}/tests/test_statewire_hostable.py +0 -0
  32. {statewire-0.7.2 → statewire-0.8.0}/tests/test_stream.py +0 -0
  33. {statewire-0.7.2 → statewire-0.8.0}/tests/test_testing.py +0 -0
  34. {statewire-0.7.2 → statewire-0.8.0}/tests/test_writer_lease.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: statewire
3
- Version: 0.7.2
3
+ Version: 0.8.0
4
4
  Summary: Replicate one JSON object over an SSE op stream + a command endpoint
5
5
  Project-URL: Repository, https://github.com/assistant-ui/harness-sdk
6
6
  License-Expression: MIT
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "statewire"
3
- version = "0.7.2"
3
+ version = "0.8.0"
4
4
  description = "Replicate one JSON object over an SSE op stream + a command endpoint"
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -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``), ``401``
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 ingress and returns the caller's identity
58
- (``None`` = anonymous); the server stamps it on the client record per
59
- attach and per admitted command submission — never client-supplied,
60
- never on a rejected ingress — and drops it with the record. Handlers
61
- read it as ``ctx.caller.identity``: the current session owner, live —
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 that declares an ``identity`` parameter receives the
86
- authorized identity in it.
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 name, fn in list(vars(cls).items()):
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, request: Request) -> dict[str, Any]:
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, identity)
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, identity: Any = _UNSET
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, identity, received)
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, identity
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, identity
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 websocket.close(code=4401, reason=denial)
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 websocket.close(code=4401, reason=denial)
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 websocket.close(code=4401, reason=denial)
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 websocket.close(code=4401, reason=reason)
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 websocket.close(code=4401, reason=reason)
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, ``401`` unauthorized. One frame in flight per
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 post_command, replayed_res, statewire_client, stream_of, ws_of
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 test_command_without_token_is_401_and_handler_never_runs():
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
- before = len(runs)
39
- response = await post_command(client, {"method": "inc", "params": []}, seq=1)
40
- assert response.status_code == 401
41
- assert len(runs) == before
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 test_unadmitted_lease_valid_submissions_do_not_restamp_identity():
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
- # a statically-rejected submission is still admitted: it consumes
178
- # its seq and restamps the identity
179
- rejected = await post_command(
184
+ admitted = await post_command(
180
185
  client,
181
- {"method": "no-such-method", "params": []},
186
+ {"method": "whoami", "params": []},
182
187
  seq=2,
183
188
  headers={"x-user": "mallory"},
184
189
  )
185
- assert rejected.status_code == 422
186
- assert instance.caller.identity == "mallory"
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 test_ws_unauthorized_headers_frame_closes_4401():
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 test_ws_init_timeout_closes_unauthenticated_sockets_4401():
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 test_ws_non_headers_first_frame_on_a_guarded_host_closes_4401():
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 test_frames_unauthorized_is_401():
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 == 401
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 declaring ``identity`` receives the authorized identity,
6
- - a handler without ``identity`` is still gated,
7
- - FastAPI body/query parameters on the handler keep working through the wrap,
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 test_identity_parameter_receives_the_authorized_identity():
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 test_body_and_query_params_survive_the_wrap():
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