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.
@@ -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
+ ]
@@ -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
+ ]