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.
@@ -0,0 +1,7 @@
1
+ __pycache__/
2
+ *.pyc
3
+ .venv/
4
+ .ruff_cache/
5
+ .pytest_cache/
6
+ dist/
7
+ *.egg-info/
@@ -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
+ )