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,714 @@
1
+ Metadata-Version: 2.4
2
+ Name: supython
3
+ Version: 0.5.0
4
+ Summary: A lightweight Postgres-first BaaS framework for Python
5
+ Project-URL: Homepage, https://github.com/Tkeby/supython
6
+ Project-URL: Repository, https://github.com/Tkeby/supython
7
+ Project-URL: Issues, https://github.com/Tkeby/supython/issues
8
+ Project-URL: Changelog, https://github.com/Tkeby/supython/blob/main/CHANGELOG.md
9
+ Project-URL: Documentation, https://github.com/Tkeby/supython/blob/main/docs/PROJECT.md
10
+ Author-email: Tkeby <tsegaw.dev@gmail.com>
11
+ License-Expression: MIT
12
+ License-File: LICENSE
13
+ Keywords: auth,baas,fastapi,postgres,postgrest
14
+ Classifier: Development Status :: 3 - Alpha
15
+ Classifier: Framework :: FastAPI
16
+ Classifier: Intended Audience :: Developers
17
+ Classifier: Operating System :: OS Independent
18
+ Classifier: Programming Language :: Python :: 3 :: Only
19
+ Classifier: Programming Language :: Python :: 3.11
20
+ Classifier: Programming Language :: Python :: 3.12
21
+ Classifier: Programming Language :: Python :: 3.13
22
+ Classifier: Topic :: Database
23
+ Classifier: Topic :: Internet :: WWW/HTTP
24
+ Classifier: Topic :: Software Development :: Libraries :: Application Frameworks
25
+ Classifier: Typing :: Typed
26
+ Requires-Python: >=3.11
27
+ Requires-Dist: aiosmtplib>=3.0
28
+ Requires-Dist: argon2-cffi>=23.1
29
+ Requires-Dist: asyncpg>=0.30
30
+ Requires-Dist: authlib>=1.3
31
+ Requires-Dist: email-validator>=2.2
32
+ Requires-Dist: fastapi>=0.115
33
+ Requires-Dist: httpx>=0.28
34
+ Requires-Dist: itsdangerous>=2.2
35
+ Requires-Dist: pydantic-settings>=2.6
36
+ Requires-Dist: pydantic>=2.9
37
+ Requires-Dist: pyjwt[crypto]>=2.10
38
+ Requires-Dist: python-multipart>=0.0.20
39
+ Requires-Dist: typer>=0.15
40
+ Requires-Dist: uvicorn[standard]>=0.32
41
+ Provides-Extra: arq
42
+ Requires-Dist: arq>=0.26; extra == 'arq'
43
+ Provides-Extra: client
44
+ Requires-Dist: httpx>=0.28; extra == 'client'
45
+ Requires-Dist: postgrest-py>=0.16.0; extra == 'client'
46
+ Requires-Dist: realtime>=2.0.0; extra == 'client'
47
+ Provides-Extra: cron-inproc
48
+ Requires-Dist: croniter>=2.0; extra == 'cron-inproc'
49
+ Provides-Extra: dev
50
+ Requires-Dist: pytest-asyncio>=0.24; extra == 'dev'
51
+ Requires-Dist: pytest>=8.3; extra == 'dev'
52
+ Requires-Dist: ruff>=0.8; extra == 'dev'
53
+ Provides-Extra: dramatiq
54
+ Requires-Dist: dramatiq>=1.17; extra == 'dramatiq'
55
+ Provides-Extra: s3
56
+ Requires-Dist: aioboto3>=13.0; extra == 's3'
57
+ Description-Content-Type: text/markdown
58
+
59
+ # supython
60
+
61
+ > A lightweight, Postgres-first BaaS framework for Python. **v0.8 pre-release — approaching v1.0.**
62
+
63
+ supython is the inverse of Django: **the database owns the schema, Python owns
64
+ the things SQL is bad at**. It leans on [PostgREST](https://postgrest.org)
65
+ for auto-generated REST APIs and on Postgres' own RLS for authorization,
66
+ while a small FastAPI service in Python handles auth, JWT issuance, realtime,
67
+ storage, functions, workers, and an optional admin control plane.
68
+
69
+ supython is for a specific person with a specific problem:
70
+ > A developer who wants to build a CRUD-heavy web app (most apps are), who thinks in SQL, who wants Postgres to own authorization, and who wants auth + storage + custom logic without assembling the integration themselves.
71
+
72
+ Shipped and hardened across v0.1–v0.8:
73
+
74
+ **Core platform**
75
+ - **Email/password auth** — signup, login, refresh-token rotation with **reuse detection**
76
+ - **OAuth** (Google + GitHub) via `authlib` with PKCE
77
+ - **Password reset**, **magic link**, and **email OTP** (pluggable email backend)
78
+ - **RS256 JWT** — asymmetric signing; PostgREST verifies via shared JWKS; zero-downtime key rotation
79
+ - **Rate limiting** on auth endpoints (per-IP fixed-window counters)
80
+ - **Row-Level Security** — `auth.uid()` helper, `request.jwt.claims` GUC, role-scoped DB access via `db.as_role()`
81
+ - **`supython init`** — scaffold a new project in one command
82
+ - **`supython gen types --lang py`** — emit typed dataclasses + TypedDicts from your Postgres schema
83
+ - **zero Python CRUD code** — every read, write, filter, sort, and pagination is served by PostgREST under RLS
84
+
85
+ **Storage & functions**
86
+ - **S3/local storage** with RLS-on-metadata, signed URLs, multipart upload, and range download
87
+ - **Edge functions** from a `functions/` directory with hot reload and role-scoped DB access
88
+
89
+ **Realtime**
90
+ - **WebSocket Realtime** — `postgres_changes`, `broadcast`, `presence` with per-subscriber RLS filtering
91
+ - **Phoenix Channels wire format** — unmodified `supabase-js` / `supabase-py` SDKs connect
92
+ - **Generic trigger** — `realtime.enable('public.todos')` opts any table in
93
+ - **Two-browser chat demo** — `examples/chat.html` (zero build step)
94
+
95
+ **Jobs & cron**
96
+ - **Job queue** — Postgres-backed (`SELECT FOR UPDATE SKIP LOCKED`), idempotent enqueue, retry with backoff
97
+ - **Cron scheduling** — `pg_cron` (primary) or in-process `croniter` fallback
98
+ - **Generic hooks** — `@app.on_signup` / `@app.on_login` lifecycle hooks
99
+ - **`supython worker run`** — long-running worker with graceful SIGTERM drain
100
+
101
+ **Operations & security**
102
+ - **Structured JSON logs** — request-id propagation, redacted auth headers, tracebacks on 5xx
103
+ - **`/livez` / `/readyz` / `/health`** — liveness, dependency readiness (DB, PostgREST, broker, worker), full detail
104
+ - **Security headers** — HSTS, CSP, X-Content-Type-Options, X-Frame-Options, Referrer-Policy
105
+ - **`supython doctor`** — diagnoses roles, extensions, grants, JWKS, migration drift, symmetric secrets
106
+ - **Secret rotation** — JWT keys, symmetric secrets, Postgres passwords; all with zero-downtime runbooks
107
+ - **Multi-arch Docker image** — `linux/amd64` + `linux/arm64`, non-root user, `tini` PID 1, ~64 MB
108
+
109
+ **Admin control plane** (shipped in v0.8, originally deferred to v1.1)
110
+ - **Vue 3 + Vite SPA** at `/admin` — no runtime Node deps; pre-built static bundle in the wheel
111
+ - **Database surface** — schema browser, table data with role switcher, SQL workspace (read-only default + write toggle), RLS policy editor with dry-run, migrations panel
112
+ - **Auth surface** — user search, ban/unban/force-logout, refresh-token inspector, audit log, email template editing
113
+ - **Module screens** — storage buckets/objects, function routes/invoke, realtime tables/channels, job queue/crons
114
+ - **Ops** — backup management, live log tail via SSE with level/request-id filters
115
+ - **`supython admin create-user`** — bootstrap the first admin (no chicken-and-egg)
116
+
117
+ ## Architecture
118
+
119
+ ```
120
+ ┌──────────────────────┐
121
+ client ─────► │ supython (FastAPI) │ /auth/v1/*, /storage/v1/*,
122
+ │ port 8000 │ /functions/v1/*, /jobs/v1/*,
123
+ │ │ /admin, /livez, /readyz ──┐
124
+ └──────────────────────┘ │
125
+ │ │
126
+ ┌──────────────────────┐ │
127
+ client ─────► │ PostgREST │ /<table> ─┤
128
+ │ port 54321 │ │
129
+ └──────────────────────┘ │
130
+ │ │
131
+ ▼ │
132
+ ┌──────────────────────┐ ◄───────────────────────────┘
133
+ │ Postgres │
134
+ │ port 54322 │
135
+ │ roles: anon / │
136
+ │ authenticated / │
137
+ │ service_role │
138
+ └──────────────────────┘
139
+ ```
140
+
141
+ The unifying contract is the **JWT** + **Postgres role system**. supython
142
+ mints the JWT; PostgREST verifies the signature via shared JWKS and runs
143
+ every request under the role + claims it carries. RLS does the rest.
144
+
145
+ ## Quick start
146
+
147
+ Requires Python 3.11+, Docker 24+ with the `compose` plugin.
148
+
149
+ ```bash
150
+ # 1. install the wheel
151
+ python -m venv .venv && source .venv/bin/activate
152
+ pip install supython
153
+
154
+ # 2. scaffold a new project
155
+ supython init myapp
156
+ cd myapp
157
+ cp .env.example .env
158
+ # Review .env — at minimum confirm AUTHENTICATOR_PASSWORD matches
159
+ # the value PostgREST will use (docker-compose.yml injects it via env).
160
+
161
+ # 3. boot Postgres + PostgREST and run migrations (one command)
162
+ supython up
163
+
164
+ # 4. run the auth/API service (separate terminal)
165
+ supython dev
166
+
167
+ # 5. (optional) generate typed Python classes from your Postgres schema
168
+ supython gen types --lang py --out types.py
169
+
170
+ # 6. (optional) bootstrap the admin dashboard
171
+ supython admin create-user
172
+ # then open http://localhost:8000/admin
173
+ ```
174
+
175
+ You should now have:
176
+
177
+ | service | url |
178
+ | ---------- | ---------------------------- |
179
+ | supython | http://localhost:8000 |
180
+ | Admin UI | http://localhost:8000/admin |
181
+ | PostgREST | http://localhost:54321 |
182
+ | Postgres | `postgres://supython:supython@localhost:54322/supython` |
183
+
184
+ ## End-to-end smoke test
185
+
186
+ ```bash
187
+ # sign up
188
+ curl -sS -X POST http://localhost:8000/auth/v1/signup \
189
+ -H 'content-type: application/json' \
190
+ -d '{"email":"alice@example.com","password":"password123"}'
191
+
192
+ # get a fresh token
193
+ TOKEN=$(curl -sS -X POST http://localhost:8000/auth/v1/token \
194
+ -H 'content-type: application/json' \
195
+ -d '{"email":"alice@example.com","password":"password123"}' \
196
+ | python -c 'import sys,json;print(json.load(sys.stdin)["access_token"])')
197
+
198
+ # create a todo via PostgREST — note: NO Python code involved
199
+ curl -sS -X POST http://localhost:54321/todos \
200
+ -H "authorization: Bearer $TOKEN" \
201
+ -H 'content-type: application/json' \
202
+ -H 'prefer: return=representation' \
203
+ -d '{"title":"buy milk"}'
204
+
205
+ # list todos — RLS hides everyone else's rows
206
+ curl -sS http://localhost:54321/todos -H "authorization: Bearer $TOKEN"
207
+ ```
208
+
209
+ See [`examples/todos.http`](examples/todos.http) for the full set of calls
210
+ including filtering, sorting, refresh, and isolation between users.
211
+
212
+ ## Realtime quickstart
213
+
214
+ supython ships a WebSocket engine that speaks the **Phoenix Channels 5-tuple
215
+ protocol** used by every official Supabase SDK. Unmodified `supabase-js` and
216
+ `supabase-py` clients connect without any shim.
217
+
218
+ ### 1. Opt a table into realtime
219
+
220
+ ```sql
221
+ -- run once (or add to a migration)
222
+ SELECT realtime.enable('public.messages');
223
+ -- with a custom owner column for DELETE visibility:
224
+ -- SELECT realtime.enable('public.messages', 'author_id');
225
+ ```
226
+
227
+ Or via CLI (requires a running server):
228
+
229
+ ```bash
230
+ supython realtime enable public.messages
231
+ ```
232
+
233
+ ### 2. Open the demo
234
+
235
+ ```bash
236
+ # start the stack
237
+ supython up
238
+ supython dev # in a second terminal
239
+
240
+ # open the chat demo in two browser tabs
241
+ python -m http.server --directory examples 8080
242
+ # → http://localhost:8080/chat.html
243
+ ```
244
+
245
+ Enter your JWT (or leave blank for `anon` role), pick a room name, and open
246
+ the same page in a second tab. Messages broadcast instantly; Postgres row
247
+ changes appear as structured cards.
248
+
249
+ ### 3. Subscribe from JavaScript (no SDK required)
250
+
251
+ ```js
252
+ // Phoenix Channels 5-tuple: [join_ref, ref, topic, event, payload]
253
+ const ws = new WebSocket(
254
+ "ws://localhost:8000/realtime/v1/websocket?apikey=<JWT>&vsn=1.0.0"
255
+ );
256
+
257
+ let ref = 0;
258
+ ws.onopen = () => {
259
+ ws.send(JSON.stringify(["1", String(++ref), "realtime:room-42", "phx_join", {
260
+ config: {
261
+ postgres_changes: [{ event: "*", schema: "public", table: "messages" }],
262
+ broadcast: { self: false }
263
+ },
264
+ access_token: "<JWT>"
265
+ }]));
266
+ };
267
+
268
+ ws.onmessage = ({ data }) => {
269
+ const [, , topic, event, payload] = JSON.parse(data);
270
+ if (event === "postgres_changes") console.log(payload.data);
271
+ if (event === "broadcast") console.log(payload.payload);
272
+ };
273
+ ```
274
+
275
+ ### 4. Subscribe from Python
276
+
277
+ ```python
278
+ import asyncio, json
279
+ import websockets
280
+
281
+ TOKEN = "eyJ..." # or omit for anon
282
+
283
+ async def main():
284
+ url = f"ws://localhost:8000/realtime/v1/websocket?apikey={TOKEN}&vsn=1.0.0"
285
+ async with websockets.connect(url) as ws:
286
+ await ws.send(json.dumps([
287
+ "1", "1", "realtime:room-42", "phx_join",
288
+ {"config": {"broadcast": {"self": True}}, "access_token": TOKEN}
289
+ ]))
290
+ async for raw in ws:
291
+ join_ref, ref, topic, event, payload = json.loads(raw)
292
+ print(event, payload)
293
+
294
+ asyncio.run(main())
295
+ ```
296
+
297
+ ### 5. REST broadcast (server → clients)
298
+
299
+ ```bash
300
+ curl -sS -X POST http://localhost:8000/realtime/v1/broadcast/room-42 \
301
+ -H "authorization: Bearer $SERVICE_ROLE_TOKEN" \
302
+ -H "content-type: application/json" \
303
+ -d '{"event": "announcement", "payload": {"text": "Hello from the server!"}}'
304
+ ```
305
+
306
+ ### Realtime settings (`.env`)
307
+
308
+ | Variable | Default | Purpose |
309
+ |---|---|---|
310
+ | `REALTIME_ENABLED` | `true` | Toggle the realtime module |
311
+ | `REALTIME_NOTIFY_CHANNEL` | `realtime:changes` | Postgres `LISTEN` channel |
312
+ | `REALTIME_MAX_CONNECTIONS` | `1000` | Max concurrent WS clients |
313
+ | `REALTIME_MAX_SUBS_PER_CONN` | `100` | Max channel joins per connection |
314
+ | `REALTIME_HEARTBEAT_TIMEOUT_SECONDS` | `30` | Idle-close timeout (client sends every 25s) |
315
+ | `REALTIME_BROKER_QUEUE_SIZE` | `1000` | Per-subscriber outbound queue depth |
316
+
317
+ ## Jobs & cron quickstart
318
+
319
+ ### 1. Define a job
320
+
321
+ ```python
322
+ # in your application code
323
+ from supython.jobs.decorators import job
324
+
325
+ @job("send_welcome_email", version=1, max_attempts=5)
326
+ async def send_welcome_email(ctx, payload):
327
+ await ctx.send_email(
328
+ to=payload["email"],
329
+ subject="Welcome!",
330
+ text="Thanks for signing up.",
331
+ )
332
+ ```
333
+
334
+ ### 2. Enqueue from a function or hook
335
+
336
+ ```python
337
+ from supython import hooks
338
+ from supython.jobs.service import enqueue
339
+
340
+ @hooks.on("signup")
341
+ async def on_signup(user, ctx):
342
+ await enqueue(
343
+ ctx.db,
344
+ name="send_welcome_email",
345
+ payload={"email": user.email},
346
+ idempotency_key=f"welcome:{user.id}",
347
+ )
348
+ ```
349
+
350
+ ### 3. Schedule a cron
351
+
352
+ ```python
353
+ from supython.jobs.decorators import cron
354
+
355
+ @cron("*/5 * * * *", name="cleanup", job_name="cleanup_job")
356
+ async def cleanup_job(ctx, payload):
357
+ ctx.logger.info("running periodic cleanup")
358
+ ```
359
+
360
+ ### 4. Run the worker
361
+
362
+ ```bash
363
+ supython worker run --queue default --concurrency 5
364
+ ```
365
+
366
+ Or enable in-process mode for development:
367
+
368
+ ```bash
369
+ # in .env
370
+ JOBS_DEV_INPROCESS=true
371
+ supython dev
372
+ ```
373
+
374
+ ### Jobs settings (`.env`)
375
+
376
+ | Variable | Default | Purpose |
377
+ |---|---|---|
378
+ | `JOBS_ENABLED` | `true` | Toggle the jobs module |
379
+ | `JOBS_BACKEND` | `pg` | Queue backend (only `pg` for now) |
380
+ | `JOBS_CRON_BACKEND` | `pg_cron` | `pg_cron`, `inproc`, or `off` |
381
+ | `JOBS_POLL_INTERVAL_S` | `1.0` | Seconds between queue polls |
382
+ | `JOBS_CONCURRENCY` | `5` | Max concurrent jobs per worker |
383
+ | `JOBS_DEFAULT_MAX_ATTEMPTS` | `3` | Default retry limit |
384
+ | `JOBS_BACKOFF_BASE_S` | `5.0` | Base backoff delay (seconds) |
385
+ | `JOBS_BACKOFF_MAX_S` | `300.0` | Max backoff delay (seconds) |
386
+ | `JOBS_VISIBILITY_TIMEOUT_S` | `300.0` | Zombie reclaim timeout (seconds) |
387
+ | `JOBS_DRAIN_TIMEOUT_S` | `30.0` | Graceful shutdown drain (seconds) |
388
+ | `JOBS_DEV_INPROCESS` | `false` | Spawn worker in-process during `supython dev` |
389
+
390
+ ## v0.2 auth endpoints
391
+
392
+ | Endpoint | Method | Purpose |
393
+ |---|---|---|
394
+ | `/auth/v1/signup` | POST | Create account, return token pair |
395
+ | `/auth/v1/token` | POST | Password login |
396
+ | `/auth/v1/refresh` | POST | Rotate refresh token (reuse detection built in) |
397
+ | `/auth/v1/logout` | POST | Revoke refresh token |
398
+ | `/auth/v1/user` | GET | Return the caller's user (JWT required) |
399
+ | `/auth/v1/recover` | POST | Request password-reset email |
400
+ | `/auth/v1/recover/verify` | POST | Verify reset token, set new password |
401
+ | `/auth/v1/magiclink` | POST | Request magic-link email |
402
+ | `/auth/v1/magiclink/verify` | GET | Verify magic-link token (`?token=…`) |
403
+ | `/auth/v1/otp` | POST | Request email OTP |
404
+ | `/auth/v1/otp/verify` | POST | Verify OTP code |
405
+ | `/auth/v1/authorize/{provider}` | GET | Start OAuth flow (redirect to provider) |
406
+ | `/auth/v1/callback/{provider}` | GET | Handle OAuth callback, redirect with tokens |
407
+
408
+ **Email backend** — set `EMAIL_BACKEND=console` (default, logs to stdout) or
409
+ `EMAIL_BACKEND=smtp` and configure `SMTP_HOST / SMTP_PORT / SMTP_USERNAME /
410
+ SMTP_PASSWORD`.
411
+
412
+ **OAuth** — add `GOOGLE_CLIENT_ID`, `GOOGLE_CLIENT_SECRET` (and/or GitHub
413
+ equivalents) to `.env`. Providers without credentials are silently disabled.
414
+
415
+ ### Auth hardening settings (`.env`)
416
+
417
+ | Variable | Default | Purpose |
418
+ |---|---|---|
419
+ | `DB_STATEMENT_TIMEOUT_MS` | `30000` | Per-connection query timeout for the asyncpg pool (`0` disables) |
420
+ | `DB_POOL_MIN_SIZE` | `1` | Minimum asyncpg pool size |
421
+ | `DB_POOL_MAX_SIZE` | `10` | Maximum asyncpg pool size |
422
+ | `AUTH_RATE_LIMIT_ENABLED` | `true` | Toggle auth endpoint rate limiting |
423
+ | `AUTH_RATE_LIMIT_WINDOW_SECONDS` | `60` | Fixed-window size for auth endpoint counters |
424
+ | `AUTH_RATE_LIMIT_TOKEN_PER_WINDOW` | `10` | `/auth/v1/token` attempts per IP/window |
425
+ | `AUTH_RATE_LIMIT_SIGNUP_PER_WINDOW` | `5` | `/auth/v1/signup` attempts per IP/window |
426
+ | `AUTH_RATE_LIMIT_RECOVER_PER_WINDOW` | `3` | `/auth/v1/recover` attempts per IP/window |
427
+ | `AUTH_RATE_LIMIT_OTP_PER_WINDOW` | `5` | `/auth/v1/otp` attempts per IP/window |
428
+ | `AUTH_RATE_LIMIT_MAGICLINK_PER_WINDOW` | `5` | `/auth/v1/magiclink` attempts per IP/window |
429
+
430
+ **`AUTHENTICATOR_PASSWORD`** — the password used for the `authenticator` Postgres
431
+ role that PostgREST connects as. Defaults to `authenticator` (matches the
432
+ migration), but you should change it in production. `supython up` automatically
433
+ runs `ALTER ROLE authenticator WITH PASSWORD …` after migrations so you never
434
+ need to edit SQL.
435
+
436
+ ## What's in the box
437
+
438
+ ```
439
+ supython/
440
+ ├── docker-compose.yml # Postgres + PostgREST (dev stack)
441
+ ├── docker-compose.prod.yml # hardened single-host production stack
442
+ ├── docker-compose.test.yml # dedicated test Postgres on port 54323
443
+ ├── Caddyfile # reverse-proxy TLS for prod
444
+ ├── migrations/
445
+ │ ├── 0001_extensions_and_roles.sql # anon/authenticated/service_role/authenticator
446
+ │ ├── 0002_auth_schema.sql # auth.users, auth.refresh_tokens, auth.uid()
447
+ │ ├── 0003_demo_todos.sql # the demo table + RLS policies
448
+ │ ├── 0004_auth_v0_2.sql # identities, one_time_tokens, audit_log
449
+ │ ├── 0005_storage_schema.sql # storage.buckets + storage.objects
450
+ │ ├── 0006_realtime_schema.sql # realtime schema, trigger, enable() helper
451
+ │ ├── 0007_jobs_schema.sql # jobs queue, cron_schedules, enqueue/claim_next
452
+ │ ├── 0008_jobs_last_error.sql # last_error column on jobs.jobs
453
+ │ ├── 0009_auth_rate_limits.sql # auth fixed-window rate-limit counters
454
+ │ ├── 0010_worker_heartbeat.sql # worker heartbeat for /readyz
455
+ │ ├── 0011_admin_schema.sql # admin.admin_users, sessions, audit
456
+ │ ├── 0012_auth_banned_until.sql # banned_until on auth.users
457
+ │ ├── 0013_email_templates.sql # auth.email_templates
458
+ │ ├── 0014_realtime_payload_warning.sql # >8KB payload warning counter
459
+ │ └── 0015_backups_schema.sql # backups metadata
460
+ ├── examples/
461
+ │ ├── todos.http # HTTP smoke tests (auth + PostgREST)
462
+ │ ├── storage.http # storage upload / signed URL examples
463
+ │ ├── functions.http # edge-function call examples
464
+ │ └── chat.html # two-browser realtime demo (vanilla JS, zero deps)
465
+ ├── docs/
466
+ │ ├── PROJECT.md # architecture + roadmap (single source of truth)
467
+ │ ├── Installation.md # full install guide (dev, prod, managed Postgres)
468
+ │ └── admin-ui/
469
+ │ ├── admin-surface-plan.md # admin implementation plan + phase status
470
+ │ └── admin-surface.md # admin architecture + contracts
471
+ ├── tests/
472
+ │ ├── conftest.py # cross-tree fixtures (keys, capturing mailer)
473
+ │ ├── _keys.py # JWT-forging helpers
474
+ │ ├── fixtures/ # test function modules, etc.
475
+ │ ├── unit/ # pure-Python tests (~6s, no Docker)
476
+ │ │ ├── test_admin_session.py
477
+ │ │ ├── test_admin_service_db.py
478
+ │ │ └── ...
479
+ │ └── integration/ # full ASGI + Postgres on port 54323
480
+ │ ├── conftest.py # pool, app, client, autouse DB cleaners
481
+ │ ├── test_auth_signup_login.py
482
+ │ ├── test_admin_auth.py
483
+ │ ├── test_admin_auth_users.py
484
+ │ ├── test_admin_db_rows.py
485
+ │ ├── test_admin_jobs.py
486
+ │ ├── test_admin_ops_backups.py
487
+ │ ├── test_admin_storage.py
488
+ │ ├── test_postgrest_rls.py
489
+ │ ├── test_realtime_ws.py
490
+ │ └── ...
491
+ ├── admin-ui/ # Vue 3 + Vite SPA (built → src/supython/admin/static/)
492
+ │ └── src/
493
+ │ ├── api/ # single fetch seam
494
+ │ ├── components/ # shell, data, editors, feedback
495
+ │ ├── composables/ # useResource, useTable, useConfirm, useImpersonate, …
496
+ │ ├── stores/ # auth, ui
497
+ │ ├── views/ # Dashboard, db/, auth/, storage/, functions/, …
498
+ │ └── router/
499
+ └── src/supython/
500
+ ├── __init__.py # single version string
501
+ ├── settings.py # pydantic-settings, .env-driven
502
+ ├── db.py # asyncpg pool + lifespan + as_role() / as_service_role()
503
+ ├── mailer.py # ConsoleBackend / SmtpBackend
504
+ ├── tokens.py # RS256/ES256 JWT + JWKS
505
+ ├── passwords.py # argon2id
506
+ ├── migrate.py # ~50-line SQL migration runner
507
+ ├── app.py # FastAPI factory
508
+ ├── cli.py # typer: up, dev, keygen, admin, worker, test, …
509
+ ├── health.py # /livez, /readyz, /health endpoints
510
+ ├── logging_config.py # structured JSON log setup
511
+ ├── security_headers.py # HSTS, CSP, etc.
512
+ ├── body_size.py # request body size guards
513
+ ├── jwks.py # JWKS generation + rotation helpers
514
+ ├── keyset.py # asymmetric key rotation manifest
515
+ ├── secretset.py # symmetric secret rotation manifest
516
+ ├── hooks.py # generic hook system: on() / fire()
517
+ ├── mail.py # email send with job-retry fallback
518
+ ├── auth/
519
+ │ ├── schemas.py
520
+ │ ├── service.py # full auth layer: signup / OAuth / OTP / recover …
521
+ │ ├── router.py # all /auth/v1/* routes
522
+ │ └── providers/ # Google, GitHub, OAuth2 helpers
523
+ ├── storage/
524
+ │ ├── backends.py # LocalBackend, S3Backend
525
+ │ ├── service.py
526
+ │ └── router.py # /storage/v1/*
527
+ ├── functions/
528
+ │ ├── loader.py # filesystem discovery + hot reload
529
+ │ └── router.py # /functions/v1/*
530
+ ├── realtime/
531
+ │ ├── protocol.py # Phoenix Channels encode/decode
532
+ │ ├── broker.py # fan-out engine with RLS filtering
533
+ │ ├── websocket.py # WS route with JWT auth
534
+ │ └── router.py # /realtime/v1/*
535
+ ├── jobs/
536
+ │ ├── registry.py # @job / @cron decorator store
537
+ │ ├── service.py # enqueue, claim_next, mark_*
538
+ │ ├── worker.py # long-running poll/dispatch/drain loop
539
+ │ ├── cron.py # pg_cron sync + InProcScheduler
540
+ │ └── router.py # /jobs/v1/*
541
+ ├── admin/
542
+ │ ├── session.py # admin cookie session (SHA-256 hashed, 8h TTL)
543
+ │ ├── deps.py # require_admin dependency
544
+ │ ├── spa.py # static SPA mount + index.html fallback
545
+ │ ├── schemas.py # shared Pydantic models
546
+ │ ├── audit.py # admin audit log writer
547
+ │ ├── static/ # pre-built Vue 3 SPA bundle (committed)
548
+ │ └── api/ # /admin/api/v1/* route handlers
549
+ ├── backups/ # pg_dump wrapper + restore
550
+ ├── gen/ # supython gen types --lang py|ts
551
+ ├── scaffold/ # supython init templates
552
+ └── client/ # Python SDK (optional [client] extra)
553
+ ```
554
+
555
+ ## CLI
556
+
557
+ ```
558
+ supython up # docker compose up + migrate + start postgrest
559
+ supython up --prod # boot the production stack
560
+ supython up --prod --worker # boot prod stack + worker
561
+ supython dev # uvicorn the FastAPI service with reload
562
+ supython down # stop the stack (keeps data)
563
+ supython down --prod # stop the prod stack
564
+ supython reset # stop the stack and DELETE the volume (destructive)
565
+ supython reset --prod # stop prod stack and DELETE volumes
566
+ supython migrate # apply pending SQL migrations
567
+ supython info # print resolved settings
568
+ supython doctor # diagnose roles, extensions, JWKS, grants, migration drift
569
+ supython init <name> # scaffold a new supython project
570
+ supython gen types --lang py --out types.py # emit typed dataclasses + TypedDicts
571
+ supython gen types --lang ts --out types.ts # emit TypeScript Database interface
572
+
573
+ # Auth & key management
574
+ supython keygen init [--alg RS256|ES256] # generate signing keypair + JWKS
575
+ supython keygen rotate [--no-reload] # add new verifying kid (zero-downtime)
576
+ supython keygen activate <kid> [--no-reload] # promote kid to active signer
577
+ supython keygen prune [--force] [--no-reload] # drop retired kids past grace window
578
+ supython secret status # show symmetric secret manifest
579
+ supython secret rotate <storage|oauth> # add new verifying symmetric secret
580
+ supython secret activate <storage|oauth> <kid>
581
+ supython secret prune <storage|oauth> [--force]
582
+ supython password rotate <role> # rotate a Postgres role password
583
+
584
+ # Admin
585
+ supython admin create-user # bootstrap the first admin (interactive)
586
+
587
+ # Realtime
588
+ supython realtime enable public.messages # opt a table into realtime
589
+ supython realtime enable public.posts --owner-column author_id
590
+
591
+ # Jobs & worker
592
+ supython worker run --queue default # start the job worker (blocks)
593
+ supython jobs list # list queued/running/finished jobs
594
+ supython jobs show <uuid> # show job details
595
+ supython jobs cancel <uuid> # cancel a job
596
+ supython jobs retry <uuid> # re-queue a failed job
597
+ supython jobs enqueue send_welcome_email --payload '{"email":"a@b.com"}'
598
+ supython cron list # list registered crons
599
+ supython cron sync # sync crons with pg_cron
600
+
601
+ # Test suite
602
+ supython test up # start test Postgres on port 54323
603
+ supython test run [PYTEST_ARGS...] # bootstrap + run full suite
604
+ supython test run tests/unit # fast loop, no Docker
605
+ supython test run tests/integration -k auth_signup
606
+ supython test down # stop test DB (keeps volume)
607
+ supython test reset # stop test DB + delete volume
608
+ ```
609
+
610
+ ## How the auth ↔ PostgREST contract works
611
+
612
+ 1. supython signs an RS256 JWT with its private key and the claims:
613
+ ```json
614
+ {
615
+ "sub": "<user uuid>",
616
+ "email": "<user email>",
617
+ "role": "authenticated",
618
+ "aud": "authenticated",
619
+ "iat": ...,
620
+ "exp": ...
621
+ }
622
+ ```
623
+ 2. The client sends the token to **either** supython (for `/auth/v1/user`)
624
+ **or** PostgREST (for everything else) as `Authorization: Bearer <jwt>`.
625
+ 3. PostgREST verifies the signature via the public JWKS, switches
626
+ the DB role to `authenticated`, and sets `request.jwt.claims` so
627
+ `auth.uid()` returns the user's id inside RLS policies.
628
+ 4. RLS policies on `public.todos` use `auth.uid()` to scope every query.
629
+
630
+ This is exactly the model Supabase uses, in ~400 lines of Python.
631
+
632
+ ## Docker image
633
+
634
+ The supython service ships as a multi-arch image (`linux/amd64`,
635
+ `linux/arm64`) on `python:3.11-slim`. The admin UI bundle is committed
636
+ into the wheel, so the image build is Node-free.
637
+
638
+ ```bash
639
+ # build locally (host arch)
640
+ docker build -t supython:dev .
641
+
642
+ # build the multi-arch manifest
643
+ docker buildx build --platform linux/amd64,linux/arm64 -t supython:dev .
644
+
645
+ # run against an existing Postgres
646
+ docker run --rm -p 8000:8000 \
647
+ -e DATABASE_URL=postgres://supython:supython@host.docker.internal:54322/supython \
648
+ -e JWT_PRIVATE_KEY_PATH=/run/secrets/jwt.pem \
649
+ -v ./.supython:/run/secrets:ro \
650
+ supython:dev
651
+ ```
652
+
653
+ The container runs as a non-root `supython` user (uid 1000), uses `tini`
654
+ as PID 1, exposes port 8000 (override with `SUPYTHON_PORT`), and ships a
655
+ `/livez` HEALTHCHECK. CI builds both arches on every PR via
656
+ `.github/workflows/docker.yml` and publishes the multi-arch manifest to
657
+ GHCR (`ghcr.io/<owner>/supython:<tag>`) on `v*` tags.
658
+
659
+ ## Running the test suite
660
+
661
+ The test suite is split in two so unit feedback is fast and integration
662
+ runs are deterministic:
663
+
664
+ ```
665
+ tests/unit/ # pure Python, no Docker (~6s) — run first, always
666
+ tests/integration/ # full ASGI + Postgres on port 54323
667
+ ```
668
+
669
+ **One-time setup:**
670
+
671
+ ```bash
672
+ supython test up # start dedicated test Postgres on port 54323, apply migrations
673
+ ```
674
+
675
+ **Day-to-day:**
676
+
677
+ ```bash
678
+ supython test run # bootstrap + run full suite
679
+ supython test run tests/unit # fast loop, no Docker required
680
+ supython test run tests/integration -k auth_signup
681
+ pytest tests/unit # unit-only without going through CLI
682
+ ```
683
+
684
+ `supython test run` forwards every extra arg to `pytest`, sets
685
+ `DATABASE_URL` to the test container, and exits with pytest's status code.
686
+ Integration tests skip cleanly (not fail) when Postgres is unreachable, so
687
+ unit tests always run in isolation.
688
+
689
+ **CI:** runners with Docker run `supython test up && supython test run`;
690
+ runners without Docker run `pytest tests/unit` for a meaningful subset.
691
+
692
+ ## Roadmap
693
+
694
+ - ~~v0.1~~ ✅ Email/password auth, PostgREST contract, RLS demo
695
+ - ~~v0.2~~ ✅ OAuth, password reset, magic link, OTP, reuse detection, email backend, test suite
696
+ - ~~v0.3~~ ✅ Storage (S3/local) with RLS-on-metadata, edge-style functions from a `functions/` directory; `db.as_role(role, claims)` helper; `supython init` scaffold; `supython gen types --lang py`
697
+ - ~~v0.4~~ ✅ Realtime over `LISTEN/NOTIFY` with RLS-aware fan-out; Phoenix Channels wire format; broadcast + presence; `examples/chat.html` demo
698
+ - ~~v0.5~~ ✅ Job queue worker + `pg_cron` scheduling + hooks + CLI management commands
699
+ - ~~v0.6~~ ✅ Grooming + security foundation: unified versioning, CORS closed by default, RS256 JWT, rate limiting, `supython doctor`, pool sizing, statement timeout
700
+ - ~~v0.7~~ ✅ Production observable: structured JSON logs, `/livez`/`/readyz`/`/health`, security headers, input size guards, audit log completeness, OAuth PKCE, secret rotation runbooks
701
+ - ~~v0.8~~ ✅ (partial) Multi-arch Docker images, admin control plane (Vue 3 SPA — database, auth, storage, functions, realtime, jobs, backups, log tail), CI buildx workflow; benchmarks + security audit pass + dependency budget CI remaining
702
+ - v0.9 *(deferred to post-v1.1)* — Realtime v2 over logical replication
703
+ - v1.0 Release — final sweep, tag, publish wheel, production deployment with no patches
704
+
705
+ ### Post v1.0
706
+
707
+ - **v1.1+** — Admin control plane polish (phase v1.1.0–v1.1.4 backend + frontend shipped in v0.8; tests + remaining DoD items deferred)
708
+ - **Realtime v2** — logical replication (demand-driven; swap when trigger overhead or >8KB payload data warrants it)
709
+ - **TypeScript SDK** — `@supython/sdk` wrapping `@supabase/postgrest-js` + `@supabase/realtime-js`
710
+ - **Prometheus `/metrics`** + **OpenTelemetry** — optional extras
711
+
712
+ ## License
713
+
714
+ MIT