supython 0.5.0__py3-none-any.whl
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.
- supython/__init__.py +8 -0
- supython/admin/__init__.py +3 -0
- supython/admin/api/__init__.py +24 -0
- supython/admin/api/auth.py +118 -0
- supython/admin/api/auth_templates.py +67 -0
- supython/admin/api/auth_users.py +225 -0
- supython/admin/api/db.py +174 -0
- supython/admin/api/functions.py +92 -0
- supython/admin/api/jobs.py +192 -0
- supython/admin/api/ops.py +224 -0
- supython/admin/api/realtime.py +281 -0
- supython/admin/api/service_auth.py +49 -0
- supython/admin/api/service_auth_templates.py +83 -0
- supython/admin/api/service_auth_users.py +346 -0
- supython/admin/api/service_db.py +214 -0
- supython/admin/api/service_functions.py +287 -0
- supython/admin/api/service_jobs.py +282 -0
- supython/admin/api/service_ops.py +213 -0
- supython/admin/api/service_realtime.py +30 -0
- supython/admin/api/service_storage.py +220 -0
- supython/admin/api/storage.py +117 -0
- supython/admin/api/system.py +37 -0
- supython/admin/audit.py +29 -0
- supython/admin/deps.py +22 -0
- supython/admin/errors.py +16 -0
- supython/admin/schemas.py +310 -0
- supython/admin/session.py +52 -0
- supython/admin/spa.py +38 -0
- supython/admin/static/assets/Alert-dluGVkos.js +49 -0
- supython/admin/static/assets/Audit-Njung3HI.js +2 -0
- supython/admin/static/assets/Backups-DzPlFgrm.js +2 -0
- supython/admin/static/assets/Buckets-ByacGkU1.js +2 -0
- supython/admin/static/assets/Channels-BoIuTtam.js +353 -0
- supython/admin/static/assets/ChevronRight-CtQH1EQ1.js +2 -0
- supython/admin/static/assets/CodeViewer-Bqy7-wvH.js +2 -0
- supython/admin/static/assets/Crons-B67vc39F.js +2 -0
- supython/admin/static/assets/DashboardView-CUTFVL6k.js +2 -0
- supython/admin/static/assets/DataTable-COAAWEft.js +747 -0
- supython/admin/static/assets/DescriptionsItem-P8JUDaBs.js +75 -0
- supython/admin/static/assets/DrawerContent-TpYTFgF1.js +139 -0
- supython/admin/static/assets/Empty-cr2r7e2u.js +25 -0
- supython/admin/static/assets/EmptyState-DeDck-OL.js +2 -0
- supython/admin/static/assets/Grid-hFkp9F4P.js +2 -0
- supython/admin/static/assets/Input-DppYTq9C.js +259 -0
- supython/admin/static/assets/Invoke-DW3Nveeh.js +2 -0
- supython/admin/static/assets/JsonField-DibyJgun.js +2 -0
- supython/admin/static/assets/LoginView-BjLyE3Ds.css +1 -0
- supython/admin/static/assets/LoginView-CoOjECT_.js +111 -0
- supython/admin/static/assets/Logs-D9WYrnIT.js +2 -0
- supython/admin/static/assets/Logs-DS1XPa0h.css +1 -0
- supython/admin/static/assets/Migrations-DOSC2ddQ.js +2 -0
- supython/admin/static/assets/ObjectBrowser-_5w8vOX8.js +2 -0
- supython/admin/static/assets/Queue-CywZs6vI.js +2 -0
- supython/admin/static/assets/RefreshTokens-Ccjr53jg.js +2 -0
- supython/admin/static/assets/RlsEditor-BSlH9vSc.js +2 -0
- supython/admin/static/assets/Routes-BiLXE49D.js +2 -0
- supython/admin/static/assets/Routes-C-ianIGD.css +1 -0
- supython/admin/static/assets/SchemaBrowser-DKy2_KQi.css +1 -0
- supython/admin/static/assets/SchemaBrowser-XFvFbtDB.js +2 -0
- supython/admin/static/assets/Select-DIzZyRZb.js +434 -0
- supython/admin/static/assets/Space-n5-XcguU.js +400 -0
- supython/admin/static/assets/SqlEditor-b8pTsILY.js +3 -0
- supython/admin/static/assets/SqlWorkspace-BUS7IntH.js +104 -0
- supython/admin/static/assets/TableData-CQIagLKn.js +2 -0
- supython/admin/static/assets/Tag-D1fOKpTH.js +72 -0
- supython/admin/static/assets/Templates-BS-ugkdq.js +2 -0
- supython/admin/static/assets/Thing-CEAniuMg.js +107 -0
- supython/admin/static/assets/Users-wzwajhlh.js +2 -0
- supython/admin/static/assets/_plugin-vue_export-helper-DGA9ry_j.js +1 -0
- supython/admin/static/assets/dist-VXIJLCYq.js +13 -0
- supython/admin/static/assets/format-length-CGCY1rMh.js +2 -0
- supython/admin/static/assets/get-Ca6unauB.js +2 -0
- supython/admin/static/assets/index-CeE6v959.js +951 -0
- supython/admin/static/assets/pinia-COXwfrOX.js +2 -0
- supython/admin/static/assets/resources-Bt6thQCD.js +44 -0
- supython/admin/static/assets/use-locale-mtgM0a3a.js +2 -0
- supython/admin/static/assets/use-merged-state-BvhkaHNX.js +2 -0
- supython/admin/static/assets/useConfirm-tMjvBFXR.js +2 -0
- supython/admin/static/assets/useResource-C_rJCY8C.js +2 -0
- supython/admin/static/assets/useTable-CnZc5zhi.js +363 -0
- supython/admin/static/assets/useTable-Dg0XlRlq.css +1 -0
- supython/admin/static/assets/useToast-DsZKx0IX.js +2 -0
- supython/admin/static/assets/utils-sbXoq7Ir.js +2 -0
- supython/admin/static/favicon.svg +1 -0
- supython/admin/static/icons.svg +24 -0
- supython/admin/static/index.html +24 -0
- supython/app.py +149 -0
- supython/auth/__init__.py +3 -0
- supython/auth/_email_job.py +11 -0
- supython/auth/providers/__init__.py +34 -0
- supython/auth/providers/github.py +22 -0
- supython/auth/providers/google.py +19 -0
- supython/auth/providers/oauth.py +56 -0
- supython/auth/providers/registry.py +16 -0
- supython/auth/ratelimit.py +39 -0
- supython/auth/router.py +282 -0
- supython/auth/schemas.py +79 -0
- supython/auth/service.py +587 -0
- supython/body_size.py +184 -0
- supython/cli.py +1653 -0
- supython/client/__init__.py +67 -0
- supython/client/_auth.py +249 -0
- supython/client/_client.py +145 -0
- supython/client/_config.py +92 -0
- supython/client/_functions.py +69 -0
- supython/client/_storage.py +255 -0
- supython/client/py.typed +0 -0
- supython/db.py +151 -0
- supython/db_admin.py +8 -0
- supython/functions/__init__.py +19 -0
- supython/functions/context.py +262 -0
- supython/functions/loader.py +307 -0
- supython/functions/router.py +228 -0
- supython/functions/schemas.py +50 -0
- supython/gen/__init__.py +5 -0
- supython/gen/_introspect.py +137 -0
- supython/gen/types_py.py +270 -0
- supython/gen/types_ts.py +365 -0
- supython/health.py +229 -0
- supython/hooks.py +117 -0
- supython/jobs/__init__.py +31 -0
- supython/jobs/backends.py +97 -0
- supython/jobs/context.py +58 -0
- supython/jobs/cron.py +152 -0
- supython/jobs/cron_inproc.py +118 -0
- supython/jobs/decorators.py +76 -0
- supython/jobs/registry.py +79 -0
- supython/jobs/router.py +136 -0
- supython/jobs/schemas.py +92 -0
- supython/jobs/service.py +311 -0
- supython/jobs/worker.py +219 -0
- supython/jwks.py +257 -0
- supython/keyset.py +279 -0
- supython/logging_config.py +291 -0
- supython/mail.py +33 -0
- supython/mailer.py +65 -0
- supython/migrate.py +81 -0
- supython/migrations/0001_extensions_and_roles.sql +46 -0
- supython/migrations/0002_auth_schema.sql +66 -0
- supython/migrations/0003_demo_todos.sql +42 -0
- supython/migrations/0004_auth_v0_2.sql +47 -0
- supython/migrations/0005_storage_schema.sql +117 -0
- supython/migrations/0006_realtime_schema.sql +206 -0
- supython/migrations/0007_jobs_schema.sql +254 -0
- supython/migrations/0008_jobs_last_error.sql +56 -0
- supython/migrations/0009_auth_rate_limits.sql +33 -0
- supython/migrations/0010_worker_heartbeat.sql +14 -0
- supython/migrations/0011_admin_schema.sql +45 -0
- supython/migrations/0012_auth_banned_until.sql +10 -0
- supython/migrations/0013_email_templates.sql +19 -0
- supython/migrations/0014_realtime_payload_warning.sql +96 -0
- supython/migrations/0015_backups_schema.sql +14 -0
- supython/passwords.py +15 -0
- supython/realtime/__init__.py +6 -0
- supython/realtime/broker.py +814 -0
- supython/realtime/protocol.py +234 -0
- supython/realtime/router.py +184 -0
- supython/realtime/schemas.py +207 -0
- supython/realtime/service.py +261 -0
- supython/realtime/topics.py +175 -0
- supython/realtime/websocket.py +586 -0
- supython/scaffold/__init__.py +5 -0
- supython/scaffold/init_project.py +133 -0
- supython/scaffold/templates/Caddyfile.tmpl +4 -0
- supython/scaffold/templates/README.md.tmpl +22 -0
- supython/scaffold/templates/docker-compose.prod.yml.tmpl +84 -0
- supython/scaffold/templates/docker-compose.yml.tmpl +41 -0
- supython/scaffold/templates/docker_postgres_Dockerfile.tmpl +9 -0
- supython/scaffold/templates/docker_postgres_postgresql.conf.tmpl +3 -0
- supython/scaffold/templates/env.example.tmpl +149 -0
- supython/scaffold/templates/functions_README.md.tmpl +21 -0
- supython/scaffold/templates/gitignore.tmpl +14 -0
- supython/scaffold/templates/migrations/.gitkeep +0 -0
- supython/secretset.py +347 -0
- supython/security_headers.py +78 -0
- supython/settings.py +198 -0
- supython/storage/__init__.py +5 -0
- supython/storage/backends.py +392 -0
- supython/storage/router.py +341 -0
- supython/storage/schemas.py +50 -0
- supython/storage/service.py +445 -0
- supython/storage/signing.py +119 -0
- supython/tokens.py +85 -0
- supython-0.5.0.dist-info/METADATA +714 -0
- supython-0.5.0.dist-info/RECORD +188 -0
- supython-0.5.0.dist-info/WHEEL +4 -0
- supython-0.5.0.dist-info/entry_points.txt +2 -0
- supython-0.5.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
"""Transport-agnostic Phoenix Channels v1 protocol helpers.
|
|
2
|
+
|
|
3
|
+
The realtime websocket endpoint speaks the Phoenix channels 5-tuple wire
|
|
4
|
+
format used by Supabase Realtime v2 (``vsn=1.0.0``):
|
|
5
|
+
|
|
6
|
+
[join_ref, ref, topic, event, payload]
|
|
7
|
+
|
|
8
|
+
This module owns the framing contract (encode/decode, envelope validation),
|
|
9
|
+
the monotonic ref counter the server uses to tag its own pushes, and a
|
|
10
|
+
re-armable heartbeat timer used to detect silent client disconnects.
|
|
11
|
+
|
|
12
|
+
Everything here is pure: no Starlette, FastAPI, or ``WebSocket`` imports.
|
|
13
|
+
The WebSocket route in :mod:`supython.realtime.websocket` wires these
|
|
14
|
+
primitives to the actual transport.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
import asyncio
|
|
18
|
+
import itertools
|
|
19
|
+
import json
|
|
20
|
+
import time
|
|
21
|
+
from collections.abc import Callable
|
|
22
|
+
from typing import Any, Final
|
|
23
|
+
|
|
24
|
+
from pydantic import ValidationError
|
|
25
|
+
|
|
26
|
+
from .schemas import Frame
|
|
27
|
+
|
|
28
|
+
# ---------------------------------------------------------------------------
|
|
29
|
+
# Constants — Phoenix/Supabase Realtime vocabulary
|
|
30
|
+
# ---------------------------------------------------------------------------
|
|
31
|
+
|
|
32
|
+
# Connection-level topic used by the SDKs for heartbeats.
|
|
33
|
+
TOPIC_PHOENIX: Final[str] = "phoenix"
|
|
34
|
+
|
|
35
|
+
# Channel lifecycle events (topic = ``realtime:<name>``).
|
|
36
|
+
EVENT_PHX_JOIN: Final[str] = "phx_join"
|
|
37
|
+
EVENT_PHX_LEAVE: Final[str] = "phx_leave"
|
|
38
|
+
EVENT_PHX_REPLY: Final[str] = "phx_reply"
|
|
39
|
+
EVENT_PHX_CLOSE: Final[str] = "phx_close"
|
|
40
|
+
EVENT_PHX_ERROR: Final[str] = "phx_error"
|
|
41
|
+
|
|
42
|
+
# In-channel events.
|
|
43
|
+
EVENT_HEARTBEAT: Final[str] = "heartbeat"
|
|
44
|
+
EVENT_ACCESS_TOKEN: Final[str] = "access_token"
|
|
45
|
+
EVENT_BROADCAST: Final[str] = "broadcast"
|
|
46
|
+
EVENT_PRESENCE: Final[str] = "presence"
|
|
47
|
+
EVENT_PRESENCE_STATE: Final[str] = "presence_state"
|
|
48
|
+
EVENT_PRESENCE_DIFF: Final[str] = "presence_diff"
|
|
49
|
+
EVENT_POSTGRES_CHANGES: Final[str] = "postgres_changes"
|
|
50
|
+
|
|
51
|
+
# phx_reply status values.
|
|
52
|
+
STATUS_OK: Final[str] = "ok"
|
|
53
|
+
STATUS_ERROR: Final[str] = "error"
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
# ---------------------------------------------------------------------------
|
|
57
|
+
# Encode / decode
|
|
58
|
+
# ---------------------------------------------------------------------------
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class ProtocolError(ValueError):
|
|
62
|
+
"""Raised for any frame that is not a well-formed Phoenix 5-tuple."""
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def encode(frame: Frame) -> str:
|
|
66
|
+
"""Serialize a :class:`Frame` to the compact JSON string sent over the wire."""
|
|
67
|
+
return json.dumps(frame.model_dump(mode="json"), separators=(",", ":"))
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def decode(raw: str | bytes | bytearray) -> Frame:
|
|
71
|
+
"""Parse a raw text frame into a validated :class:`Frame`.
|
|
72
|
+
|
|
73
|
+
Binary frames (``vsn=2.0.0``) are intentionally unsupported in v0.4; the
|
|
74
|
+
client is expected to negotiate JSON via ``?vsn=1.0.0``.
|
|
75
|
+
"""
|
|
76
|
+
if isinstance(raw, (bytes, bytearray)):
|
|
77
|
+
try:
|
|
78
|
+
raw = raw.decode("utf-8")
|
|
79
|
+
except UnicodeDecodeError as exc:
|
|
80
|
+
raise ProtocolError("Frame payload is not valid UTF-8.") from exc
|
|
81
|
+
|
|
82
|
+
try:
|
|
83
|
+
data = json.loads(raw)
|
|
84
|
+
except json.JSONDecodeError as exc:
|
|
85
|
+
raise ProtocolError(f"Frame is not valid JSON: {exc.msg}") from exc
|
|
86
|
+
|
|
87
|
+
if not isinstance(data, list):
|
|
88
|
+
raise ProtocolError(
|
|
89
|
+
f"Frame must be a JSON array, got {type(data).__name__}."
|
|
90
|
+
)
|
|
91
|
+
if len(data) != 5:
|
|
92
|
+
raise ProtocolError(
|
|
93
|
+
f"Frame must have exactly 5 elements [join_ref, ref, topic, event, payload], got {len(data)}."
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
try:
|
|
97
|
+
return Frame.model_validate(data)
|
|
98
|
+
except ValidationError as exc:
|
|
99
|
+
raise ProtocolError(f"Frame failed validation: {exc}") from exc
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
# ---------------------------------------------------------------------------
|
|
103
|
+
# Convenience builders
|
|
104
|
+
# ---------------------------------------------------------------------------
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def make_reply(
|
|
108
|
+
*,
|
|
109
|
+
join_ref: str | None,
|
|
110
|
+
ref: str | None,
|
|
111
|
+
topic: str,
|
|
112
|
+
status: str = STATUS_OK,
|
|
113
|
+
response: dict[str, Any] | None = None,
|
|
114
|
+
) -> Frame:
|
|
115
|
+
"""Build a ``phx_reply`` frame.
|
|
116
|
+
|
|
117
|
+
``ref`` echoes the originating request's ``ref`` so the client can
|
|
118
|
+
correlate. ``response`` defaults to ``{}`` which matches how the
|
|
119
|
+
Supabase SDK parses an ack.
|
|
120
|
+
"""
|
|
121
|
+
return Frame.build(
|
|
122
|
+
join_ref=join_ref,
|
|
123
|
+
ref=ref,
|
|
124
|
+
topic=topic,
|
|
125
|
+
event=EVENT_PHX_REPLY,
|
|
126
|
+
payload={"status": status, "response": response or {}},
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def make_server_push(
|
|
131
|
+
*,
|
|
132
|
+
topic: str,
|
|
133
|
+
event: str,
|
|
134
|
+
payload: dict[str, Any],
|
|
135
|
+
) -> Frame:
|
|
136
|
+
"""Build a server-initiated push (``join_ref`` and ``ref`` are ``null``)."""
|
|
137
|
+
return Frame.build(
|
|
138
|
+
join_ref=None,
|
|
139
|
+
ref=None,
|
|
140
|
+
topic=topic,
|
|
141
|
+
event=event,
|
|
142
|
+
payload=payload,
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
# ---------------------------------------------------------------------------
|
|
147
|
+
# Ref counter
|
|
148
|
+
# ---------------------------------------------------------------------------
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
class RefCounter:
|
|
152
|
+
"""Monotonic per-connection ref generator.
|
|
153
|
+
|
|
154
|
+
Refs are emitted as strings because the Phoenix wire format treats them
|
|
155
|
+
opaquely and the JS SDK occasionally sends non-numeric refs. Starting
|
|
156
|
+
at 1 matches the convention used by the Supabase SDK for the first
|
|
157
|
+
join.
|
|
158
|
+
"""
|
|
159
|
+
|
|
160
|
+
__slots__ = ("_counter",)
|
|
161
|
+
|
|
162
|
+
def __init__(self, start: int = 1) -> None:
|
|
163
|
+
self._counter: itertools.count[int] = itertools.count(start)
|
|
164
|
+
|
|
165
|
+
def next(self) -> str:
|
|
166
|
+
return str(next(self._counter))
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
# ---------------------------------------------------------------------------
|
|
170
|
+
# Heartbeat timer
|
|
171
|
+
# ---------------------------------------------------------------------------
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
Clock = Callable[[], float]
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
class HeartbeatTimeout:
|
|
178
|
+
"""Re-armable inactivity timer.
|
|
179
|
+
|
|
180
|
+
The server does not send heartbeats; it only ensures clients send them.
|
|
181
|
+
Each incoming frame calls :meth:`touch` to reset the deadline. The
|
|
182
|
+
WebSocket handler concurrently runs :meth:`wait_for_timeout` and closes
|
|
183
|
+
the socket (code 1001) when it returns.
|
|
184
|
+
|
|
185
|
+
A custom ``clock`` can be injected for deterministic unit tests. The
|
|
186
|
+
default is :func:`time.monotonic`, which is immune to wall-clock jumps.
|
|
187
|
+
"""
|
|
188
|
+
|
|
189
|
+
__slots__ = ("_timeout", "_clock", "_last")
|
|
190
|
+
|
|
191
|
+
def __init__(
|
|
192
|
+
self,
|
|
193
|
+
timeout_seconds: float,
|
|
194
|
+
*,
|
|
195
|
+
clock: Clock = time.monotonic,
|
|
196
|
+
) -> None:
|
|
197
|
+
if timeout_seconds <= 0:
|
|
198
|
+
raise ValueError("timeout_seconds must be > 0")
|
|
199
|
+
self._timeout = float(timeout_seconds)
|
|
200
|
+
self._clock = clock
|
|
201
|
+
self._last = clock()
|
|
202
|
+
|
|
203
|
+
def touch(self) -> None:
|
|
204
|
+
"""Mark activity just observed; re-arms the timer."""
|
|
205
|
+
self._last = self._clock()
|
|
206
|
+
|
|
207
|
+
@property
|
|
208
|
+
def seconds_since_last(self) -> float:
|
|
209
|
+
return self._clock() - self._last
|
|
210
|
+
|
|
211
|
+
@property
|
|
212
|
+
def seconds_until_timeout(self) -> float:
|
|
213
|
+
return max(0.0, self._timeout - self.seconds_since_last)
|
|
214
|
+
|
|
215
|
+
def is_expired(self) -> bool:
|
|
216
|
+
return self.seconds_since_last >= self._timeout
|
|
217
|
+
|
|
218
|
+
async def wait_for_timeout(
|
|
219
|
+
self,
|
|
220
|
+
*,
|
|
221
|
+
sleep: Callable[[float], Any] = asyncio.sleep,
|
|
222
|
+
) -> None:
|
|
223
|
+
"""Block until the timeout deadline is reached without a ``touch``.
|
|
224
|
+
|
|
225
|
+
If :meth:`touch` is called while this coroutine is sleeping, the
|
|
226
|
+
sleep naturally extends on the next iteration because the remaining
|
|
227
|
+
window is recomputed from the updated ``_last``. Returns as soon
|
|
228
|
+
as the deadline has been met with no intervening touch.
|
|
229
|
+
"""
|
|
230
|
+
while True:
|
|
231
|
+
remaining = self.seconds_until_timeout
|
|
232
|
+
if remaining <= 0:
|
|
233
|
+
return
|
|
234
|
+
await sleep(remaining)
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
"""HTTP control plane for the realtime module.
|
|
2
|
+
|
|
3
|
+
Exposes three REST endpoints under ``/realtime/v1`` and mounts the
|
|
4
|
+
WebSocket route from :mod:`.websocket` onto the same router so that a
|
|
5
|
+
single ``app.include_router(realtime.router)`` call wires up both the
|
|
6
|
+
REST surface and the channel transport:
|
|
7
|
+
|
|
8
|
+
``POST /realtime/v1/enable``
|
|
9
|
+
Service-role only. Opt a table into change-event fan-out. Wraps
|
|
10
|
+
:func:`service.enable_table`.
|
|
11
|
+
|
|
12
|
+
``GET /realtime/v1/info``
|
|
13
|
+
List the contents of ``realtime.enabled_tables`` under the caller's
|
|
14
|
+
role (RLS still applies).
|
|
15
|
+
|
|
16
|
+
``POST /realtime/v1/broadcast/{topic}``
|
|
17
|
+
Service-role only. Push a Phoenix ``broadcast`` frame to every
|
|
18
|
+
subscriber of ``topic``. Useful for server-to-client pushes from
|
|
19
|
+
edge functions and cron jobs that don't want to hold a WebSocket.
|
|
20
|
+
|
|
21
|
+
``WS /realtime/v1/websocket``
|
|
22
|
+
The actual channel transport — see :mod:`.websocket`.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from typing import Annotated, Any
|
|
26
|
+
|
|
27
|
+
import jwt
|
|
28
|
+
from fastapi import APIRouter, Depends, Header, HTTPException, status
|
|
29
|
+
|
|
30
|
+
from .. import db, tokens
|
|
31
|
+
from . import service
|
|
32
|
+
from .broker import get_broker
|
|
33
|
+
from .schemas import (
|
|
34
|
+
BroadcastRequest,
|
|
35
|
+
BroadcastResponse,
|
|
36
|
+
EnabledTable,
|
|
37
|
+
EnableTableRequest,
|
|
38
|
+
)
|
|
39
|
+
from .topics import TopicError, validate_topic
|
|
40
|
+
from .websocket import realtime_websocket
|
|
41
|
+
|
|
42
|
+
router = APIRouter(prefix="/realtime/v1", tags=["realtime"])
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
# ---------------------------------------------------------------------------
|
|
46
|
+
# Error translation
|
|
47
|
+
# ---------------------------------------------------------------------------
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _realtime_error(exc: service.RealtimeError) -> HTTPException:
|
|
51
|
+
return HTTPException(
|
|
52
|
+
status_code=exc.status,
|
|
53
|
+
detail={"code": exc.code, "message": exc.message},
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
# ---------------------------------------------------------------------------
|
|
58
|
+
# Auth dependencies
|
|
59
|
+
# ---------------------------------------------------------------------------
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
async def _current_claims(
|
|
63
|
+
authorization: Annotated[str | None, Header()] = None,
|
|
64
|
+
) -> dict[str, Any]:
|
|
65
|
+
"""Decode the bearer JWT and return its claims."""
|
|
66
|
+
if not authorization or not authorization.lower().startswith("bearer "):
|
|
67
|
+
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Missing bearer token")
|
|
68
|
+
token = authorization.split(" ", 1)[1]
|
|
69
|
+
try:
|
|
70
|
+
return tokens.decode_access_token(token)
|
|
71
|
+
except jwt.PyJWTError as exc:
|
|
72
|
+
raise HTTPException(
|
|
73
|
+
status.HTTP_401_UNAUTHORIZED, f"Invalid token: {exc}"
|
|
74
|
+
) from exc
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _claims_role(claims: dict[str, Any]) -> str:
|
|
78
|
+
role = claims.get("role")
|
|
79
|
+
if not isinstance(role, str) or not role:
|
|
80
|
+
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Token missing role claim")
|
|
81
|
+
return role
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _require_service_role(claims: dict[str, Any]) -> None:
|
|
85
|
+
if _claims_role(claims) != "service_role":
|
|
86
|
+
raise HTTPException(
|
|
87
|
+
status.HTTP_403_FORBIDDEN,
|
|
88
|
+
detail={
|
|
89
|
+
"code": "forbidden",
|
|
90
|
+
"message": "service_role required",
|
|
91
|
+
},
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
# ---------------------------------------------------------------------------
|
|
96
|
+
# REST endpoints
|
|
97
|
+
# ---------------------------------------------------------------------------
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
@router.post("/enable", response_model=EnabledTable, status_code=201)
|
|
101
|
+
async def enable_table(
|
|
102
|
+
payload: EnableTableRequest,
|
|
103
|
+
claims: Annotated[dict[str, Any], Depends(_current_claims)],
|
|
104
|
+
) -> EnabledTable:
|
|
105
|
+
"""Opt a table into realtime change events.
|
|
106
|
+
|
|
107
|
+
Only callable with a ``service_role`` JWT. Returns the freshly
|
|
108
|
+
written :class:`EnabledTable` row so the caller can echo it back to
|
|
109
|
+
the user.
|
|
110
|
+
"""
|
|
111
|
+
_require_service_role(claims)
|
|
112
|
+
try:
|
|
113
|
+
async with db.as_service_role(claims=claims) as conn:
|
|
114
|
+
return await service.enable_table(conn, payload)
|
|
115
|
+
except service.RealtimeError as exc:
|
|
116
|
+
raise _realtime_error(exc) from exc
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
@router.get("/info", response_model=list[EnabledTable])
|
|
120
|
+
async def list_info(
|
|
121
|
+
claims: Annotated[dict[str, Any], Depends(_current_claims)],
|
|
122
|
+
) -> list[EnabledTable]:
|
|
123
|
+
"""List every row of ``realtime.enabled_tables`` visible to the caller.
|
|
124
|
+
|
|
125
|
+
The registry has its own RLS policy granting ``select`` to ``anon``,
|
|
126
|
+
``authenticated``, and ``service_role`` (see migration 0006), so all
|
|
127
|
+
callers see the same rows in v0.4 — but reads still go through a
|
|
128
|
+
role-scoped connection so future per-row policies are honoured
|
|
129
|
+
without changes here.
|
|
130
|
+
"""
|
|
131
|
+
role = _claims_role(claims)
|
|
132
|
+
if role == "service_role":
|
|
133
|
+
async with db.as_service_role(claims=claims) as conn:
|
|
134
|
+
return await service.list_enabled(conn)
|
|
135
|
+
try:
|
|
136
|
+
async with db.as_role(role, claims) as conn:
|
|
137
|
+
return await service.list_enabled(conn)
|
|
138
|
+
except ValueError as exc:
|
|
139
|
+
# Role decoded from the JWT is not in db_allowed_roles.
|
|
140
|
+
raise HTTPException(
|
|
141
|
+
status.HTTP_403_FORBIDDEN,
|
|
142
|
+
detail={"code": "forbidden", "message": str(exc)},
|
|
143
|
+
) from exc
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
@router.post(
|
|
147
|
+
"/broadcast/{topic}",
|
|
148
|
+
response_model=BroadcastResponse,
|
|
149
|
+
status_code=status.HTTP_202_ACCEPTED,
|
|
150
|
+
)
|
|
151
|
+
async def broadcast(
|
|
152
|
+
topic: str,
|
|
153
|
+
payload: BroadcastRequest,
|
|
154
|
+
claims: Annotated[dict[str, Any], Depends(_current_claims)],
|
|
155
|
+
) -> BroadcastResponse:
|
|
156
|
+
"""Fan a Phoenix ``broadcast`` frame out to every subscriber of *topic*.
|
|
157
|
+
|
|
158
|
+
Service-role only. REST-initiated broadcasts have no sender id, so
|
|
159
|
+
the sender-exclusion logic is a no-op — every subscriber receives
|
|
160
|
+
the frame.
|
|
161
|
+
"""
|
|
162
|
+
_require_service_role(claims)
|
|
163
|
+
try:
|
|
164
|
+
validate_topic(topic)
|
|
165
|
+
except TopicError as exc:
|
|
166
|
+
raise HTTPException(
|
|
167
|
+
status.HTTP_400_BAD_REQUEST,
|
|
168
|
+
detail={"code": "invalid_topic", "message": str(exc)},
|
|
169
|
+
) from exc
|
|
170
|
+
delivered = get_broker().broadcast(
|
|
171
|
+
topic=topic,
|
|
172
|
+
event=payload.event,
|
|
173
|
+
payload=payload.payload,
|
|
174
|
+
sender_id=None,
|
|
175
|
+
)
|
|
176
|
+
return BroadcastResponse(topic=topic, delivered=delivered)
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
# ---------------------------------------------------------------------------
|
|
180
|
+
# WebSocket transport — same prefix, same router
|
|
181
|
+
# ---------------------------------------------------------------------------
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
router.add_api_websocket_route("/websocket", realtime_websocket)
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
from datetime import datetime
|
|
2
|
+
from typing import Annotated, Any, Literal
|
|
3
|
+
|
|
4
|
+
from pydantic import BaseModel, ConfigDict, Field, RootModel
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
# ---------------------------------------------------------------------------
|
|
8
|
+
# Wire envelope — Phoenix 5-tuple: [join_ref, ref, topic, event, payload]
|
|
9
|
+
# ---------------------------------------------------------------------------
|
|
10
|
+
|
|
11
|
+
_EnvelopeTuple = tuple[str | None, str | None, str, str, dict[str, Any]]
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class Frame(RootModel[_EnvelopeTuple]):
|
|
15
|
+
"""Codec for the Phoenix channel 5-tuple wire format."""
|
|
16
|
+
|
|
17
|
+
@property
|
|
18
|
+
def join_ref(self) -> str | None:
|
|
19
|
+
return self.root[0]
|
|
20
|
+
|
|
21
|
+
@property
|
|
22
|
+
def ref(self) -> str | None:
|
|
23
|
+
return self.root[1]
|
|
24
|
+
|
|
25
|
+
@property
|
|
26
|
+
def topic(self) -> str:
|
|
27
|
+
return self.root[2]
|
|
28
|
+
|
|
29
|
+
@property
|
|
30
|
+
def event(self) -> str:
|
|
31
|
+
return self.root[3]
|
|
32
|
+
|
|
33
|
+
@property
|
|
34
|
+
def payload(self) -> dict[str, Any]:
|
|
35
|
+
return self.root[4]
|
|
36
|
+
|
|
37
|
+
@classmethod
|
|
38
|
+
def build(
|
|
39
|
+
cls,
|
|
40
|
+
*,
|
|
41
|
+
join_ref: str | None,
|
|
42
|
+
ref: str | None,
|
|
43
|
+
topic: str,
|
|
44
|
+
event: str,
|
|
45
|
+
payload: dict[str, Any],
|
|
46
|
+
) -> "Frame":
|
|
47
|
+
return cls.model_validate((join_ref, ref, topic, event, payload))
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
# ---------------------------------------------------------------------------
|
|
51
|
+
# phx_join payload — config sub-models
|
|
52
|
+
# ---------------------------------------------------------------------------
|
|
53
|
+
|
|
54
|
+
PostgresChangesEvent = Literal["INSERT", "UPDATE", "DELETE", "*"]
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class PostgresChangesFilter(BaseModel):
|
|
58
|
+
"""One entry in config.postgres_changes sent by the client on phx_join."""
|
|
59
|
+
|
|
60
|
+
model_config = ConfigDict(populate_by_name=True)
|
|
61
|
+
|
|
62
|
+
event: PostgresChangesEvent
|
|
63
|
+
schema_name: str = Field(alias="schema")
|
|
64
|
+
table: str
|
|
65
|
+
filter: str | None = None
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class BroadcastConfig(BaseModel):
|
|
69
|
+
model_config = ConfigDict(populate_by_name=True)
|
|
70
|
+
|
|
71
|
+
self_echo: bool = Field(default=False, alias="self")
|
|
72
|
+
ack: bool = False
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class PresenceConfig(BaseModel):
|
|
76
|
+
key: str = ""
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class JoinConfig(BaseModel):
|
|
80
|
+
postgres_changes: list[PostgresChangesFilter] = Field(default_factory=list)
|
|
81
|
+
broadcast: BroadcastConfig = Field(default_factory=BroadcastConfig)
|
|
82
|
+
presence: PresenceConfig = Field(default_factory=PresenceConfig)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
class PhxJoinPayload(BaseModel):
|
|
86
|
+
config: JoinConfig = Field(default_factory=JoinConfig)
|
|
87
|
+
access_token: str | None = None
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
# ---------------------------------------------------------------------------
|
|
91
|
+
# phx_reply payload — join acknowledgement
|
|
92
|
+
# ---------------------------------------------------------------------------
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
class PostgresChangesSubscription(BaseModel):
|
|
96
|
+
"""Server-assigned subscription entry returned inside the phx_join ack."""
|
|
97
|
+
|
|
98
|
+
model_config = ConfigDict(populate_by_name=True)
|
|
99
|
+
|
|
100
|
+
id: int
|
|
101
|
+
event: PostgresChangesEvent
|
|
102
|
+
schema_name: str = Field(alias="schema")
|
|
103
|
+
table: str
|
|
104
|
+
filter: str | None = None
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
class PhxReplyPayload(BaseModel):
|
|
108
|
+
status: Literal["ok", "error"]
|
|
109
|
+
response: dict[str, Any] = Field(default_factory=dict)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
class JoinReplyResponse(BaseModel):
|
|
113
|
+
postgres_changes: list[PostgresChangesSubscription] = Field(default_factory=list)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
# ---------------------------------------------------------------------------
|
|
117
|
+
# postgres_changes server push
|
|
118
|
+
# ---------------------------------------------------------------------------
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
class ColumnInfo(BaseModel):
|
|
122
|
+
name: str
|
|
123
|
+
type: str
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
class PostgresChangesData(BaseModel):
|
|
127
|
+
model_config = ConfigDict(populate_by_name=True)
|
|
128
|
+
|
|
129
|
+
schema_name: str = Field(alias="schema")
|
|
130
|
+
table: str
|
|
131
|
+
type: Literal["INSERT", "UPDATE", "DELETE"]
|
|
132
|
+
commit_timestamp: datetime
|
|
133
|
+
columns: list[ColumnInfo] = Field(default_factory=list)
|
|
134
|
+
record: dict[str, Any] | None = None
|
|
135
|
+
old_record: dict[str, Any] | None = None
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
class PostgresChangesPush(BaseModel):
|
|
139
|
+
"""Payload for the server-push 'postgres_changes' event."""
|
|
140
|
+
|
|
141
|
+
ids: list[int]
|
|
142
|
+
data: PostgresChangesData
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
# ---------------------------------------------------------------------------
|
|
146
|
+
# presence pushes
|
|
147
|
+
# ---------------------------------------------------------------------------
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
class PresenceState(BaseModel):
|
|
151
|
+
"""Full presence state pushed once on join."""
|
|
152
|
+
|
|
153
|
+
# Maps presence key → list of metas
|
|
154
|
+
presences: dict[str, list[dict[str, Any]]] = Field(default_factory=dict)
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
class PresenceDiff(BaseModel):
|
|
158
|
+
joins: dict[str, list[dict[str, Any]]] = Field(default_factory=dict)
|
|
159
|
+
leaves: dict[str, list[dict[str, Any]]] = Field(default_factory=dict)
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
# ---------------------------------------------------------------------------
|
|
163
|
+
# REST control-plane — POST /realtime/v1/enable
|
|
164
|
+
# ---------------------------------------------------------------------------
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
class EnableTableRequest(BaseModel):
|
|
168
|
+
"""Body for POST /realtime/v1/enable (service-role only)."""
|
|
169
|
+
|
|
170
|
+
# Fully qualified: "public.todos"
|
|
171
|
+
table: Annotated[str, Field(pattern=r"^[a-zA-Z_][a-zA-Z0-9_]*\.[a-zA-Z_][a-zA-Z0-9_]*$")]
|
|
172
|
+
owner_column: str | None = "user_id"
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
class EnabledTable(BaseModel):
|
|
176
|
+
"""One row from realtime.enabled_tables, returned by GET /realtime/v1/info."""
|
|
177
|
+
|
|
178
|
+
schema_name: str
|
|
179
|
+
table_name: str
|
|
180
|
+
pk_columns: list[str]
|
|
181
|
+
owner_column: str | None
|
|
182
|
+
created_at: datetime
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
# ---------------------------------------------------------------------------
|
|
186
|
+
# REST control-plane — POST /realtime/v1/broadcast/{topic}
|
|
187
|
+
# ---------------------------------------------------------------------------
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
class BroadcastRequest(BaseModel):
|
|
191
|
+
"""Body for POST /realtime/v1/broadcast/{topic} (service-role only).
|
|
192
|
+
|
|
193
|
+
The REST broadcast hop lets edge functions / cron jobs push to a
|
|
194
|
+
realtime channel without holding an open WebSocket. Shape mirrors the
|
|
195
|
+
``broadcast`` event payload sent by SDK clients so subscribers receive
|
|
196
|
+
an identical frame regardless of source.
|
|
197
|
+
"""
|
|
198
|
+
|
|
199
|
+
event: str = Field(min_length=1, max_length=255)
|
|
200
|
+
payload: dict[str, Any] = Field(default_factory=dict)
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
class BroadcastResponse(BaseModel):
|
|
204
|
+
"""Result of a REST-initiated broadcast."""
|
|
205
|
+
|
|
206
|
+
topic: str
|
|
207
|
+
delivered: int
|