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.
Files changed (188) hide show
  1. supython/__init__.py +8 -0
  2. supython/admin/__init__.py +3 -0
  3. supython/admin/api/__init__.py +24 -0
  4. supython/admin/api/auth.py +118 -0
  5. supython/admin/api/auth_templates.py +67 -0
  6. supython/admin/api/auth_users.py +225 -0
  7. supython/admin/api/db.py +174 -0
  8. supython/admin/api/functions.py +92 -0
  9. supython/admin/api/jobs.py +192 -0
  10. supython/admin/api/ops.py +224 -0
  11. supython/admin/api/realtime.py +281 -0
  12. supython/admin/api/service_auth.py +49 -0
  13. supython/admin/api/service_auth_templates.py +83 -0
  14. supython/admin/api/service_auth_users.py +346 -0
  15. supython/admin/api/service_db.py +214 -0
  16. supython/admin/api/service_functions.py +287 -0
  17. supython/admin/api/service_jobs.py +282 -0
  18. supython/admin/api/service_ops.py +213 -0
  19. supython/admin/api/service_realtime.py +30 -0
  20. supython/admin/api/service_storage.py +220 -0
  21. supython/admin/api/storage.py +117 -0
  22. supython/admin/api/system.py +37 -0
  23. supython/admin/audit.py +29 -0
  24. supython/admin/deps.py +22 -0
  25. supython/admin/errors.py +16 -0
  26. supython/admin/schemas.py +310 -0
  27. supython/admin/session.py +52 -0
  28. supython/admin/spa.py +38 -0
  29. supython/admin/static/assets/Alert-dluGVkos.js +49 -0
  30. supython/admin/static/assets/Audit-Njung3HI.js +2 -0
  31. supython/admin/static/assets/Backups-DzPlFgrm.js +2 -0
  32. supython/admin/static/assets/Buckets-ByacGkU1.js +2 -0
  33. supython/admin/static/assets/Channels-BoIuTtam.js +353 -0
  34. supython/admin/static/assets/ChevronRight-CtQH1EQ1.js +2 -0
  35. supython/admin/static/assets/CodeViewer-Bqy7-wvH.js +2 -0
  36. supython/admin/static/assets/Crons-B67vc39F.js +2 -0
  37. supython/admin/static/assets/DashboardView-CUTFVL6k.js +2 -0
  38. supython/admin/static/assets/DataTable-COAAWEft.js +747 -0
  39. supython/admin/static/assets/DescriptionsItem-P8JUDaBs.js +75 -0
  40. supython/admin/static/assets/DrawerContent-TpYTFgF1.js +139 -0
  41. supython/admin/static/assets/Empty-cr2r7e2u.js +25 -0
  42. supython/admin/static/assets/EmptyState-DeDck-OL.js +2 -0
  43. supython/admin/static/assets/Grid-hFkp9F4P.js +2 -0
  44. supython/admin/static/assets/Input-DppYTq9C.js +259 -0
  45. supython/admin/static/assets/Invoke-DW3Nveeh.js +2 -0
  46. supython/admin/static/assets/JsonField-DibyJgun.js +2 -0
  47. supython/admin/static/assets/LoginView-BjLyE3Ds.css +1 -0
  48. supython/admin/static/assets/LoginView-CoOjECT_.js +111 -0
  49. supython/admin/static/assets/Logs-D9WYrnIT.js +2 -0
  50. supython/admin/static/assets/Logs-DS1XPa0h.css +1 -0
  51. supython/admin/static/assets/Migrations-DOSC2ddQ.js +2 -0
  52. supython/admin/static/assets/ObjectBrowser-_5w8vOX8.js +2 -0
  53. supython/admin/static/assets/Queue-CywZs6vI.js +2 -0
  54. supython/admin/static/assets/RefreshTokens-Ccjr53jg.js +2 -0
  55. supython/admin/static/assets/RlsEditor-BSlH9vSc.js +2 -0
  56. supython/admin/static/assets/Routes-BiLXE49D.js +2 -0
  57. supython/admin/static/assets/Routes-C-ianIGD.css +1 -0
  58. supython/admin/static/assets/SchemaBrowser-DKy2_KQi.css +1 -0
  59. supython/admin/static/assets/SchemaBrowser-XFvFbtDB.js +2 -0
  60. supython/admin/static/assets/Select-DIzZyRZb.js +434 -0
  61. supython/admin/static/assets/Space-n5-XcguU.js +400 -0
  62. supython/admin/static/assets/SqlEditor-b8pTsILY.js +3 -0
  63. supython/admin/static/assets/SqlWorkspace-BUS7IntH.js +104 -0
  64. supython/admin/static/assets/TableData-CQIagLKn.js +2 -0
  65. supython/admin/static/assets/Tag-D1fOKpTH.js +72 -0
  66. supython/admin/static/assets/Templates-BS-ugkdq.js +2 -0
  67. supython/admin/static/assets/Thing-CEAniuMg.js +107 -0
  68. supython/admin/static/assets/Users-wzwajhlh.js +2 -0
  69. supython/admin/static/assets/_plugin-vue_export-helper-DGA9ry_j.js +1 -0
  70. supython/admin/static/assets/dist-VXIJLCYq.js +13 -0
  71. supython/admin/static/assets/format-length-CGCY1rMh.js +2 -0
  72. supython/admin/static/assets/get-Ca6unauB.js +2 -0
  73. supython/admin/static/assets/index-CeE6v959.js +951 -0
  74. supython/admin/static/assets/pinia-COXwfrOX.js +2 -0
  75. supython/admin/static/assets/resources-Bt6thQCD.js +44 -0
  76. supython/admin/static/assets/use-locale-mtgM0a3a.js +2 -0
  77. supython/admin/static/assets/use-merged-state-BvhkaHNX.js +2 -0
  78. supython/admin/static/assets/useConfirm-tMjvBFXR.js +2 -0
  79. supython/admin/static/assets/useResource-C_rJCY8C.js +2 -0
  80. supython/admin/static/assets/useTable-CnZc5zhi.js +363 -0
  81. supython/admin/static/assets/useTable-Dg0XlRlq.css +1 -0
  82. supython/admin/static/assets/useToast-DsZKx0IX.js +2 -0
  83. supython/admin/static/assets/utils-sbXoq7Ir.js +2 -0
  84. supython/admin/static/favicon.svg +1 -0
  85. supython/admin/static/icons.svg +24 -0
  86. supython/admin/static/index.html +24 -0
  87. supython/app.py +149 -0
  88. supython/auth/__init__.py +3 -0
  89. supython/auth/_email_job.py +11 -0
  90. supython/auth/providers/__init__.py +34 -0
  91. supython/auth/providers/github.py +22 -0
  92. supython/auth/providers/google.py +19 -0
  93. supython/auth/providers/oauth.py +56 -0
  94. supython/auth/providers/registry.py +16 -0
  95. supython/auth/ratelimit.py +39 -0
  96. supython/auth/router.py +282 -0
  97. supython/auth/schemas.py +79 -0
  98. supython/auth/service.py +587 -0
  99. supython/body_size.py +184 -0
  100. supython/cli.py +1653 -0
  101. supython/client/__init__.py +67 -0
  102. supython/client/_auth.py +249 -0
  103. supython/client/_client.py +145 -0
  104. supython/client/_config.py +92 -0
  105. supython/client/_functions.py +69 -0
  106. supython/client/_storage.py +255 -0
  107. supython/client/py.typed +0 -0
  108. supython/db.py +151 -0
  109. supython/db_admin.py +8 -0
  110. supython/functions/__init__.py +19 -0
  111. supython/functions/context.py +262 -0
  112. supython/functions/loader.py +307 -0
  113. supython/functions/router.py +228 -0
  114. supython/functions/schemas.py +50 -0
  115. supython/gen/__init__.py +5 -0
  116. supython/gen/_introspect.py +137 -0
  117. supython/gen/types_py.py +270 -0
  118. supython/gen/types_ts.py +365 -0
  119. supython/health.py +229 -0
  120. supython/hooks.py +117 -0
  121. supython/jobs/__init__.py +31 -0
  122. supython/jobs/backends.py +97 -0
  123. supython/jobs/context.py +58 -0
  124. supython/jobs/cron.py +152 -0
  125. supython/jobs/cron_inproc.py +118 -0
  126. supython/jobs/decorators.py +76 -0
  127. supython/jobs/registry.py +79 -0
  128. supython/jobs/router.py +136 -0
  129. supython/jobs/schemas.py +92 -0
  130. supython/jobs/service.py +311 -0
  131. supython/jobs/worker.py +219 -0
  132. supython/jwks.py +257 -0
  133. supython/keyset.py +279 -0
  134. supython/logging_config.py +291 -0
  135. supython/mail.py +33 -0
  136. supython/mailer.py +65 -0
  137. supython/migrate.py +81 -0
  138. supython/migrations/0001_extensions_and_roles.sql +46 -0
  139. supython/migrations/0002_auth_schema.sql +66 -0
  140. supython/migrations/0003_demo_todos.sql +42 -0
  141. supython/migrations/0004_auth_v0_2.sql +47 -0
  142. supython/migrations/0005_storage_schema.sql +117 -0
  143. supython/migrations/0006_realtime_schema.sql +206 -0
  144. supython/migrations/0007_jobs_schema.sql +254 -0
  145. supython/migrations/0008_jobs_last_error.sql +56 -0
  146. supython/migrations/0009_auth_rate_limits.sql +33 -0
  147. supython/migrations/0010_worker_heartbeat.sql +14 -0
  148. supython/migrations/0011_admin_schema.sql +45 -0
  149. supython/migrations/0012_auth_banned_until.sql +10 -0
  150. supython/migrations/0013_email_templates.sql +19 -0
  151. supython/migrations/0014_realtime_payload_warning.sql +96 -0
  152. supython/migrations/0015_backups_schema.sql +14 -0
  153. supython/passwords.py +15 -0
  154. supython/realtime/__init__.py +6 -0
  155. supython/realtime/broker.py +814 -0
  156. supython/realtime/protocol.py +234 -0
  157. supython/realtime/router.py +184 -0
  158. supython/realtime/schemas.py +207 -0
  159. supython/realtime/service.py +261 -0
  160. supython/realtime/topics.py +175 -0
  161. supython/realtime/websocket.py +586 -0
  162. supython/scaffold/__init__.py +5 -0
  163. supython/scaffold/init_project.py +133 -0
  164. supython/scaffold/templates/Caddyfile.tmpl +4 -0
  165. supython/scaffold/templates/README.md.tmpl +22 -0
  166. supython/scaffold/templates/docker-compose.prod.yml.tmpl +84 -0
  167. supython/scaffold/templates/docker-compose.yml.tmpl +41 -0
  168. supython/scaffold/templates/docker_postgres_Dockerfile.tmpl +9 -0
  169. supython/scaffold/templates/docker_postgres_postgresql.conf.tmpl +3 -0
  170. supython/scaffold/templates/env.example.tmpl +149 -0
  171. supython/scaffold/templates/functions_README.md.tmpl +21 -0
  172. supython/scaffold/templates/gitignore.tmpl +14 -0
  173. supython/scaffold/templates/migrations/.gitkeep +0 -0
  174. supython/secretset.py +347 -0
  175. supython/security_headers.py +78 -0
  176. supython/settings.py +198 -0
  177. supython/storage/__init__.py +5 -0
  178. supython/storage/backends.py +392 -0
  179. supython/storage/router.py +341 -0
  180. supython/storage/schemas.py +50 -0
  181. supython/storage/service.py +445 -0
  182. supython/storage/signing.py +119 -0
  183. supython/tokens.py +85 -0
  184. supython-0.5.0.dist-info/METADATA +714 -0
  185. supython-0.5.0.dist-info/RECORD +188 -0
  186. supython-0.5.0.dist-info/WHEEL +4 -0
  187. supython-0.5.0.dist-info/entry_points.txt +2 -0
  188. 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