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,586 @@
1
+ """FastAPI WebSocket route for ``/realtime/v1/websocket``.
2
+
3
+ This module is the only place where Phoenix Channels framing meets
4
+ Starlette's WebSocket transport. The routing surface is a single
5
+ endpoint::
6
+
7
+ ws://<host>/realtime/v1/websocket?apikey=<jwt>&vsn=1.0.0
8
+
9
+ All framing, encode/decode, ref counters, and heartbeat timing live in
10
+ :mod:`.protocol`. Broker state (subscriptions, presence, fan-out) lives
11
+ in :mod:`.broker`. This module only:
12
+
13
+ * extracts the JWT (query string ``?apikey=`` or
14
+ ``Sec-WebSocket-Protocol: bearer, <jwt>`` subprotocol — both paths
15
+ used by the official Supabase SDKs);
16
+ * runs three concurrent tasks per connection (reader, writer, heartbeat
17
+ watchdog) using :class:`asyncio.TaskGroup`;
18
+ * dispatches incoming frames to the right broker call;
19
+ * maps domain errors to ``phx_reply`` ``status="error"``.
20
+
21
+ Token decoding goes through :func:`tokens.decode_access_token` so RS256
22
+ support (deferred to v1.0) lights up automatically when the auth module
23
+ ships it.
24
+ """
25
+
26
+ import asyncio
27
+ import logging
28
+ from typing import Any
29
+
30
+ import jwt
31
+ from fastapi import APIRouter, WebSocket
32
+ from pydantic import ValidationError
33
+ from starlette.websockets import WebSocketDisconnect, WebSocketState
34
+
35
+ from .. import tokens
36
+ from ..settings import get_settings
37
+ from .broker import Broker, BrokerError, Connection, get_broker
38
+ from .protocol import (
39
+ EVENT_ACCESS_TOKEN,
40
+ EVENT_BROADCAST,
41
+ EVENT_HEARTBEAT,
42
+ EVENT_PHX_CLOSE,
43
+ EVENT_PHX_ERROR,
44
+ EVENT_PHX_JOIN,
45
+ EVENT_PHX_LEAVE,
46
+ EVENT_POSTGRES_CHANGES, # noqa: F401 (re-exported for completeness)
47
+ EVENT_PRESENCE,
48
+ STATUS_ERROR,
49
+ STATUS_OK,
50
+ TOPIC_PHOENIX,
51
+ HeartbeatTimeout,
52
+ ProtocolError,
53
+ decode,
54
+ encode,
55
+ make_reply,
56
+ make_server_push,
57
+ )
58
+ from .schemas import Frame, JoinReplyResponse, PhxJoinPayload
59
+ from .topics import (
60
+ FilterError,
61
+ TopicError,
62
+ assign_subscription_ids,
63
+ resolved_to_subscription_schema,
64
+ validate_topic,
65
+ )
66
+
67
+ logger = logging.getLogger(__name__)
68
+
69
+
70
+ # ---------------------------------------------------------------------------
71
+ # Close codes (RFC 6455 + Supabase conventions)
72
+ # ---------------------------------------------------------------------------
73
+
74
+ _WS_CLOSE_NORMAL = 1000
75
+ _WS_CLOSE_GOING_AWAY = 1001 # used for heartbeat timeout
76
+ _WS_CLOSE_POLICY_VIOLATION = 1008 # used for auth failure
77
+ _WS_CLOSE_TRY_AGAIN_LATER = 1013 # broker over capacity / writer back-pressure
78
+ _WS_CLOSE_INTERNAL_ERROR = 1011
79
+
80
+ _ANON_CLAIMS: dict[str, Any] = {"role": "anon"}
81
+
82
+
83
+ router = APIRouter(prefix="/realtime/v1", tags=["realtime"])
84
+
85
+
86
+ # ---------------------------------------------------------------------------
87
+ # Auth helpers
88
+ # ---------------------------------------------------------------------------
89
+
90
+
91
+ def _extract_token(ws: WebSocket) -> tuple[str | None, str | None]:
92
+ """Pull the JWT off either the query string or the bearer subprotocol.
93
+
94
+ Returns ``(token, subprotocol_to_echo)``. ``subprotocol_to_echo`` is
95
+ ``"bearer"`` if the client used the subprotocol path (so we have to
96
+ accept with that subprotocol to satisfy the RFC 6455 handshake) and
97
+ ``None`` otherwise.
98
+ """
99
+ token = ws.query_params.get("apikey") or ws.query_params.get("access_token")
100
+ if token is not None:
101
+ return token, None
102
+ subprotos = ws.scope.get("subprotocols") or []
103
+ if (
104
+ len(subprotos) >= 2
105
+ and subprotos[0].lower() == "bearer"
106
+ and subprotos[1]
107
+ ):
108
+ return subprotos[1], "bearer"
109
+ return None, None
110
+
111
+
112
+ def _decode_or_anon(token: str | None) -> tuple[str, dict[str, Any]]:
113
+ """Decode *token* into ``(role, claims)``.
114
+
115
+ No token → ``("anon", {"role": "anon"})``. An invalid token raises
116
+ :class:`jwt.InvalidTokenError` which the caller turns into a
117
+ 1008 (policy violation) close.
118
+ """
119
+ if not token:
120
+ return "anon", dict(_ANON_CLAIMS)
121
+ claims = tokens.decode_access_token(token)
122
+ role = str(claims.get("role") or "authenticated")
123
+ return role, claims
124
+
125
+
126
+ # ---------------------------------------------------------------------------
127
+ # Frame send helpers
128
+ # ---------------------------------------------------------------------------
129
+
130
+
131
+ async def _send_frame(ws: WebSocket, frame: Frame) -> None:
132
+ if ws.client_state is not WebSocketState.CONNECTED:
133
+ return
134
+ try:
135
+ await ws.send_text(encode(frame))
136
+ except (WebSocketDisconnect, RuntimeError):
137
+ # Writer races with disconnect; the reader / cleanup path will
138
+ # close the broker registration shortly.
139
+ return
140
+
141
+
142
+ async def _reply_ok(
143
+ ws: WebSocket,
144
+ *,
145
+ join_ref: str | None,
146
+ ref: str | None,
147
+ topic: str,
148
+ response: dict[str, Any] | None = None,
149
+ ) -> None:
150
+ await _send_frame(
151
+ ws,
152
+ make_reply(
153
+ join_ref=join_ref,
154
+ ref=ref,
155
+ topic=topic,
156
+ status=STATUS_OK,
157
+ response=response,
158
+ ),
159
+ )
160
+
161
+
162
+ async def _reply_error(
163
+ ws: WebSocket,
164
+ *,
165
+ join_ref: str | None,
166
+ ref: str | None,
167
+ topic: str,
168
+ reason: str,
169
+ ) -> None:
170
+ await _send_frame(
171
+ ws,
172
+ make_reply(
173
+ join_ref=join_ref,
174
+ ref=ref,
175
+ topic=topic,
176
+ status=STATUS_ERROR,
177
+ response={"reason": reason},
178
+ ),
179
+ )
180
+
181
+
182
+ # ---------------------------------------------------------------------------
183
+ # Endpoint
184
+ # ---------------------------------------------------------------------------
185
+
186
+
187
+ @router.websocket("/websocket")
188
+ async def realtime_websocket(ws: WebSocket) -> None:
189
+ """Phoenix-channels WebSocket endpoint speaking ``vsn=1.0.0`` JSON."""
190
+ settings = get_settings()
191
+ if not settings.realtime_enabled:
192
+ # Refuse upgrade — no accept = HTTP 403 to the WS handshake.
193
+ await ws.close(code=_WS_CLOSE_INTERNAL_ERROR, reason="realtime disabled")
194
+ return
195
+
196
+ token, subprotocol_echo = _extract_token(ws)
197
+ try:
198
+ role, claims = _decode_or_anon(token)
199
+ except jwt.InvalidTokenError as exc:
200
+ logger.warning("realtime: rejecting connection — invalid JWT: %s", exc)
201
+ await ws.close(code=_WS_CLOSE_POLICY_VIOLATION, reason="invalid token")
202
+ return
203
+
204
+ await ws.accept(subprotocol=subprotocol_echo)
205
+
206
+ broker = get_broker()
207
+ try:
208
+ conn = await broker.register(role=role, claims=claims)
209
+ except BrokerError as exc:
210
+ logger.warning("realtime: rejecting connection — %s", exc)
211
+ await ws.close(code=_WS_CLOSE_TRY_AGAIN_LATER, reason=str(exc))
212
+ return
213
+
214
+ heartbeat = HeartbeatTimeout(float(settings.realtime_heartbeat_timeout_seconds))
215
+ max_subs = settings.realtime_max_subs_per_conn
216
+ timed_out = False
217
+
218
+ async def _drain() -> None:
219
+ """Writer task: pull frames off the broker outbound queue and send."""
220
+ while True:
221
+ frame = await conn.outbound.get()
222
+ await _send_frame(ws, frame)
223
+
224
+ async def _read() -> None:
225
+ """Reader task: parse frames and dispatch to the broker."""
226
+ while True:
227
+ raw = await ws.receive_text()
228
+ heartbeat.touch()
229
+ try:
230
+ frame = decode(raw)
231
+ except ProtocolError as exc:
232
+ logger.debug("realtime: dropped malformed frame: %s", exc)
233
+ continue
234
+ await _dispatch(ws, broker, conn, frame, max_subs=max_subs)
235
+
236
+ async def _watchdog() -> None:
237
+ """Heartbeat watchdog: close socket on idle deadline."""
238
+ nonlocal timed_out
239
+ await heartbeat.wait_for_timeout()
240
+ timed_out = True
241
+ # The reader's blocking ws.receive_text() will raise as soon as the
242
+ # socket is closed, unwinding the TaskGroup.
243
+ with _suppress_disconnect():
244
+ await ws.close(code=_WS_CLOSE_GOING_AWAY, reason="heartbeat timeout")
245
+
246
+ try:
247
+ async with asyncio.TaskGroup() as tg:
248
+ tg.create_task(_read(), name=f"realtime-ws-{conn.id}-read")
249
+ tg.create_task(_drain(), name=f"realtime-ws-{conn.id}-write")
250
+ tg.create_task(_watchdog(), name=f"realtime-ws-{conn.id}-watchdog")
251
+ except* WebSocketDisconnect:
252
+ pass
253
+ except* Exception: # noqa: BLE001
254
+ logger.exception("realtime: unhandled error on conn=%s", conn.id)
255
+ finally:
256
+ await broker.unregister(conn)
257
+ if ws.client_state is not WebSocketState.DISCONNECTED:
258
+ with _suppress_disconnect():
259
+ await ws.close(code=_WS_CLOSE_NORMAL if not timed_out else _WS_CLOSE_GOING_AWAY)
260
+
261
+
262
+ # ---------------------------------------------------------------------------
263
+ # Dispatch
264
+ # ---------------------------------------------------------------------------
265
+
266
+
267
+ async def _dispatch(
268
+ ws: WebSocket,
269
+ broker: Broker,
270
+ conn: Connection,
271
+ frame: Frame,
272
+ *,
273
+ max_subs: int,
274
+ ) -> None:
275
+ topic = frame.topic
276
+ event = frame.event
277
+
278
+ if topic == TOPIC_PHOENIX:
279
+ if event == EVENT_HEARTBEAT:
280
+ await _reply_ok(ws, join_ref=frame.join_ref, ref=frame.ref, topic=topic)
281
+ else:
282
+ logger.debug(
283
+ "realtime: ignoring unsupported phoenix event %r on conn=%s",
284
+ event,
285
+ conn.id,
286
+ )
287
+ return
288
+
289
+ # All other events live on a realtime:* channel.
290
+ try:
291
+ validate_topic(topic)
292
+ except TopicError as exc:
293
+ await _reply_error(
294
+ ws,
295
+ join_ref=frame.join_ref,
296
+ ref=frame.ref,
297
+ topic=topic,
298
+ reason=str(exc),
299
+ )
300
+ return
301
+
302
+ if event == EVENT_PHX_JOIN:
303
+ await _handle_join(ws, broker, conn, frame, max_subs=max_subs)
304
+ return
305
+ if event == EVENT_PHX_LEAVE:
306
+ await _handle_leave(ws, broker, conn, frame)
307
+ return
308
+ if event == EVENT_ACCESS_TOKEN:
309
+ await _handle_access_token(ws, broker, conn, frame)
310
+ return
311
+ if event == EVENT_BROADCAST:
312
+ await _handle_broadcast(ws, broker, conn, frame)
313
+ return
314
+ if event == EVENT_PRESENCE:
315
+ await _handle_presence(ws, broker, conn, frame)
316
+ return
317
+
318
+ logger.debug(
319
+ "realtime: ignoring unknown event %r on topic %r conn=%s",
320
+ event,
321
+ topic,
322
+ conn.id,
323
+ )
324
+
325
+
326
+ # ---------------------------------------------------------------------------
327
+ # Handlers
328
+ # ---------------------------------------------------------------------------
329
+
330
+
331
+ async def _handle_join(
332
+ ws: WebSocket,
333
+ broker: Broker,
334
+ conn: Connection,
335
+ frame: Frame,
336
+ *,
337
+ max_subs: int,
338
+ ) -> None:
339
+ try:
340
+ payload = PhxJoinPayload.model_validate(frame.payload)
341
+ except ValidationError as exc:
342
+ await _reply_error(
343
+ ws,
344
+ join_ref=frame.join_ref,
345
+ ref=frame.ref,
346
+ topic=frame.topic,
347
+ reason=f"invalid join config: {exc.errors()[0]['msg']}",
348
+ )
349
+ return
350
+
351
+ # Rotate JWT if the join carries one. Failure here is a hard reject —
352
+ # joining with a bad token would let the client believe it is
353
+ # authenticated when it is not.
354
+ if payload.access_token:
355
+ try:
356
+ role, claims = _decode_or_anon(payload.access_token)
357
+ except jwt.InvalidTokenError as exc:
358
+ await _reply_error(
359
+ ws,
360
+ join_ref=frame.join_ref,
361
+ ref=frame.ref,
362
+ topic=frame.topic,
363
+ reason=f"invalid access_token: {exc}",
364
+ )
365
+ return
366
+ broker.update_claims(conn, role=role, claims=claims)
367
+
368
+ # Per-connection subscription cap. Counts each filter as one
369
+ # subscription so a client that joins 100 channels with 100 filters
370
+ # each does not silently swamp the registry.
371
+ new_total = sum(
372
+ len(s.postgres_changes) for s in conn.subscriptions.values()
373
+ ) + len(payload.config.postgres_changes)
374
+ if new_total > max_subs:
375
+ await _reply_error(
376
+ ws,
377
+ join_ref=frame.join_ref,
378
+ ref=frame.ref,
379
+ topic=frame.topic,
380
+ reason=f"per-connection subscription cap reached ({max_subs})",
381
+ )
382
+ return
383
+
384
+ try:
385
+ resolved = assign_subscription_ids(payload.config)
386
+ except FilterError as exc:
387
+ await _reply_error(
388
+ ws,
389
+ join_ref=frame.join_ref,
390
+ ref=frame.ref,
391
+ topic=frame.topic,
392
+ reason=str(exc),
393
+ )
394
+ return
395
+
396
+ sub = await broker.subscribe(
397
+ conn,
398
+ topic=frame.topic,
399
+ join_ref=frame.join_ref,
400
+ postgres_changes=resolved,
401
+ broadcast_self=payload.config.broadcast.self_echo,
402
+ presence_key=payload.config.presence.key,
403
+ )
404
+
405
+ response = JoinReplyResponse(
406
+ postgres_changes=[resolved_to_subscription_schema(r) for r in sub.postgres_changes],
407
+ )
408
+ await _reply_ok(
409
+ ws,
410
+ join_ref=frame.join_ref,
411
+ ref=frame.ref,
412
+ topic=frame.topic,
413
+ response=response.model_dump(by_alias=True),
414
+ )
415
+
416
+ await broker.push_presence_state(conn, frame.topic)
417
+
418
+
419
+ async def _handle_leave(
420
+ ws: WebSocket,
421
+ broker: Broker,
422
+ conn: Connection,
423
+ frame: Frame,
424
+ ) -> None:
425
+ await broker.unsubscribe(conn, frame.topic)
426
+ await _reply_ok(ws, join_ref=frame.join_ref, ref=frame.ref, topic=frame.topic)
427
+ # Phoenix follows the ack with a phx_close so the SDK can drop its
428
+ # local channel state without a rejoin loop.
429
+ await _send_frame(
430
+ ws,
431
+ make_server_push(topic=frame.topic, event=EVENT_PHX_CLOSE, payload={}),
432
+ )
433
+
434
+
435
+ async def _handle_access_token(
436
+ ws: WebSocket,
437
+ broker: Broker,
438
+ conn: Connection,
439
+ frame: Frame,
440
+ ) -> None:
441
+ raw = frame.payload.get("access_token") if isinstance(frame.payload, dict) else None
442
+ if not isinstance(raw, str) or not raw:
443
+ await _reply_error(
444
+ ws,
445
+ join_ref=frame.join_ref,
446
+ ref=frame.ref,
447
+ topic=frame.topic,
448
+ reason="missing access_token",
449
+ )
450
+ return
451
+ try:
452
+ role, claims = _decode_or_anon(raw)
453
+ except jwt.InvalidTokenError as exc:
454
+ await _reply_error(
455
+ ws,
456
+ join_ref=frame.join_ref,
457
+ ref=frame.ref,
458
+ topic=frame.topic,
459
+ reason=f"invalid access_token: {exc}",
460
+ )
461
+ # An invalid rotation should not nuke the existing claims; the
462
+ # client may simply have shipped a malformed retry.
463
+ return
464
+ broker.update_claims(conn, role=role, claims=claims)
465
+ await _reply_ok(ws, join_ref=frame.join_ref, ref=frame.ref, topic=frame.topic)
466
+
467
+
468
+ async def _handle_broadcast(
469
+ ws: WebSocket,
470
+ broker: Broker,
471
+ conn: Connection,
472
+ frame: Frame,
473
+ ) -> None:
474
+ if frame.topic not in conn.subscriptions:
475
+ await _reply_error(
476
+ ws,
477
+ join_ref=frame.join_ref,
478
+ ref=frame.ref,
479
+ topic=frame.topic,
480
+ reason="not joined",
481
+ )
482
+ return
483
+ if conn.is_token_expired and conn.role != "anon":
484
+ await _reply_error(
485
+ ws,
486
+ join_ref=frame.join_ref,
487
+ ref=frame.ref,
488
+ topic=frame.topic,
489
+ reason="access_token expired",
490
+ )
491
+ return
492
+ payload = frame.payload if isinstance(frame.payload, dict) else {}
493
+ inner_event = str(payload.get("event") or "")
494
+ inner_payload = payload.get("payload") if isinstance(payload.get("payload"), dict) else {}
495
+ broker.broadcast(
496
+ topic=frame.topic,
497
+ event=inner_event,
498
+ payload=inner_payload, # type: ignore[arg-type]
499
+ sender_id=conn.id,
500
+ )
501
+ if frame.ref is not None:
502
+ await _reply_ok(ws, join_ref=frame.join_ref, ref=frame.ref, topic=frame.topic)
503
+
504
+
505
+ async def _handle_presence(
506
+ ws: WebSocket,
507
+ broker: Broker,
508
+ conn: Connection,
509
+ frame: Frame,
510
+ ) -> None:
511
+ if frame.topic not in conn.subscriptions:
512
+ await _reply_error(
513
+ ws,
514
+ join_ref=frame.join_ref,
515
+ ref=frame.ref,
516
+ topic=frame.topic,
517
+ reason="not joined",
518
+ )
519
+ return
520
+ if conn.is_token_expired and conn.role != "anon":
521
+ await _reply_error(
522
+ ws,
523
+ join_ref=frame.join_ref,
524
+ ref=frame.ref,
525
+ topic=frame.topic,
526
+ reason="access_token expired",
527
+ )
528
+ return
529
+ payload = frame.payload if isinstance(frame.payload, dict) else {}
530
+ presence_event = str(payload.get("event") or "")
531
+ inner_payload = payload.get("payload") if isinstance(payload.get("payload"), dict) else {}
532
+
533
+ try:
534
+ if presence_event == "track":
535
+ await broker.track_presence(conn, topic=frame.topic, meta=inner_payload or {})
536
+ elif presence_event == "untrack":
537
+ await broker.untrack_presence(conn, topic=frame.topic)
538
+ else:
539
+ await _reply_error(
540
+ ws,
541
+ join_ref=frame.join_ref,
542
+ ref=frame.ref,
543
+ topic=frame.topic,
544
+ reason=f"unknown presence event {presence_event!r}",
545
+ )
546
+ return
547
+ except BrokerError as exc:
548
+ await _reply_error(
549
+ ws,
550
+ join_ref=frame.join_ref,
551
+ ref=frame.ref,
552
+ topic=frame.topic,
553
+ reason=str(exc),
554
+ )
555
+ return
556
+
557
+ if frame.ref is not None:
558
+ await _reply_ok(ws, join_ref=frame.join_ref, ref=frame.ref, topic=frame.topic)
559
+
560
+
561
+ # ---------------------------------------------------------------------------
562
+ # Misc
563
+ # ---------------------------------------------------------------------------
564
+
565
+
566
+ class _suppress_disconnect:
567
+ """Tiny context manager: swallow ``WebSocketDisconnect`` and ``RuntimeError``.
568
+
569
+ Closing or sending on an already-closed Starlette ``WebSocket`` raises
570
+ one of these two; in cleanup paths we never want to hide the original
571
+ failure behind a "WebSocket is not connected" wrapper.
572
+ """
573
+
574
+ def __enter__(self) -> "_suppress_disconnect":
575
+ return self
576
+
577
+ def __exit__(self, exc_type, exc, tb) -> bool: # noqa: ANN001
578
+ return exc_type is not None and issubclass(
579
+ exc_type, (WebSocketDisconnect, RuntimeError)
580
+ )
581
+
582
+
583
+ __all__ = [
584
+ "router",
585
+ "EVENT_PHX_ERROR", # re-exported for downstream convenience
586
+ ]
@@ -0,0 +1,5 @@
1
+ """Project scaffold for `supython init`."""
2
+
3
+ from .init_project import scaffold
4
+
5
+ __all__ = ["scaffold"]