brutalsystems-realtime-core 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.
- brutalsystems_realtime_core-0.1.0/.gitignore +7 -0
- brutalsystems_realtime_core-0.1.0/PKG-INFO +8 -0
- brutalsystems_realtime_core-0.1.0/pyproject.toml +17 -0
- brutalsystems_realtime_core-0.1.0/realtime_core/__init__.py +28 -0
- brutalsystems_realtime_core-0.1.0/realtime_core/auth.py +67 -0
- brutalsystems_realtime_core-0.1.0/realtime_core/channels.py +33 -0
- brutalsystems_realtime_core-0.1.0/realtime_core/frames.py +55 -0
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: brutalsystems-realtime-core
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Shared wire contract for the realtime service — WS frames, channel grammar, and JWT auth. The single source of truth depended on by both the SDK and the service. Import as `realtime_core`.
|
|
5
|
+
Requires-Python: >=3.13
|
|
6
|
+
Requires-Dist: cryptography>=42
|
|
7
|
+
Requires-Dist: pydantic>=2.7.0
|
|
8
|
+
Requires-Dist: pyjwt[crypto]>=2.10.0
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "brutalsystems-realtime-core"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Shared wire contract for the realtime service — WS frames, channel grammar, and JWT auth. The single source of truth depended on by both the SDK and the service. Import as `realtime_core`."
|
|
5
|
+
requires-python = ">=3.13"
|
|
6
|
+
dependencies = [
|
|
7
|
+
"pydantic>=2.7.0",
|
|
8
|
+
"pyjwt[crypto]>=2.10.0",
|
|
9
|
+
"cryptography>=42",
|
|
10
|
+
]
|
|
11
|
+
|
|
12
|
+
[build-system]
|
|
13
|
+
requires = ["hatchling"]
|
|
14
|
+
build-backend = "hatchling.build"
|
|
15
|
+
|
|
16
|
+
[tool.hatch.build.targets.wheel]
|
|
17
|
+
packages = ["realtime_core"]
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"""Wire contract for the realtime service."""
|
|
2
|
+
from realtime_core.auth import AUTH_CLAIM_KEYS, TokenMinter, bearer_subprotocol, compute_kid
|
|
3
|
+
from realtime_core.channels import ChannelType, channel_type, is_presence, matches
|
|
4
|
+
from realtime_core.frames import (
|
|
5
|
+
EventFrame,
|
|
6
|
+
parse_inbound,
|
|
7
|
+
ping_frame,
|
|
8
|
+
publish_frame,
|
|
9
|
+
subscribe_frame,
|
|
10
|
+
unsubscribe_frame,
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
__all__ = [
|
|
14
|
+
"EventFrame",
|
|
15
|
+
"parse_inbound",
|
|
16
|
+
"ping_frame",
|
|
17
|
+
"publish_frame",
|
|
18
|
+
"subscribe_frame",
|
|
19
|
+
"unsubscribe_frame",
|
|
20
|
+
"ChannelType",
|
|
21
|
+
"channel_type",
|
|
22
|
+
"is_presence",
|
|
23
|
+
"matches",
|
|
24
|
+
"AUTH_CLAIM_KEYS",
|
|
25
|
+
"TokenMinter",
|
|
26
|
+
"bearer_subprotocol",
|
|
27
|
+
"compute_kid",
|
|
28
|
+
]
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
"""Generic JWT minting for realtime-service connections.
|
|
2
|
+
|
|
3
|
+
The SDK ships the MECHANISM (kid derivation + RS256 signing), never an
|
|
4
|
+
identity. Each caller supplies its own `issuer` (and serves a matching JWKS
|
|
5
|
+
the server validates against). kid = first 16 chars of URL-safe base64
|
|
6
|
+
SHA-256 of the SubjectPublicKeyInfo DER — matching the server's JWKS."""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import base64
|
|
10
|
+
import hashlib
|
|
11
|
+
import time
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
import jwt as pyjwt
|
|
15
|
+
from cryptography.hazmat.primitives import serialization
|
|
16
|
+
|
|
17
|
+
AUTH_CLAIM_KEYS = frozenset({"iss", "sub", "tenant_id", "iat", "exp"})
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _public_der_from_pem(pem: str) -> bytes:
|
|
21
|
+
data = pem.encode()
|
|
22
|
+
try:
|
|
23
|
+
priv = serialization.load_pem_private_key(data, password=None)
|
|
24
|
+
pub = priv.public_key()
|
|
25
|
+
except ValueError:
|
|
26
|
+
pub = serialization.load_pem_public_key(data)
|
|
27
|
+
return pub.public_bytes(
|
|
28
|
+
encoding=serialization.Encoding.DER,
|
|
29
|
+
format=serialization.PublicFormat.SubjectPublicKeyInfo,
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def compute_kid(pem: str) -> str:
|
|
34
|
+
"""Derive the JWKS key id from a public or private PEM."""
|
|
35
|
+
digest = hashlib.sha256(_public_der_from_pem(pem)).digest()
|
|
36
|
+
return base64.urlsafe_b64encode(digest).decode().rstrip("=")[:16]
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def bearer_subprotocol(token: str) -> str:
|
|
40
|
+
"""The WS subprotocol the server reads the JWT from: `Bearer.<jwt>`."""
|
|
41
|
+
return f"Bearer.{token}"
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class TokenMinter:
|
|
45
|
+
"""Zero-arg callable returning a fresh signed JWT each call."""
|
|
46
|
+
|
|
47
|
+
def __init__(
|
|
48
|
+
self, *, private_key: str, issuer: str, subject: str,
|
|
49
|
+
tenant_id: str, ttl_seconds: int = 300,
|
|
50
|
+
) -> None:
|
|
51
|
+
self._private_key = private_key
|
|
52
|
+
self._issuer = issuer
|
|
53
|
+
self._subject = subject
|
|
54
|
+
self._tenant_id = tenant_id
|
|
55
|
+
self._ttl_seconds = ttl_seconds
|
|
56
|
+
self._kid = compute_kid(private_key)
|
|
57
|
+
|
|
58
|
+
def __call__(self) -> str:
|
|
59
|
+
now = int(time.time())
|
|
60
|
+
claims: dict[str, Any] = {
|
|
61
|
+
"iss": self._issuer,
|
|
62
|
+
"sub": self._subject,
|
|
63
|
+
"tenant_id": self._tenant_id,
|
|
64
|
+
"iat": now,
|
|
65
|
+
"exp": now + self._ttl_seconds,
|
|
66
|
+
}
|
|
67
|
+
return pyjwt.encode(claims, self._private_key, algorithm="RS256", headers={"kid": self._kid})
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# python/packages/realtime-core/realtime_core/channels.py
|
|
2
|
+
"""Channel-name grammar for the realtime service.
|
|
3
|
+
|
|
4
|
+
Mirrors the realtime server's channel-type rules. Channel type is determined by
|
|
5
|
+
prefix: `private-` -> PRIVATE, `presence-` -> PRESENCE (dash, NOT colon —
|
|
6
|
+
a `presence:` colon form is a PUBLIC channel and presence will not track it),
|
|
7
|
+
everything else -> PUBLIC."""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from enum import StrEnum
|
|
11
|
+
from fnmatch import fnmatch
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class ChannelType(StrEnum):
|
|
15
|
+
PUBLIC = "public"
|
|
16
|
+
PRIVATE = "private"
|
|
17
|
+
PRESENCE = "presence"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def channel_type(name: str) -> ChannelType:
|
|
21
|
+
if name.startswith("private-"):
|
|
22
|
+
return ChannelType.PRIVATE
|
|
23
|
+
if name.startswith("presence-"):
|
|
24
|
+
return ChannelType.PRESENCE
|
|
25
|
+
return ChannelType.PUBLIC
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def is_presence(name: str) -> bool:
|
|
29
|
+
return channel_type(name) is ChannelType.PRESENCE
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def matches(channel: str, pattern: str) -> bool:
|
|
33
|
+
return fnmatch(channel, pattern)
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"""WebSocket frame encoders + inbound parser for the realtime service.
|
|
2
|
+
|
|
3
|
+
Mirrors the realtime server's protocol message definitions. Server-published events arrive as
|
|
4
|
+
{"type": "message", "channel": str, "data": {"event": str, "payload": dict}}
|
|
5
|
+
— the frame `type` is "message" (ServerMessageType.MESSAGE), NOT "event".
|
|
6
|
+
The inner data.event is the app-level event name (e.g. "run.succeeded")."""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def subscribe_frame(channel: str, *, scope: str | None = None, msg_id: str | None = None) -> dict[str, Any]:
|
|
14
|
+
frame: dict[str, Any] = {"type": "subscribe", "channel": channel}
|
|
15
|
+
if scope is not None:
|
|
16
|
+
frame["scope"] = scope
|
|
17
|
+
if msg_id is not None:
|
|
18
|
+
frame["id"] = msg_id
|
|
19
|
+
return frame
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def unsubscribe_frame(channel: str) -> dict[str, Any]:
|
|
23
|
+
return {"type": "unsubscribe", "channel": channel}
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def publish_frame(channel: str, data: dict[str, Any], *, scope: str | None = None) -> dict[str, Any]:
|
|
27
|
+
frame: dict[str, Any] = {"type": "publish", "channel": channel, "data": data}
|
|
28
|
+
if scope is not None:
|
|
29
|
+
frame["scope"] = scope
|
|
30
|
+
return frame
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def ping_frame() -> dict[str, Any]:
|
|
34
|
+
return {"type": "ping"}
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass(frozen=True)
|
|
38
|
+
class EventFrame:
|
|
39
|
+
channel: str
|
|
40
|
+
event: str
|
|
41
|
+
payload: dict[str, Any]
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def parse_inbound(msg: dict[str, Any]) -> EventFrame | None:
|
|
45
|
+
if msg.get("type") != "message":
|
|
46
|
+
return None
|
|
47
|
+
channel = msg.get("channel")
|
|
48
|
+
if not channel:
|
|
49
|
+
return None
|
|
50
|
+
data = msg.get("data") or {}
|
|
51
|
+
return EventFrame(
|
|
52
|
+
channel=channel,
|
|
53
|
+
event=data.get("event", ""),
|
|
54
|
+
payload=data.get("payload") or {},
|
|
55
|
+
)
|