terp-cap-realtime 0.1.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.
- terp_cap_realtime-0.1.0/.gitignore +47 -0
- terp_cap_realtime-0.1.0/PKG-INFO +7 -0
- terp_cap_realtime-0.1.0/README.md +66 -0
- terp_cap_realtime-0.1.0/pyproject.toml +25 -0
- terp_cap_realtime-0.1.0/src/terp/capabilities/realtime/__init__.py +101 -0
- terp_cap_realtime-0.1.0/src/terp/capabilities/realtime/broker.py +151 -0
- terp_cap_realtime-0.1.0/src/terp/capabilities/realtime/channel.py +171 -0
- terp_cap_realtime-0.1.0/src/terp/capabilities/realtime/py.typed +0 -0
- terp_cap_realtime-0.1.0/src/terp/capabilities/realtime/router.py +458 -0
- terp_cap_realtime-0.1.0/src/terp/capabilities/realtime/tickets.py +117 -0
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# Python
|
|
2
|
+
__pycache__/
|
|
3
|
+
*.py[cod]
|
|
4
|
+
*.egg-info/
|
|
5
|
+
.eggs/
|
|
6
|
+
build/
|
|
7
|
+
dist/
|
|
8
|
+
.venv/
|
|
9
|
+
.venv-*/
|
|
10
|
+
venv/
|
|
11
|
+
.pytest_cache/
|
|
12
|
+
.mypy_cache/
|
|
13
|
+
.ruff_cache/
|
|
14
|
+
.coverage
|
|
15
|
+
htmlcov/
|
|
16
|
+
|
|
17
|
+
# uv
|
|
18
|
+
uv.lock
|
|
19
|
+
|
|
20
|
+
# Node
|
|
21
|
+
node_modules/
|
|
22
|
+
.pnpm-store/
|
|
23
|
+
*.tsbuildinfo
|
|
24
|
+
|
|
25
|
+
# Playwright (conformance e2e) artifacts
|
|
26
|
+
test-results/
|
|
27
|
+
playwright-report/
|
|
28
|
+
blob-report/
|
|
29
|
+
playwright/.cache/
|
|
30
|
+
.last-run.json
|
|
31
|
+
|
|
32
|
+
# Local frontend template render checks
|
|
33
|
+
apps/example/_frontend_tpl_check/
|
|
34
|
+
|
|
35
|
+
# Editor / OS
|
|
36
|
+
.DS_Store
|
|
37
|
+
.idea/
|
|
38
|
+
*.local
|
|
39
|
+
|
|
40
|
+
# Local environment overrides — never commit (a real .env may hold SECRET_KEY).
|
|
41
|
+
# The tracked template is `.env.example`.
|
|
42
|
+
.env
|
|
43
|
+
.env.*
|
|
44
|
+
!.env.example
|
|
45
|
+
!.env.example.jinja
|
|
46
|
+
# Rendered app-declared variables (environment.schema.json) — may hold secrets.
|
|
47
|
+
.app.env
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: terp-cap-realtime
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Terp realtime capability — typed, policy-gated SSE and WebSocket channels with one-use connection tickets.
|
|
5
|
+
License-Expression: Apache-2.0
|
|
6
|
+
Requires-Python: >=3.13
|
|
7
|
+
Requires-Dist: terp-core==0.1.0
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
# terp-cap-realtime
|
|
2
|
+
|
|
3
|
+
The opt-in, sanctioned realtime surface for Terp apps: typed SSE and WebSocket
|
|
4
|
+
channels behind one generated-client handshake.
|
|
5
|
+
|
|
6
|
+
## Backend
|
|
7
|
+
|
|
8
|
+
Declare and register a channel once:
|
|
9
|
+
|
|
10
|
+
```python
|
|
11
|
+
from pydantic import BaseModel
|
|
12
|
+
from terp.capabilities.realtime import RealtimeChannel, register_channel
|
|
13
|
+
|
|
14
|
+
class Notice(BaseModel):
|
|
15
|
+
sequence: int
|
|
16
|
+
text: str
|
|
17
|
+
|
|
18
|
+
NOTICES = register_channel(RealtimeChannel("system.notices", Notice))
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
The default audience is the authenticated principal. Publish to that explicit
|
|
22
|
+
audience (a tenant resolver can return a tenant id instead; use
|
|
23
|
+
`global_audience` only for intentionally global broadcasts):
|
|
24
|
+
|
|
25
|
+
```python
|
|
26
|
+
await publish(NOTICES, Notice(sequence=1, text="Ready"), audience=str(user_id))
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
A bidirectional WebSocket channel adds an inbound Pydantic model + handler. Its
|
|
30
|
+
subscription requirement defaults to VIEWER; its inbound requirement defaults
|
|
31
|
+
to EDITOR, and both can be typed `Role`/`Permission` objects. The handler receives
|
|
32
|
+
the authenticated principal and a validated model, never an untyped frame. Each
|
|
33
|
+
frame runs in a fresh session that closes when its sync or async handler returns;
|
|
34
|
+
apps with a custom session seam pass it as `message_session_provider` to
|
|
35
|
+
`configure_realtime`.
|
|
36
|
+
|
|
37
|
+
The self-registering `ModuleSpec` mounts:
|
|
38
|
+
|
|
39
|
+
- `POST /api/v1/realtime/tickets` — authenticated by the app's normal principal
|
|
40
|
+
provider; applies channel authority; returns a 30-second opaque ticket.
|
|
41
|
+
- `GET /api/v1/realtime/sse/{channel}` — redeems an SSE ticket once.
|
|
42
|
+
- `WS /api/v1/realtime/ws/{channel}` — redeems a WebSocket ticket once.
|
|
43
|
+
|
|
44
|
+
The bearer token stays server-side; it never appears in the URL. Configure
|
|
45
|
+
`principal_validator` to recheck expiry/revocation during long-lived connections.
|
|
46
|
+
Single-process apps use bounded in-memory broker/ticket stores. Multi-replica
|
|
47
|
+
apps wire a shared broker and `RedisConnectionTicketStore` (atomic GET+DEL),
|
|
48
|
+
shipped behind the `terp-cap-redis[realtime]` extra
|
|
49
|
+
(`terp.capabilities.redis.realtime`).
|
|
50
|
+
|
|
51
|
+
## Frontend
|
|
52
|
+
|
|
53
|
+
App modules use the package-root hook, never raw transports:
|
|
54
|
+
|
|
55
|
+
```tsx
|
|
56
|
+
const notices = useRealtimeChannel({
|
|
57
|
+
channel: "system.notices",
|
|
58
|
+
validate: isNotice,
|
|
59
|
+
});
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
The hook mints its ticket through the generated authenticated client, opens the
|
|
63
|
+
native transport internally, and validates every JSON payload with the supplied
|
|
64
|
+
type guard before exposing it. A transient disconnect closes the consumed-ticket
|
|
65
|
+
transport and remints with bounded exponential backoff; `close()` cancels both a
|
|
66
|
+
pending mint and scheduled reconnects.
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "terp-cap-realtime"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Terp realtime capability — typed, policy-gated SSE and WebSocket channels with one-use connection tickets."
|
|
9
|
+
requires-python = ">=3.13"
|
|
10
|
+
license = "Apache-2.0"
|
|
11
|
+
dependencies = [
|
|
12
|
+
"terp-core==0.1.0",
|
|
13
|
+
]
|
|
14
|
+
|
|
15
|
+
# Self-registering: the kernel mounts the authenticated ticket + transport
|
|
16
|
+
# router at /api/v1/realtime behind Policy.default(). App code declares typed
|
|
17
|
+
# channels and publishes through the capability's public registry/broker seam.
|
|
18
|
+
[project.entry-points."terp.capabilities"]
|
|
19
|
+
realtime = "terp.capabilities.realtime:module"
|
|
20
|
+
|
|
21
|
+
# PEP 420 namespace package: this distribution owns only
|
|
22
|
+
# `terp.capabilities.realtime`.
|
|
23
|
+
[tool.hatch.build.targets.wheel]
|
|
24
|
+
sources = ["src"]
|
|
25
|
+
only-include = ["src/terp/capabilities/realtime"]
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
"""terp.capabilities.realtime — sanctioned typed SSE + WebSocket channels.
|
|
2
|
+
|
|
3
|
+
App modules never touch raw transports. They declare a :class:`RealtimeChannel`,
|
|
4
|
+
register it, and publish Pydantic messages through :func:`publish`; the
|
|
5
|
+
self-registering capability exposes the authenticated ticket + transport
|
|
6
|
+
router. Browser code subscribes through ``@terp/react-core``'s
|
|
7
|
+
``useRealtimeChannel`` hook, which mints its one-use ticket with the generated
|
|
8
|
+
client before opening EventSource/WebSocket.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from pydantic import BaseModel
|
|
14
|
+
|
|
15
|
+
from terp.capabilities.realtime.broker import (
|
|
16
|
+
BackpressureError,
|
|
17
|
+
InMemoryRealtimeBroker,
|
|
18
|
+
RealtimeBroker,
|
|
19
|
+
audience_topic,
|
|
20
|
+
configure_broker,
|
|
21
|
+
get_broker,
|
|
22
|
+
)
|
|
23
|
+
from terp.capabilities.realtime.channel import (
|
|
24
|
+
AudienceResolver,
|
|
25
|
+
InboundHandler,
|
|
26
|
+
RealtimeAuthzRef,
|
|
27
|
+
RealtimeChannel,
|
|
28
|
+
clear_channels,
|
|
29
|
+
global_audience,
|
|
30
|
+
get_channel,
|
|
31
|
+
register_channel,
|
|
32
|
+
registered_channels,
|
|
33
|
+
principal_audience,
|
|
34
|
+
)
|
|
35
|
+
from terp.capabilities.realtime.router import (
|
|
36
|
+
HEARTBEAT_SECONDS,
|
|
37
|
+
MAX_INBOUND_BYTES,
|
|
38
|
+
TICKET_TTL_SECONDS,
|
|
39
|
+
MessageSessionProvider,
|
|
40
|
+
PrincipalValidator,
|
|
41
|
+
configure_realtime,
|
|
42
|
+
module,
|
|
43
|
+
reset_realtime_configuration,
|
|
44
|
+
router,
|
|
45
|
+
)
|
|
46
|
+
from terp.capabilities.realtime.tickets import (
|
|
47
|
+
ConnectionTicket,
|
|
48
|
+
ConnectionTicketStore,
|
|
49
|
+
InMemoryConnectionTicketStore,
|
|
50
|
+
configure_ticket_store,
|
|
51
|
+
get_ticket_store,
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
async def publish(
|
|
56
|
+
channel: RealtimeChannel, message: BaseModel, *, audience: str
|
|
57
|
+
) -> None:
|
|
58
|
+
"""Validate and publish *message* to one explicit channel *audience*."""
|
|
59
|
+
if get_channel(channel.name) != channel:
|
|
60
|
+
raise ValueError(
|
|
61
|
+
f"realtime channel {channel.name!r} is not registered with this declaration"
|
|
62
|
+
)
|
|
63
|
+
validated = channel.outbound_model.model_validate(message)
|
|
64
|
+
await get_broker().publish(
|
|
65
|
+
audience_topic(channel.name, audience), validated.model_dump_json()
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
__all__ = [
|
|
70
|
+
"AudienceResolver",
|
|
71
|
+
"BackpressureError",
|
|
72
|
+
"ConnectionTicket",
|
|
73
|
+
"ConnectionTicketStore",
|
|
74
|
+
"HEARTBEAT_SECONDS",
|
|
75
|
+
"InboundHandler",
|
|
76
|
+
"InMemoryConnectionTicketStore",
|
|
77
|
+
"InMemoryRealtimeBroker",
|
|
78
|
+
"MAX_INBOUND_BYTES",
|
|
79
|
+
"MessageSessionProvider",
|
|
80
|
+
"PrincipalValidator",
|
|
81
|
+
"RealtimeBroker",
|
|
82
|
+
"RealtimeChannel",
|
|
83
|
+
"RealtimeAuthzRef",
|
|
84
|
+
"TICKET_TTL_SECONDS",
|
|
85
|
+
"clear_channels",
|
|
86
|
+
"audience_topic",
|
|
87
|
+
"configure_broker",
|
|
88
|
+
"configure_realtime",
|
|
89
|
+
"configure_ticket_store",
|
|
90
|
+
"get_broker",
|
|
91
|
+
"get_channel",
|
|
92
|
+
"get_ticket_store",
|
|
93
|
+
"global_audience",
|
|
94
|
+
"module",
|
|
95
|
+
"publish",
|
|
96
|
+
"principal_audience",
|
|
97
|
+
"register_channel",
|
|
98
|
+
"registered_channels",
|
|
99
|
+
"reset_realtime_configuration",
|
|
100
|
+
"router",
|
|
101
|
+
]
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
"""Realtime broker port + bounded in-process implementation.
|
|
2
|
+
|
|
3
|
+
The broker is the fan-out seam: publishers submit already-validated JSON and
|
|
4
|
+
each subscriber gets a bounded queue. A slow consumer is disconnected instead
|
|
5
|
+
of growing memory without bound; its transport observes ``BackpressureError``
|
|
6
|
+
and closes. Multi-replica deployments replace this per-process adapter with a
|
|
7
|
+
shared broker (Redis/pubsub, managed messaging) through ``configure_broker``.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import asyncio
|
|
13
|
+
from abc import ABC, abstractmethod
|
|
14
|
+
from collections.abc import AsyncIterator
|
|
15
|
+
from dataclasses import dataclass
|
|
16
|
+
from threading import RLock
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class BackpressureError(RuntimeError):
|
|
20
|
+
"""A subscriber fell behind its bounded queue and must reconnect."""
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def audience_topic(channel: str, audience: str) -> str:
|
|
24
|
+
"""Opaque broker topic for one declared channel + authorized audience."""
|
|
25
|
+
normalized = audience.strip()
|
|
26
|
+
if not normalized or len(normalized) > 500 or "\x00" in normalized:
|
|
27
|
+
raise ValueError("realtime audience must be a non-empty string (max 500 chars)")
|
|
28
|
+
return f"{channel}\x00{normalized}"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass(frozen=True)
|
|
32
|
+
class _Overflow:
|
|
33
|
+
pass
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
_OVERFLOW = _Overflow()
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class RealtimeBroker(ABC):
|
|
40
|
+
"""Publish validated JSON and subscribe to one declared channel name."""
|
|
41
|
+
|
|
42
|
+
@abstractmethod
|
|
43
|
+
async def publish(self, channel: str, payload: str) -> None: ...
|
|
44
|
+
|
|
45
|
+
@abstractmethod
|
|
46
|
+
def stream(self, channel: str) -> AsyncIterator[str]: ...
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@dataclass(eq=False)
|
|
50
|
+
class _Subscriber:
|
|
51
|
+
loop: asyncio.AbstractEventLoop
|
|
52
|
+
queue: asyncio.Queue[str | _Overflow]
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class InMemoryRealtimeBroker(RealtimeBroker):
|
|
56
|
+
"""Thread-safe, per-process fan-out with bounded queues.
|
|
57
|
+
|
|
58
|
+
``publish`` may run on an event loop different from a subscriber's (or be
|
|
59
|
+
invoked via ``asyncio.run`` from a sync service hook), so delivery uses
|
|
60
|
+
``call_soon_threadsafe`` into the queue owner's loop. Queue overflow posts
|
|
61
|
+
one terminal marker and unregisters the subscriber.
|
|
62
|
+
"""
|
|
63
|
+
|
|
64
|
+
def __init__(self, *, queue_size: int = 100) -> None:
|
|
65
|
+
if queue_size <= 0:
|
|
66
|
+
raise ValueError("realtime queue_size must be positive")
|
|
67
|
+
self._queue_size = queue_size
|
|
68
|
+
self._lock = RLock()
|
|
69
|
+
self._subscribers: dict[str, set[_Subscriber]] = {}
|
|
70
|
+
|
|
71
|
+
async def publish(self, channel: str, payload: str) -> None:
|
|
72
|
+
with self._lock:
|
|
73
|
+
subscribers = tuple(self._subscribers.get(channel, ()))
|
|
74
|
+
for subscriber in subscribers:
|
|
75
|
+
subscriber.loop.call_soon_threadsafe(
|
|
76
|
+
self._deliver, channel, subscriber, payload
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
def stream(self, channel: str) -> AsyncIterator[str]:
|
|
80
|
+
return self._subscription(channel)
|
|
81
|
+
|
|
82
|
+
async def _subscription(self, channel: str) -> AsyncIterator[str]:
|
|
83
|
+
subscriber = _Subscriber(
|
|
84
|
+
loop=asyncio.get_running_loop(),
|
|
85
|
+
queue=asyncio.Queue(maxsize=self._queue_size),
|
|
86
|
+
)
|
|
87
|
+
with self._lock:
|
|
88
|
+
self._subscribers.setdefault(channel, set()).add(subscriber)
|
|
89
|
+
try:
|
|
90
|
+
while True:
|
|
91
|
+
item = await subscriber.queue.get()
|
|
92
|
+
if item is _OVERFLOW:
|
|
93
|
+
raise BackpressureError(
|
|
94
|
+
f"realtime subscriber for {channel!r} exceeded its queue"
|
|
95
|
+
)
|
|
96
|
+
yield item
|
|
97
|
+
finally:
|
|
98
|
+
self._discard(channel, subscriber)
|
|
99
|
+
|
|
100
|
+
def _deliver(self, channel: str, subscriber: _Subscriber, payload: str) -> None:
|
|
101
|
+
try:
|
|
102
|
+
subscriber.queue.put_nowait(payload)
|
|
103
|
+
except asyncio.QueueFull:
|
|
104
|
+
self._discard(channel, subscriber)
|
|
105
|
+
while not subscriber.queue.empty():
|
|
106
|
+
subscriber.queue.get_nowait()
|
|
107
|
+
subscriber.queue.put_nowait(_OVERFLOW)
|
|
108
|
+
|
|
109
|
+
def _discard(self, channel: str, subscriber: _Subscriber) -> None:
|
|
110
|
+
with self._lock:
|
|
111
|
+
subscribers = self._subscribers.get(channel)
|
|
112
|
+
if subscribers is None:
|
|
113
|
+
return
|
|
114
|
+
subscribers.discard(subscriber)
|
|
115
|
+
if not subscribers:
|
|
116
|
+
self._subscribers.pop(channel, None)
|
|
117
|
+
|
|
118
|
+
def reset(self) -> None:
|
|
119
|
+
"""Drop every subscriber (test-isolation seam)."""
|
|
120
|
+
with self._lock:
|
|
121
|
+
self._subscribers.clear()
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
_configured_broker: RealtimeBroker | None = None
|
|
125
|
+
_configuration_lock = RLock()
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def configure_broker(broker: RealtimeBroker | None) -> None:
|
|
129
|
+
"""Install *broker* process-wide (``None`` resets to the lazy default)."""
|
|
130
|
+
global _configured_broker
|
|
131
|
+
with _configuration_lock:
|
|
132
|
+
_configured_broker = broker
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def get_broker() -> RealtimeBroker:
|
|
136
|
+
"""The configured broker, creating the bounded in-memory default lazily."""
|
|
137
|
+
global _configured_broker
|
|
138
|
+
with _configuration_lock:
|
|
139
|
+
if _configured_broker is None:
|
|
140
|
+
_configured_broker = InMemoryRealtimeBroker()
|
|
141
|
+
return _configured_broker
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
__all__ = [
|
|
145
|
+
"BackpressureError",
|
|
146
|
+
"InMemoryRealtimeBroker",
|
|
147
|
+
"RealtimeBroker",
|
|
148
|
+
"audience_topic",
|
|
149
|
+
"configure_broker",
|
|
150
|
+
"get_broker",
|
|
151
|
+
]
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
"""Typed realtime channel declarations + the process registry.
|
|
2
|
+
|
|
3
|
+
A channel is the realtime analogue of ``EventDefinition``: app code names it
|
|
4
|
+
once and binds its wire payload to Pydantic models. The transport never accepts
|
|
5
|
+
an untyped dict. ``mode='sse'`` is server-push only; ``mode='websocket'`` may
|
|
6
|
+
add an inbound model + handler for bidirectional messages.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import re
|
|
12
|
+
from collections.abc import Awaitable, Callable
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
from threading import RLock
|
|
15
|
+
from typing import Literal
|
|
16
|
+
|
|
17
|
+
from pydantic import BaseModel
|
|
18
|
+
from sqlmodel import Session
|
|
19
|
+
|
|
20
|
+
from terp.core import (
|
|
21
|
+
AuthorizationRequirement,
|
|
22
|
+
Permission,
|
|
23
|
+
Principal,
|
|
24
|
+
Role,
|
|
25
|
+
Roles,
|
|
26
|
+
as_role,
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
_CHANNEL_RE = re.compile(r"^[a-z][a-z0-9]*(?:[._-][a-z0-9]+)*$")
|
|
30
|
+
|
|
31
|
+
InboundHandler = Callable[[Session, Principal, BaseModel], None | Awaitable[None]]
|
|
32
|
+
AudienceResolver = Callable[[Session, Principal], str]
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def principal_audience(_session: Session, principal: Principal) -> str:
|
|
36
|
+
"""Default audience: only connections minted for this principal."""
|
|
37
|
+
return str(principal.id)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def global_audience(_session: Session, _principal: Principal) -> str:
|
|
41
|
+
"""Explicit opt-in audience for a channel intentionally shared by all subscribers."""
|
|
42
|
+
return "global"
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
RealtimeAuthzRef = AuthorizationRequirement | Permission | Role | Roles
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _requirement(value: RealtimeAuthzRef) -> AuthorizationRequirement:
|
|
49
|
+
if isinstance(value, AuthorizationRequirement):
|
|
50
|
+
return value
|
|
51
|
+
if isinstance(value, Permission):
|
|
52
|
+
return AuthorizationRequirement.from_permission(value)
|
|
53
|
+
return AuthorizationRequirement.from_role(as_role(value))
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@dataclass(frozen=True, init=False)
|
|
57
|
+
class RealtimeChannel:
|
|
58
|
+
"""One typed, authorized realtime stream.
|
|
59
|
+
|
|
60
|
+
``requirement`` is the read authority for minting a ticket. Role floors and
|
|
61
|
+
permissions use the same normalized objects as ``Policy``; a permission is
|
|
62
|
+
denied fail-closed unless the realtime capability is configured with a
|
|
63
|
+
permission enforcer. Each ``on_message`` call receives a fresh per-frame
|
|
64
|
+
session that closes when the handler completes. ``inbound_model`` /
|
|
65
|
+
``on_message`` are both required or both absent, and only WebSocket channels
|
|
66
|
+
may declare them.
|
|
67
|
+
"""
|
|
68
|
+
|
|
69
|
+
name: str
|
|
70
|
+
outbound_model: type[BaseModel]
|
|
71
|
+
mode: Literal["sse", "websocket"] = "sse"
|
|
72
|
+
requirement: AuthorizationRequirement
|
|
73
|
+
inbound_requirement: AuthorizationRequirement
|
|
74
|
+
inbound_model: type[BaseModel] | None = None
|
|
75
|
+
on_message: InboundHandler | None = None
|
|
76
|
+
audience: AudienceResolver
|
|
77
|
+
|
|
78
|
+
def __init__(
|
|
79
|
+
self,
|
|
80
|
+
name: str,
|
|
81
|
+
outbound_model: type[BaseModel],
|
|
82
|
+
*,
|
|
83
|
+
mode: Literal["sse", "websocket"] = "sse",
|
|
84
|
+
requirement: RealtimeAuthzRef = Roles.VIEWER,
|
|
85
|
+
inbound_requirement: RealtimeAuthzRef = Roles.EDITOR,
|
|
86
|
+
inbound_model: type[BaseModel] | None = None,
|
|
87
|
+
on_message: InboundHandler | None = None,
|
|
88
|
+
audience: AudienceResolver = principal_audience,
|
|
89
|
+
) -> None:
|
|
90
|
+
if _CHANNEL_RE.fullmatch(name) is None:
|
|
91
|
+
raise ValueError(
|
|
92
|
+
"RealtimeChannel.name must be a lowercase dotted/dashed token, "
|
|
93
|
+
f"got {name!r}"
|
|
94
|
+
)
|
|
95
|
+
if not isinstance(outbound_model, type) or not issubclass(
|
|
96
|
+
outbound_model, BaseModel
|
|
97
|
+
):
|
|
98
|
+
raise TypeError("RealtimeChannel.outbound_model must be a Pydantic model")
|
|
99
|
+
if mode not in {"sse", "websocket"}:
|
|
100
|
+
raise ValueError("RealtimeChannel.mode must be 'sse' or 'websocket'")
|
|
101
|
+
has_model = inbound_model is not None
|
|
102
|
+
has_handler = on_message is not None
|
|
103
|
+
if has_model != has_handler:
|
|
104
|
+
raise ValueError(
|
|
105
|
+
"RealtimeChannel.inbound_model and on_message must be declared together"
|
|
106
|
+
)
|
|
107
|
+
if has_model and mode != "websocket":
|
|
108
|
+
raise ValueError("only WebSocket channels can receive inbound messages")
|
|
109
|
+
if has_model and (
|
|
110
|
+
not isinstance(inbound_model, type)
|
|
111
|
+
or not issubclass(inbound_model, BaseModel)
|
|
112
|
+
):
|
|
113
|
+
raise TypeError("RealtimeChannel.inbound_model must be a Pydantic model")
|
|
114
|
+
if not callable(audience):
|
|
115
|
+
raise TypeError("RealtimeChannel.audience must be callable")
|
|
116
|
+
object.__setattr__(self, "name", name)
|
|
117
|
+
object.__setattr__(self, "outbound_model", outbound_model)
|
|
118
|
+
object.__setattr__(self, "mode", mode)
|
|
119
|
+
object.__setattr__(self, "requirement", _requirement(requirement))
|
|
120
|
+
object.__setattr__(
|
|
121
|
+
self, "inbound_requirement", _requirement(inbound_requirement)
|
|
122
|
+
)
|
|
123
|
+
object.__setattr__(self, "inbound_model", inbound_model)
|
|
124
|
+
object.__setattr__(self, "on_message", on_message)
|
|
125
|
+
object.__setattr__(self, "audience", audience)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
_lock = RLock()
|
|
129
|
+
_channels: dict[str, RealtimeChannel] = {}
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def register_channel(channel: RealtimeChannel) -> RealtimeChannel:
|
|
133
|
+
"""Register *channel* exactly once (idempotent for the same declaration)."""
|
|
134
|
+
with _lock:
|
|
135
|
+
existing = _channels.get(channel.name)
|
|
136
|
+
if existing is not None and existing != channel:
|
|
137
|
+
raise ValueError(f"duplicate realtime channel declaration: {channel.name!r}")
|
|
138
|
+
_channels[channel.name] = channel
|
|
139
|
+
return channel
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def get_channel(name: str) -> RealtimeChannel | None:
|
|
143
|
+
"""The declared channel named *name*, or ``None`` (never a dynamic topic)."""
|
|
144
|
+
with _lock:
|
|
145
|
+
return _channels.get(name)
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def registered_channels() -> tuple[RealtimeChannel, ...]:
|
|
149
|
+
"""All declarations, sorted for deterministic introspection/tests."""
|
|
150
|
+
with _lock:
|
|
151
|
+
return tuple(_channels[name] for name in sorted(_channels))
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def clear_channels() -> None:
|
|
155
|
+
"""Drop the registry (test-isolation seam; declarations are import-time)."""
|
|
156
|
+
with _lock:
|
|
157
|
+
_channels.clear()
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
__all__ = [
|
|
161
|
+
"AudienceResolver",
|
|
162
|
+
"InboundHandler",
|
|
163
|
+
"RealtimeAuthzRef",
|
|
164
|
+
"RealtimeChannel",
|
|
165
|
+
"clear_channels",
|
|
166
|
+
"get_channel",
|
|
167
|
+
"global_audience",
|
|
168
|
+
"principal_audience",
|
|
169
|
+
"register_channel",
|
|
170
|
+
"registered_channels",
|
|
171
|
+
]
|
|
File without changes
|
|
@@ -0,0 +1,458 @@
|
|
|
1
|
+
"""Authenticated ticket mint + SSE/WebSocket transport endpoints.
|
|
2
|
+
|
|
3
|
+
The HTTP mint endpoint runs behind the capability's normal deny-by-default
|
|
4
|
+
``Policy`` and receives the live principal from the app's configured auth seam.
|
|
5
|
+
It applies the channel's typed authority requirement, then captures that
|
|
6
|
+
principal in a 30-second, single-use ticket. Browser-native transports redeem
|
|
7
|
+
the opaque ticket at handshake; the bearer token never enters a URL.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import asyncio
|
|
13
|
+
import json
|
|
14
|
+
from collections.abc import AsyncIterator, Callable, Iterator
|
|
15
|
+
from contextlib import contextmanager
|
|
16
|
+
from inspect import isawaitable, iscoroutinefunction
|
|
17
|
+
from typing import Annotated, Literal
|
|
18
|
+
|
|
19
|
+
from fastapi import APIRouter, Depends, Query, Request, WebSocket, WebSocketDisconnect
|
|
20
|
+
from pydantic import BaseModel, Field, ValidationError
|
|
21
|
+
from sqlmodel import Session
|
|
22
|
+
from starlette.responses import StreamingResponse
|
|
23
|
+
|
|
24
|
+
from terp.core import (
|
|
25
|
+
AuthorizationRequirement,
|
|
26
|
+
ModuleSpec,
|
|
27
|
+
PermissionDeniedError,
|
|
28
|
+
PermissionEnforcer,
|
|
29
|
+
Policy,
|
|
30
|
+
Principal,
|
|
31
|
+
SessionDep,
|
|
32
|
+
bind_audit_actor,
|
|
33
|
+
get_principal,
|
|
34
|
+
get_session,
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
from terp.capabilities.realtime.broker import (
|
|
38
|
+
BackpressureError,
|
|
39
|
+
audience_topic,
|
|
40
|
+
get_broker,
|
|
41
|
+
)
|
|
42
|
+
from terp.capabilities.realtime.channel import RealtimeChannel, get_channel
|
|
43
|
+
from terp.capabilities.realtime.tickets import ConnectionTicket, get_ticket_store
|
|
44
|
+
|
|
45
|
+
TICKET_TTL_SECONDS = 30
|
|
46
|
+
HEARTBEAT_SECONDS = 15.0
|
|
47
|
+
MAX_INBOUND_BYTES = 64 * 1024
|
|
48
|
+
|
|
49
|
+
PrincipalValidator = Callable[[Principal, str], bool]
|
|
50
|
+
MessageSessionProvider = Callable[[], Iterator[Session]]
|
|
51
|
+
|
|
52
|
+
_permission_enforcer: PermissionEnforcer | None = None
|
|
53
|
+
_principal_validator: PrincipalValidator | None = None
|
|
54
|
+
_message_session_provider: MessageSessionProvider | None = None
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class TicketRequest(BaseModel):
|
|
58
|
+
channel: str = Field(min_length=1, max_length=200)
|
|
59
|
+
transport: Literal["sse", "websocket"]
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class TicketResponse(BaseModel):
|
|
63
|
+
ticket: str = Field(min_length=1, max_length=200)
|
|
64
|
+
expires_in: int = Field(gt=0)
|
|
65
|
+
channel: str = Field(min_length=1, max_length=200)
|
|
66
|
+
transport: Literal["sse", "websocket"]
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def configure_realtime(
|
|
70
|
+
*,
|
|
71
|
+
permission_enforcer: PermissionEnforcer | None = None,
|
|
72
|
+
principal_validator: PrincipalValidator | None = None,
|
|
73
|
+
message_session_provider: MessageSessionProvider | None = None,
|
|
74
|
+
) -> None:
|
|
75
|
+
"""Wire optional authorization/revocation seams at composition time.
|
|
76
|
+
|
|
77
|
+
A channel whose requirement is a Permission denies fail-closed unless
|
|
78
|
+
``permission_enforcer`` is present. ``principal_validator`` may revalidate
|
|
79
|
+
long-lived connections at handshake/heartbeat/frame boundaries; without it,
|
|
80
|
+
authority is the live principal captured by the 30-second ticket mint.
|
|
81
|
+
``message_session_provider`` supplies one fresh session per inbound frame;
|
|
82
|
+
the core request-session provider is the default.
|
|
83
|
+
"""
|
|
84
|
+
global _permission_enforcer, _principal_validator, _message_session_provider
|
|
85
|
+
_permission_enforcer = permission_enforcer
|
|
86
|
+
_principal_validator = principal_validator
|
|
87
|
+
_message_session_provider = message_session_provider
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def reset_realtime_configuration() -> None:
|
|
91
|
+
"""Restore optional seam defaults (test isolation)."""
|
|
92
|
+
configure_realtime()
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _authorize_requirement(
|
|
96
|
+
requirement: AuthorizationRequirement,
|
|
97
|
+
principal: Principal,
|
|
98
|
+
session: Session,
|
|
99
|
+
) -> None:
|
|
100
|
+
if principal.role.rank < requirement.min_rank:
|
|
101
|
+
raise PermissionDeniedError()
|
|
102
|
+
if requirement.kind == "permission" and (
|
|
103
|
+
_permission_enforcer is None
|
|
104
|
+
or not _permission_enforcer(session, principal.id, requirement.name)
|
|
105
|
+
):
|
|
106
|
+
raise PermissionDeniedError()
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _authorize(channel: RealtimeChannel, principal: Principal, session: Session) -> None:
|
|
110
|
+
_authorize_requirement(channel.requirement, principal, session)
|
|
111
|
+
if channel.inbound_model is not None:
|
|
112
|
+
_authorize_requirement(channel.inbound_requirement, principal, session)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _validate_live(ticket: ConnectionTicket) -> bool:
|
|
116
|
+
return _principal_validator is None or _principal_validator(
|
|
117
|
+
ticket.principal, ticket.credential
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
async def _validate_live_async(ticket: ConnectionTicket) -> bool:
|
|
122
|
+
validator = _principal_validator
|
|
123
|
+
if validator is None:
|
|
124
|
+
return True
|
|
125
|
+
return await asyncio.to_thread(
|
|
126
|
+
validator, ticket.principal, ticket.credential
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
@contextmanager
|
|
131
|
+
def _message_session() -> Iterator[Session]:
|
|
132
|
+
sessions = (_message_session_provider or get_session)()
|
|
133
|
+
try:
|
|
134
|
+
session = next(sessions)
|
|
135
|
+
except StopIteration as exc:
|
|
136
|
+
raise RuntimeError("realtime message session provider yielded no session") from exc
|
|
137
|
+
try:
|
|
138
|
+
yield session
|
|
139
|
+
finally:
|
|
140
|
+
close = getattr(sessions, "close", None)
|
|
141
|
+
if close is not None:
|
|
142
|
+
close()
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _handler_is_async(handler: Callable[..., object]) -> bool:
|
|
146
|
+
return iscoroutinefunction(handler) or iscoroutinefunction(
|
|
147
|
+
getattr(handler, "__call__", None)
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def _run_sync_handler(
|
|
152
|
+
handler: Callable[..., object],
|
|
153
|
+
ticket: ConnectionTicket,
|
|
154
|
+
message: BaseModel,
|
|
155
|
+
) -> None:
|
|
156
|
+
with _message_session() as session, bind_audit_actor(ticket.principal.id):
|
|
157
|
+
result = handler(session, ticket.principal, message)
|
|
158
|
+
if isawaitable(result):
|
|
159
|
+
close = getattr(result, "close", None)
|
|
160
|
+
if close is not None:
|
|
161
|
+
close()
|
|
162
|
+
raise TypeError(
|
|
163
|
+
"realtime sync handler returned an awaitable; declare it with async def"
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
async def _run_inbound_handler(
|
|
168
|
+
handler: Callable[..., object],
|
|
169
|
+
ticket: ConnectionTicket,
|
|
170
|
+
message: BaseModel,
|
|
171
|
+
) -> None:
|
|
172
|
+
if not _handler_is_async(handler):
|
|
173
|
+
await asyncio.to_thread(_run_sync_handler, handler, ticket, message)
|
|
174
|
+
return
|
|
175
|
+
with _message_session() as session, bind_audit_actor(ticket.principal.id):
|
|
176
|
+
result = handler(session, ticket.principal, message)
|
|
177
|
+
if not isawaitable(result):
|
|
178
|
+
raise TypeError("realtime async handler returned a non-awaitable")
|
|
179
|
+
await result
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def _bearer_credential(request: Request) -> str:
|
|
183
|
+
header = request.headers.get("Authorization", "")
|
|
184
|
+
if not header.lower().startswith("bearer "):
|
|
185
|
+
return ""
|
|
186
|
+
return header[7:].strip()
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
router = APIRouter(tags=["realtime"])
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
@router.post("/tickets", response_model=TicketResponse, status_code=201)
|
|
193
|
+
def mint_ticket(
|
|
194
|
+
payload: TicketRequest,
|
|
195
|
+
request: Request,
|
|
196
|
+
session: SessionDep,
|
|
197
|
+
principal: Principal | None = Depends(get_principal),
|
|
198
|
+
) -> TicketResponse:
|
|
199
|
+
if principal is None:
|
|
200
|
+
# Defensive: the module guard already rejects this endpoint, but the
|
|
201
|
+
# handler remains fail-closed when called directly in tests.
|
|
202
|
+
from terp.core import AuthenticationError
|
|
203
|
+
|
|
204
|
+
raise AuthenticationError()
|
|
205
|
+
channel = get_channel(payload.channel)
|
|
206
|
+
if channel is None or channel.mode != payload.transport:
|
|
207
|
+
# Do not reveal whether a guessed channel exists under another mode.
|
|
208
|
+
raise PermissionDeniedError()
|
|
209
|
+
_authorize(channel, principal, session)
|
|
210
|
+
connection_ticket = ConnectionTicket(
|
|
211
|
+
principal=principal,
|
|
212
|
+
channel=channel.name,
|
|
213
|
+
transport=payload.transport,
|
|
214
|
+
credential=_bearer_credential(request),
|
|
215
|
+
audience=channel.audience(session, principal),
|
|
216
|
+
)
|
|
217
|
+
if not _validate_live(connection_ticket):
|
|
218
|
+
from terp.core import AuthenticationError
|
|
219
|
+
|
|
220
|
+
raise AuthenticationError()
|
|
221
|
+
ticket = get_ticket_store().issue(
|
|
222
|
+
connection_ticket,
|
|
223
|
+
ttl_seconds=TICKET_TTL_SECONDS,
|
|
224
|
+
)
|
|
225
|
+
return TicketResponse(
|
|
226
|
+
ticket=ticket,
|
|
227
|
+
expires_in=TICKET_TTL_SECONDS,
|
|
228
|
+
channel=channel.name,
|
|
229
|
+
transport=payload.transport,
|
|
230
|
+
)
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def _consume_ticket(
|
|
234
|
+
token: str, *, channel_name: str, transport: str
|
|
235
|
+
) -> ConnectionTicket | None:
|
|
236
|
+
return get_ticket_store().consume(
|
|
237
|
+
token, channel=channel_name, transport=transport
|
|
238
|
+
)
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def _sse_data(payload: str) -> bytes:
|
|
242
|
+
# The broker accepts only Pydantic-produced compact JSON; replace CR/LF as
|
|
243
|
+
# defense in depth so one payload can never inject an SSE field/event.
|
|
244
|
+
safe = payload.replace("\r", "").replace("\n", "")
|
|
245
|
+
return f"data: {safe}\n\n".encode("utf-8")
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
async def _sse_stream(
|
|
249
|
+
channel: RealtimeChannel,
|
|
250
|
+
ticket: ConnectionTicket,
|
|
251
|
+
*,
|
|
252
|
+
heartbeat_seconds: float = HEARTBEAT_SECONDS,
|
|
253
|
+
) -> AsyncIterator[bytes]:
|
|
254
|
+
iterator = get_broker().stream(
|
|
255
|
+
audience_topic(channel.name, ticket.audience)
|
|
256
|
+
).__aiter__()
|
|
257
|
+
pending = asyncio.create_task(anext(iterator))
|
|
258
|
+
try:
|
|
259
|
+
while True:
|
|
260
|
+
done, _ = await asyncio.wait(
|
|
261
|
+
{pending}, timeout=heartbeat_seconds
|
|
262
|
+
)
|
|
263
|
+
if not done:
|
|
264
|
+
if not await _validate_live_async(ticket):
|
|
265
|
+
return
|
|
266
|
+
yield b": keepalive\n\n"
|
|
267
|
+
continue
|
|
268
|
+
try:
|
|
269
|
+
payload = pending.result()
|
|
270
|
+
except (StopAsyncIteration, BackpressureError):
|
|
271
|
+
return
|
|
272
|
+
if not await _validate_live_async(ticket):
|
|
273
|
+
return
|
|
274
|
+
yield _sse_data(payload)
|
|
275
|
+
pending = asyncio.create_task(anext(iterator))
|
|
276
|
+
finally:
|
|
277
|
+
pending.cancel()
|
|
278
|
+
# asyncio.wait, never gather — see _settle_websocket_tasks.
|
|
279
|
+
await asyncio.wait({pending})
|
|
280
|
+
await iterator.aclose()
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
@router.get(
|
|
284
|
+
"/sse/{channel_name}",
|
|
285
|
+
response_model=None,
|
|
286
|
+
response_class=StreamingResponse,
|
|
287
|
+
)
|
|
288
|
+
def subscribe_sse(
|
|
289
|
+
channel_name: str,
|
|
290
|
+
ticket: Annotated[str, Query(min_length=1, max_length=200)],
|
|
291
|
+
) -> StreamingResponse:
|
|
292
|
+
channel = get_channel(channel_name)
|
|
293
|
+
redeemed = _consume_ticket(ticket, channel_name=channel_name, transport="sse")
|
|
294
|
+
if channel is None or channel.mode != "sse" or redeemed is None:
|
|
295
|
+
from terp.core import AuthenticationError
|
|
296
|
+
|
|
297
|
+
raise AuthenticationError()
|
|
298
|
+
if not _validate_live(redeemed):
|
|
299
|
+
from terp.core import AuthenticationError
|
|
300
|
+
|
|
301
|
+
raise AuthenticationError()
|
|
302
|
+
return StreamingResponse(
|
|
303
|
+
_sse_stream(channel, redeemed),
|
|
304
|
+
media_type="text/event-stream",
|
|
305
|
+
headers={
|
|
306
|
+
"Cache-Control": "no-cache, no-store",
|
|
307
|
+
"X-Accel-Buffering": "no",
|
|
308
|
+
},
|
|
309
|
+
)
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
async def _websocket_outbound(
|
|
313
|
+
websocket: WebSocket, channel: RealtimeChannel, ticket: ConnectionTicket
|
|
314
|
+
) -> None:
|
|
315
|
+
async for payload in get_broker().stream(
|
|
316
|
+
audience_topic(channel.name, ticket.audience)
|
|
317
|
+
):
|
|
318
|
+
if not await _validate_live_async(ticket):
|
|
319
|
+
await websocket.close(code=1008, reason="session no longer valid")
|
|
320
|
+
return
|
|
321
|
+
await websocket.send_text(payload)
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
async def _websocket_liveness(
|
|
325
|
+
websocket: WebSocket,
|
|
326
|
+
ticket: ConnectionTicket,
|
|
327
|
+
*,
|
|
328
|
+
interval_seconds: float = HEARTBEAT_SECONDS,
|
|
329
|
+
) -> None:
|
|
330
|
+
while True:
|
|
331
|
+
await asyncio.sleep(interval_seconds)
|
|
332
|
+
if not await _validate_live_async(ticket):
|
|
333
|
+
await websocket.close(code=1008, reason="session no longer valid")
|
|
334
|
+
return
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
async def _websocket_inbound(
|
|
338
|
+
websocket: WebSocket,
|
|
339
|
+
channel: RealtimeChannel,
|
|
340
|
+
ticket: ConnectionTicket,
|
|
341
|
+
) -> None:
|
|
342
|
+
while True:
|
|
343
|
+
text = await websocket.receive_text()
|
|
344
|
+
if len(text.encode("utf-8")) > MAX_INBOUND_BYTES:
|
|
345
|
+
await websocket.close(code=1009, reason="message too large")
|
|
346
|
+
return
|
|
347
|
+
if not await _validate_live_async(ticket):
|
|
348
|
+
await websocket.close(code=1008, reason="session no longer valid")
|
|
349
|
+
return
|
|
350
|
+
if channel.inbound_model is None or channel.on_message is None:
|
|
351
|
+
await websocket.close(code=1008, reason="channel is server-push only")
|
|
352
|
+
return
|
|
353
|
+
try:
|
|
354
|
+
message = channel.inbound_model.model_validate_json(text)
|
|
355
|
+
except ValidationError:
|
|
356
|
+
await websocket.send_text(
|
|
357
|
+
json.dumps(
|
|
358
|
+
{"type": "error", "code": "invalid_message"}, separators=(",", ":")
|
|
359
|
+
)
|
|
360
|
+
)
|
|
361
|
+
continue
|
|
362
|
+
await _run_inbound_handler(channel.on_message, ticket, message)
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
def _raise_unexpected_task_results(results: list[object]) -> None:
|
|
366
|
+
for result in results:
|
|
367
|
+
if isinstance(result, BaseException) and not isinstance(
|
|
368
|
+
result,
|
|
369
|
+
(WebSocketDisconnect, BackpressureError, asyncio.CancelledError),
|
|
370
|
+
):
|
|
371
|
+
raise result
|
|
372
|
+
|
|
373
|
+
|
|
374
|
+
async def _settle_websocket_tasks(*tasks: asyncio.Task[None]) -> None:
|
|
375
|
+
"""Run until the first task settles, then cancel and drain the rest.
|
|
376
|
+
|
|
377
|
+
The drain awaits ``asyncio.wait`` — never ``asyncio.gather``: when the
|
|
378
|
+
host task is cancelled mid-drain (server shutdown, test-portal teardown),
|
|
379
|
+
a cancelled gather re-raises the *last child's* CancelledError instead of
|
|
380
|
+
the host's own. The child's copy carries no cancel-scope message, so the
|
|
381
|
+
surrounding anyio cancel scope refuses to absorb it and the teardown
|
|
382
|
+
crashes the connection task.
|
|
383
|
+
"""
|
|
384
|
+
_done, pending = await asyncio.wait(tasks, return_when=asyncio.FIRST_COMPLETED)
|
|
385
|
+
for task in pending:
|
|
386
|
+
task.cancel()
|
|
387
|
+
await asyncio.wait(tasks)
|
|
388
|
+
results: list[object] = []
|
|
389
|
+
for task in tasks:
|
|
390
|
+
if task.cancelled():
|
|
391
|
+
continue
|
|
392
|
+
exc = task.exception()
|
|
393
|
+
results.append(exc if exc is not None else task.result())
|
|
394
|
+
_raise_unexpected_task_results(results)
|
|
395
|
+
|
|
396
|
+
|
|
397
|
+
@router.websocket("/ws/{channel_name}")
|
|
398
|
+
async def subscribe_websocket(
|
|
399
|
+
websocket: WebSocket,
|
|
400
|
+
channel_name: str,
|
|
401
|
+
ticket: Annotated[str, Query(min_length=1, max_length=200)],
|
|
402
|
+
handshake_session: SessionDep,
|
|
403
|
+
) -> None:
|
|
404
|
+
channel = get_channel(channel_name)
|
|
405
|
+
redeemed = _consume_ticket(
|
|
406
|
+
ticket, channel_name=channel_name, transport="websocket"
|
|
407
|
+
)
|
|
408
|
+
if (
|
|
409
|
+
channel is None
|
|
410
|
+
or channel.mode != "websocket"
|
|
411
|
+
or redeemed is None
|
|
412
|
+
or not await _validate_live_async(redeemed)
|
|
413
|
+
):
|
|
414
|
+
await websocket.close(code=1008, reason="invalid connection ticket")
|
|
415
|
+
return
|
|
416
|
+
# Global guard/audit dependencies resolve one request session even for this
|
|
417
|
+
# ticket-authenticated public route. Release it before the long-lived socket;
|
|
418
|
+
# each inbound frame gets a fresh, bounded message unit of work instead.
|
|
419
|
+
handshake_session.close()
|
|
420
|
+
await websocket.accept()
|
|
421
|
+
outbound = asyncio.create_task(
|
|
422
|
+
_websocket_outbound(websocket, channel, redeemed)
|
|
423
|
+
)
|
|
424
|
+
inbound = asyncio.create_task(
|
|
425
|
+
_websocket_inbound(websocket, channel, redeemed)
|
|
426
|
+
)
|
|
427
|
+
liveness = asyncio.create_task(_websocket_liveness(websocket, redeemed))
|
|
428
|
+
await _settle_websocket_tasks(outbound, inbound, liveness)
|
|
429
|
+
|
|
430
|
+
|
|
431
|
+
module = ModuleSpec(
|
|
432
|
+
name="realtime",
|
|
433
|
+
router=router,
|
|
434
|
+
# Native EventSource/WebSocket constructors cannot attach the in-memory
|
|
435
|
+
# bearer header, so the transport handshake is public at the HTTP layer
|
|
436
|
+
# and authenticates by a 30-second, single-use ticket instead. The mint
|
|
437
|
+
# endpoint self-authenticates with Depends(get_principal), then enforces
|
|
438
|
+
# the channel's typed authority. The stronger public-write declaration
|
|
439
|
+
# makes this exceptional route posture explicit and runtime-validated.
|
|
440
|
+
policy=Policy.public_write(
|
|
441
|
+
reason="realtime handshakes redeem one-use authenticated connection tickets"
|
|
442
|
+
),
|
|
443
|
+
)
|
|
444
|
+
|
|
445
|
+
|
|
446
|
+
__all__ = [
|
|
447
|
+
"HEARTBEAT_SECONDS",
|
|
448
|
+
"MAX_INBOUND_BYTES",
|
|
449
|
+
"MessageSessionProvider",
|
|
450
|
+
"TICKET_TTL_SECONDS",
|
|
451
|
+
"PrincipalValidator",
|
|
452
|
+
"TicketRequest",
|
|
453
|
+
"TicketResponse",
|
|
454
|
+
"configure_realtime",
|
|
455
|
+
"module",
|
|
456
|
+
"reset_realtime_configuration",
|
|
457
|
+
"router",
|
|
458
|
+
]
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
"""Single-use, TTL-bounded connection tickets for browser-native transports.
|
|
2
|
+
|
|
3
|
+
The Terp access token lives in React memory. Native ``EventSource`` and
|
|
4
|
+
``WebSocket`` constructors cannot attach its Authorization header, and putting
|
|
5
|
+
the bearer token in a URL would leak it into logs/history. The sanctioned flow
|
|
6
|
+
therefore mints a short-lived opaque ticket over the generated authenticated
|
|
7
|
+
client, then redeems it exactly once at the transport handshake.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import secrets
|
|
13
|
+
import time
|
|
14
|
+
from abc import ABC, abstractmethod
|
|
15
|
+
from collections.abc import Callable
|
|
16
|
+
from dataclasses import dataclass
|
|
17
|
+
from threading import Lock
|
|
18
|
+
|
|
19
|
+
from terp.core import Principal
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass(frozen=True)
|
|
23
|
+
class ConnectionTicket:
|
|
24
|
+
"""The authority captured at mint for one channel + transport handshake."""
|
|
25
|
+
|
|
26
|
+
principal: Principal
|
|
27
|
+
channel: str
|
|
28
|
+
transport: str
|
|
29
|
+
credential: str = ""
|
|
30
|
+
audience: str = ""
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class ConnectionTicketStore(ABC):
|
|
34
|
+
"""Atomic issue/consume port for opaque one-use tickets."""
|
|
35
|
+
|
|
36
|
+
@abstractmethod
|
|
37
|
+
def issue(self, ticket: ConnectionTicket, *, ttl_seconds: int) -> str: ...
|
|
38
|
+
|
|
39
|
+
@abstractmethod
|
|
40
|
+
def consume(
|
|
41
|
+
self, token: str, *, channel: str, transport: str
|
|
42
|
+
) -> ConnectionTicket | None:
|
|
43
|
+
"""Atomically remove and return a live exact-match ticket, else None."""
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class InMemoryConnectionTicketStore(ConnectionTicketStore):
|
|
47
|
+
"""Thread-safe per-process ticket store, TTL-expired on issue/consume."""
|
|
48
|
+
|
|
49
|
+
def __init__(self, *, clock: Callable[[], float] = time.monotonic) -> None:
|
|
50
|
+
self._clock = clock
|
|
51
|
+
self._lock = Lock()
|
|
52
|
+
self._entries: dict[str, tuple[float, ConnectionTicket]] = {}
|
|
53
|
+
|
|
54
|
+
def issue(self, ticket: ConnectionTicket, *, ttl_seconds: int) -> str:
|
|
55
|
+
if ttl_seconds <= 0:
|
|
56
|
+
raise ValueError("connection ticket ttl_seconds must be positive")
|
|
57
|
+
token = secrets.token_urlsafe(32)
|
|
58
|
+
now = self._clock()
|
|
59
|
+
with self._lock:
|
|
60
|
+
self._sweep(now)
|
|
61
|
+
self._entries[token] = (now + ttl_seconds, ticket)
|
|
62
|
+
return token
|
|
63
|
+
|
|
64
|
+
def consume(
|
|
65
|
+
self, token: str, *, channel: str, transport: str
|
|
66
|
+
) -> ConnectionTicket | None:
|
|
67
|
+
now = self._clock()
|
|
68
|
+
with self._lock:
|
|
69
|
+
entry = self._entries.pop(token, None)
|
|
70
|
+
if entry is None:
|
|
71
|
+
return None
|
|
72
|
+
expires_at, ticket = entry
|
|
73
|
+
if expires_at <= now:
|
|
74
|
+
return None
|
|
75
|
+
# Consume even on a mismatch: a leaked ticket cannot be probed
|
|
76
|
+
# against channel names/transports and then reused correctly.
|
|
77
|
+
if ticket.channel != channel or ticket.transport != transport:
|
|
78
|
+
return None
|
|
79
|
+
return ticket
|
|
80
|
+
|
|
81
|
+
def _sweep(self, now: float) -> None:
|
|
82
|
+
expired = [key for key, (expires_at, _) in self._entries.items() if expires_at <= now]
|
|
83
|
+
for key in expired:
|
|
84
|
+
del self._entries[key]
|
|
85
|
+
|
|
86
|
+
def reset(self) -> None:
|
|
87
|
+
with self._lock:
|
|
88
|
+
self._entries.clear()
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
_configured_store: ConnectionTicketStore | None = None
|
|
92
|
+
_configuration_lock = Lock()
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def configure_ticket_store(store: ConnectionTicketStore | None) -> None:
|
|
96
|
+
"""Install *store* process-wide (``None`` resets to the lazy default)."""
|
|
97
|
+
global _configured_store
|
|
98
|
+
with _configuration_lock:
|
|
99
|
+
_configured_store = store
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def get_ticket_store() -> ConnectionTicketStore:
|
|
103
|
+
"""The configured store, creating the in-memory default lazily."""
|
|
104
|
+
global _configured_store
|
|
105
|
+
with _configuration_lock:
|
|
106
|
+
if _configured_store is None:
|
|
107
|
+
_configured_store = InMemoryConnectionTicketStore()
|
|
108
|
+
return _configured_store
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
__all__ = [
|
|
112
|
+
"ConnectionTicket",
|
|
113
|
+
"ConnectionTicketStore",
|
|
114
|
+
"InMemoryConnectionTicketStore",
|
|
115
|
+
"configure_ticket_store",
|
|
116
|
+
"get_ticket_store",
|
|
117
|
+
]
|