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,254 @@
1
+ -- v0.5 jobs: durable job queue with idempotency, bounded zombie reclaim, and
2
+ -- cron scheduling metadata.
3
+ --
4
+ -- Creates:
5
+ -- jobs schema (owned by service_role)
6
+ -- jobs.jobs — main queue table
7
+ -- jobs.cron_schedules — cron schedule metadata
8
+ -- jobs.enqueue() — idempotent insert, returns (job, is_new)
9
+ -- jobs.claim_next() — SKIP LOCKED poll + bounded zombie reclaim
10
+ -- Partial unique index on idempotency_key
11
+ -- 4-policy RLS on jobs.jobs
12
+ -- pg_cron grants for service_role (when the extension is installed)
13
+ --
14
+ -- Per B5: execute grant on jobs.enqueue is service_role only.
15
+ -- Per B9: claim_next reclaims zombies older than visibility_timeout_ms.
16
+ -- Per B6: enqueue returns TABLE(job jobs.jobs, is_new boolean).
17
+
18
+ create schema if not exists jobs;
19
+
20
+ grant usage on schema jobs to anon, authenticated, service_role;
21
+
22
+ -- ─── pg_cron grants ──────────────────────────────────────────────────────────
23
+ --
24
+ -- ``sync_pg_cron`` runs as ``service_role`` and needs to call
25
+ -- ``cron.schedule`` / ``cron.unschedule``. Without these grants the pg_cron
26
+ -- path raises ``InsufficientPrivilegeError``. The block is conditional so
27
+ -- this migration still applies cleanly on managed Postgres without pg_cron.
28
+
29
+ do $$
30
+ begin
31
+ if exists (select 1 from pg_extension where extname = 'pg_cron') then
32
+ execute 'grant usage on schema cron to service_role';
33
+ execute 'grant select on all tables in schema cron to service_role';
34
+ execute 'grant execute on function cron.schedule(text, text, text) to service_role';
35
+ execute 'grant execute on function cron.unschedule(text) to service_role';
36
+ end if;
37
+ end $$;
38
+
39
+ -- ─── jobs.jobs ────────────────────────────────────────────────────────────────
40
+
41
+ create table if not exists jobs.jobs (
42
+ id uuid primary key default gen_random_uuid(),
43
+ name text not null,
44
+ version int not null default 1,
45
+ payload jsonb not null default '{}',
46
+ queue text not null default 'default',
47
+ idempotency_key text,
48
+ user_id uuid references auth.users (id) on delete cascade,
49
+ status text not null default 'queued'
50
+ check (status in ('queued', 'running', 'succeeded', 'failed', 'cancelled')),
51
+ attempts int not null default 0,
52
+ max_attempts int not null default 3,
53
+ backoff text not null default 'exponential'
54
+ check (backoff in ('exponential', 'linear', 'constant')),
55
+ backoff_base_s float not null default 5.0,
56
+ backoff_max_s float not null default 300.0,
57
+ run_at timestamptz not null default now(),
58
+ locked_at timestamptz,
59
+ locked_by text,
60
+ role text not null default 'service_role',
61
+ claims_from text,
62
+ finished_at timestamptz,
63
+ created_at timestamptz not null default now()
64
+ );
65
+
66
+ alter table jobs.jobs owner to service_role;
67
+
68
+ -- idempotency: at most one queued/running job per key
69
+ create unique index if not exists jobs_jobs_idempotency_key_idx
70
+ on jobs.jobs (idempotency_key)
71
+ where idempotency_key is not null
72
+ and status in ('queued', 'running');
73
+
74
+ -- index for user-scoped queries
75
+ create index if not exists jobs_jobs_user_id_idx
76
+ on jobs.jobs (user_id)
77
+ where user_id is not null;
78
+
79
+ -- ─── RLS ─────────────────────────────────────────────────────────────────────
80
+
81
+ alter table jobs.jobs enable row level security;
82
+
83
+ drop policy if exists "jobs: owner can read" on jobs.jobs;
84
+ drop policy if exists "jobs: owner can insert" on jobs.jobs;
85
+ drop policy if exists "jobs: owner can update" on jobs.jobs;
86
+ drop policy if exists "jobs: owner can delete" on jobs.jobs;
87
+
88
+ create policy "jobs: owner can read"
89
+ on jobs.jobs for select to authenticated
90
+ using (user_id = auth.uid());
91
+
92
+ create policy "jobs: owner can insert"
93
+ on jobs.jobs for insert to authenticated
94
+ with check (user_id = auth.uid());
95
+
96
+ create policy "jobs: owner can update"
97
+ on jobs.jobs for update to authenticated
98
+ using (user_id = auth.uid())
99
+ with check (user_id = auth.uid());
100
+
101
+ create policy "jobs: owner can delete"
102
+ on jobs.jobs for delete to authenticated
103
+ using (user_id = auth.uid());
104
+
105
+ grant select, insert, update, delete on jobs.jobs to authenticated;
106
+
107
+ -- ─── jobs.cron_schedules ─────────────────────────────────────────────────────
108
+
109
+ create table if not exists jobs.cron_schedules (
110
+ id uuid primary key default gen_random_uuid(),
111
+ name text not null unique,
112
+ cron_expr text not null,
113
+ job_name text not null,
114
+ job_version int not null default 1,
115
+ payload jsonb not null default '{}',
116
+ queue text not null default 'default',
117
+ enabled boolean not null default true,
118
+ last_fire_at timestamptz,
119
+ created_at timestamptz not null default now()
120
+ );
121
+
122
+ alter table jobs.cron_schedules owner to service_role;
123
+
124
+ -- ─── jobs.enqueue ────────────────────────────────────────────────────────────
125
+ --
126
+ -- Idempotent enqueue. If an idempotency_key is provided and a queued/running
127
+ -- row already exists with that key, the existing row is returned with
128
+ -- is_new = false. Otherwise a new row is inserted and is_new = true.
129
+ --
130
+ -- The "fresh insert" path is split into a CTE so we never need to reference
131
+ -- a system column (``xmax = 0``) after moving the row into a local variable —
132
+ -- the idempotency short-circuit above returns before the INSERT, so anything
133
+ -- that makes it past the guard is definitively new.
134
+
135
+ create or replace function jobs.enqueue(
136
+ p_name text,
137
+ p_payload jsonb default '{}',
138
+ p_queue text default 'default',
139
+ p_idempotency_key text default null,
140
+ p_user_id uuid default null,
141
+ p_max_attempts int default 3,
142
+ p_backoff text default 'exponential',
143
+ p_backoff_base_s float default 5.0,
144
+ p_backoff_max_s float default 300.0,
145
+ p_run_at timestamptz default now(),
146
+ p_version int default 1,
147
+ p_role text default 'service_role',
148
+ p_claims_from text default null
149
+ )
150
+ returns table (job jobs.jobs, is_new boolean)
151
+ language plpgsql
152
+ security definer
153
+ as $$
154
+ declare
155
+ existing_id uuid;
156
+ begin
157
+ if p_idempotency_key is not null then
158
+ select j.id into existing_id
159
+ from jobs.jobs j
160
+ where j.idempotency_key = p_idempotency_key
161
+ and j.status in ('queued', 'running')
162
+ limit 1;
163
+
164
+ if existing_id is not null then
165
+ return query
166
+ select j, false
167
+ from jobs.jobs j
168
+ where j.id = existing_id;
169
+ return;
170
+ end if;
171
+ end if;
172
+
173
+ return query
174
+ with ins as (
175
+ insert into jobs.jobs (
176
+ name, version, payload, queue, idempotency_key, user_id,
177
+ max_attempts, backoff, backoff_base_s, backoff_max_s,
178
+ run_at, role, claims_from
179
+ )
180
+ values (
181
+ p_name, p_version, coalesce(p_payload, '{}'::jsonb), p_queue,
182
+ p_idempotency_key, p_user_id,
183
+ p_max_attempts, p_backoff, p_backoff_base_s, p_backoff_max_s,
184
+ coalesce(p_run_at, now()), p_role, p_claims_from
185
+ )
186
+ returning *
187
+ )
188
+ select ins::jobs.jobs, true from ins;
189
+ end;
190
+ $$;
191
+
192
+ grant execute on function jobs.enqueue(
193
+ text, jsonb, text, text, uuid, int, text, float, float, timestamptz, int, text, text
194
+ ) to service_role;
195
+
196
+ -- ─── jobs.claim_next ─────────────────────────────────────────────────────────
197
+
198
+ -- Poll the queue for the next available job. Uses SKIP LOCKED so multiple
199
+ -- workers never claim the same row. Reclaims "zombie" rows whose
200
+ -- locked_at is older than p_visibility_timeout_ms ago (bounded by
201
+ -- p_zombie_batch to avoid thundering herd).
202
+ create or replace function jobs.claim_next(
203
+ p_queue text default 'default',
204
+ p_worker_id text default null,
205
+ p_visibility_timeout_ms int default 300000,
206
+ p_zombie_batch int default 10
207
+ )
208
+ returns setof jobs.jobs
209
+ language plpgsql
210
+ security definer
211
+ as $$
212
+ declare
213
+ reclaimed int;
214
+ begin
215
+ -- bounded zombie reclaim
216
+ update jobs.jobs
217
+ set status = 'queued',
218
+ locked_at = null,
219
+ locked_by = null
220
+ where id in (
221
+ select id
222
+ from jobs.jobs
223
+ where status = 'running'
224
+ and queue = p_queue
225
+ and locked_at < now() - (p_visibility_timeout_ms || ' milliseconds')::interval
226
+ order by locked_at
227
+ limit p_zombie_batch
228
+ for update skip locked
229
+ );
230
+
231
+ get diagnostics reclaimed = row_count;
232
+
233
+ -- claim the next queued job
234
+ return query
235
+ update jobs.jobs j
236
+ set status = 'running',
237
+ attempts = j.attempts + 1,
238
+ locked_at = now(),
239
+ locked_by = p_worker_id
240
+ where j.id in (
241
+ select c.id
242
+ from jobs.jobs c
243
+ where c.status = 'queued'
244
+ and c.queue = p_queue
245
+ and c.run_at <= now()
246
+ order by c.run_at, c.id
247
+ limit 1
248
+ for update skip locked
249
+ )
250
+ returning j.*;
251
+ end;
252
+ $$;
253
+
254
+ grant execute on function jobs.claim_next(text, text, int, int) to service_role;
@@ -0,0 +1,56 @@
1
+ -- v0.6 grooming: add last_error column to jobs.jobs so the failure reason
2
+ -- lands on the row instead of only in logs.
3
+
4
+ alter table jobs.jobs add column if not exists last_error text;
5
+
6
+ -- clear last_error on reclaim so zombie rows don't carry stale errors
7
+ create or replace function jobs.claim_next(
8
+ p_queue text default 'default',
9
+ p_worker_id text default null,
10
+ p_visibility_timeout_ms int default 300000,
11
+ p_zombie_batch int default 10
12
+ )
13
+ returns setof jobs.jobs
14
+ language plpgsql
15
+ security definer
16
+ as $$
17
+ declare
18
+ reclaimed int;
19
+ begin
20
+ update jobs.jobs
21
+ set status = 'queued',
22
+ locked_at = null,
23
+ locked_by = null,
24
+ last_error = null
25
+ where id in (
26
+ select id
27
+ from jobs.jobs
28
+ where status = 'running'
29
+ and queue = p_queue
30
+ and locked_at < now() - (p_visibility_timeout_ms || ' milliseconds')::interval
31
+ order by locked_at
32
+ limit p_zombie_batch
33
+ for update skip locked
34
+ );
35
+
36
+ get diagnostics reclaimed = row_count;
37
+
38
+ return query
39
+ update jobs.jobs j
40
+ set status = 'running',
41
+ attempts = j.attempts + 1,
42
+ locked_at = now(),
43
+ locked_by = p_worker_id
44
+ where j.id in (
45
+ select c.id
46
+ from jobs.jobs c
47
+ where c.status = 'queued'
48
+ and c.queue = p_queue
49
+ and c.run_at <= now()
50
+ order by c.run_at, c.id
51
+ limit 1
52
+ for update skip locked
53
+ )
54
+ returning j.*;
55
+ end;
56
+ $$;
@@ -0,0 +1,33 @@
1
+ -- v0.6: brute-force protection for auth endpoints with a Postgres-backed
2
+ -- fixed-window counter (no Redis dependency).
3
+ --
4
+ -- Uses an UNLOGGED table because counters are ephemeral; a crash truncation
5
+ -- only resets buckets, which is preferable to keeping stale windows.
6
+
7
+ create unlogged table if not exists auth.rate_limit_buckets (
8
+ bucket text not null,
9
+ window_start timestamptz not null,
10
+ count int not null default 0,
11
+ primary key (bucket, window_start)
12
+ );
13
+
14
+ alter table auth.rate_limit_buckets owner to service_role;
15
+
16
+ grant select, insert, update, delete
17
+ on auth.rate_limit_buckets to service_role;
18
+
19
+ -- prune stale buckets every 5 minutes when pg_cron is available.
20
+ -- conditional so migration applies cleanly on managed Postgres without pg_cron.
21
+ do $$
22
+ begin
23
+ if exists (select 1 from pg_extension where extname = 'pg_cron') then
24
+ perform cron.unschedule('auth_rate_limit_prune')
25
+ where exists (select 1 from cron.job where jobname = 'auth_rate_limit_prune');
26
+ perform cron.schedule(
27
+ 'auth_rate_limit_prune',
28
+ '*/5 * * * *',
29
+ $cron$delete from auth.rate_limit_buckets
30
+ where window_start < now() - interval '1 hour'$cron$
31
+ );
32
+ end if;
33
+ end $$;
@@ -0,0 +1,14 @@
1
+ -- Worker heartbeat table for /readyz health probing.
2
+ -- Each running worker upserts a row on every poll tick;
3
+ -- /readyz checks the age of the most recent heartbeat.
4
+ create table if not exists jobs.worker_heartbeats (
5
+ worker_id text primary key,
6
+ last_heartbeat timestamptz not null default now(),
7
+ inflight int not null default 0
8
+ );
9
+
10
+ alter table jobs.worker_heartbeats enable row level security;
11
+ alter table jobs.worker_heartbeats owner to service_role;
12
+
13
+ -- service_role owns the table; workers run as service_role.
14
+ -- No grant to authenticated -- this is internal infrastructure.
@@ -0,0 +1,45 @@
1
+ create schema if not exists admin authorization service_role;
2
+
3
+ create table if not exists admin.admin_users (
4
+ id uuid primary key default gen_random_uuid(),
5
+ email citext unique not null,
6
+ password_hash text not null,
7
+ is_root boolean not null default false,
8
+ created_at timestamptz not null default now(),
9
+ last_login_at timestamptz
10
+ );
11
+
12
+ create table if not exists admin.admin_sessions (
13
+ id uuid primary key default gen_random_uuid(),
14
+ admin_id uuid not null references admin.admin_users(id) on delete cascade,
15
+ token_hash bytea not null unique,
16
+ issued_at timestamptz not null default now(),
17
+ expires_at timestamptz not null,
18
+ revoked_at timestamptz,
19
+ ip inet,
20
+ user_agent text
21
+ );
22
+ create index if not exists admin_sessions_admin_id_idx on admin.admin_sessions (admin_id);
23
+
24
+ create table if not exists admin.admin_audit (
25
+ id bigserial primary key,
26
+ admin_id uuid references admin.admin_users(id) on delete set null,
27
+ action text not null,
28
+ target text,
29
+ payload jsonb not null default '{}'::jsonb,
30
+ ip inet,
31
+ user_agent text,
32
+ at timestamptz not null default now()
33
+ );
34
+ create index if not exists admin_audit_at_idx on admin.admin_audit (at desc);
35
+
36
+ revoke all on schema admin from public;
37
+ revoke all on all tables in schema admin from public;
38
+
39
+ -- Schema is owned by service_role, but tables created here by the migration
40
+ -- runner (e.g. `supython` in dev) are not. Grant explicitly so the login
41
+ -- handler — which runs as service_role via `db.as_service_role()` — can read
42
+ -- and write admin tables. Mirrors the pattern in 0002_auth_schema.sql.
43
+ grant usage on schema admin to service_role;
44
+ grant all on all tables in schema admin to service_role;
45
+ grant all on all sequences in schema admin to service_role;
@@ -0,0 +1,10 @@
1
+ -- Add a per-user ban window so the admin surface can ban / unban accounts
2
+ -- without deleting rows. `auth.users.banned_until` is null when the user is
3
+ -- not banned, otherwise the timestamp at which the ban lifts.
4
+
5
+ alter table auth.users
6
+ add column if not exists banned_until timestamptz;
7
+
8
+ create index if not exists users_banned_until_idx
9
+ on auth.users (banned_until)
10
+ where banned_until is not null;
@@ -0,0 +1,19 @@
1
+ -- Store editable email templates so operators can customise the auth emails
2
+ -- without touching source files. The auth module looks up templates by name;
3
+ -- if a row exists, its subject + text_body are used instead of the built-in
4
+ -- default.
5
+
6
+ create table if not exists admin.email_templates (
7
+ name text primary key,
8
+ subject text not null,
9
+ text_body text not null,
10
+ updated_at timestamptz not null default now()
11
+ );
12
+
13
+ -- Seed the three templates the auth module currently ships as hard-coded
14
+ -- strings, so the operator sees the current defaults on first open.
15
+ insert into admin.email_templates (name, subject, text_body) values
16
+ ('recover', 'Reset your password', 'Use this token to reset your password: {{ token }}'),
17
+ ('magic_link', 'Sign in to your account', 'Click the link to sign in: {{ url }}'),
18
+ ('otp', 'Your one-time password', 'Your OTP is: {{ token }}')
19
+ on conflict (name) do nothing;
@@ -0,0 +1,96 @@
1
+ -- v0.4 follow-up: do not let realtime.fire_notify() abort user writes
2
+ -- when the rendered NOTIFY payload exceeds Postgres's 8000-byte ceiling.
3
+ --
4
+ -- Background: pg_notify(channel, payload) raises `payload string too
5
+ -- long` if `payload` is larger than 8000 bytes (a hard server-side
6
+ -- limit). Before this migration the trigger called pg_notify
7
+ -- unconditionally, so a single oversize row aborted the user's write
8
+ -- transaction. That is a correctness bug: a realtime subscription must
9
+ -- never break user writes.
10
+ --
11
+ -- New behavior: pre-check the rendered payload size before NOTIFY.
12
+ -- * Under the threshold (default 7900 bytes — ~100-byte headroom):
13
+ -- identical to migration 0006.
14
+ -- * Over the threshold:
15
+ -- 1. RAISE WARNING with schema, table, op, and rendered byte size
16
+ -- so the operator sees the event in the Postgres log. The v0.8
17
+ -- benchmark counts these as the second motivator for the
18
+ -- deferred logical-replication source (§19, 2026-05-04).
19
+ -- 2. Skip pg_notify. The write succeeds; subscribers receive no
20
+ -- event for this row. (Skipping is the conservative choice:
21
+ -- emitting a metadata-only notify would put `record: null` on
22
+ -- the wire for INSERT/UPDATE, which today only happens for
23
+ -- DELETE — a wire-shape change the broker and SDKs would have
24
+ -- to learn about.)
25
+ --
26
+ -- Operator escape hatch: realtime v2 (logical replication, Post v1.1)
27
+ -- has no payload-size cap. Until that ships, oversize rows are not
28
+ -- delivered via realtime; clients can refetch via REST/PostgREST.
29
+ --
30
+ -- Idempotent: `create or replace function` — fresh installs apply 0006
31
+ -- and then this; existing installs apply only this and pick up the new
32
+ -- body in place.
33
+
34
+ create or replace function realtime.fire_notify()
35
+ returns trigger language plpgsql security definer as $$
36
+ declare
37
+ v_columns jsonb;
38
+ v_payload jsonb;
39
+ v_payload_text text;
40
+ v_payload_bytes int;
41
+ -- 8000-byte NOTIFY cap minus ~100 bytes of headroom for asyncpg /
42
+ -- channel-name overhead. Hard-coded rather than a setting because
43
+ -- the 8000 limit is a Postgres compile-time constant — operators
44
+ -- cannot raise it without recompiling.
45
+ v_payload_max int := 7900;
46
+ begin
47
+ select jsonb_agg(
48
+ jsonb_build_object(
49
+ 'name', a.attname,
50
+ 'type', format_type(a.atttypid, a.atttypmod)
51
+ )
52
+ order by a.attnum
53
+ )
54
+ into v_columns
55
+ from pg_attribute a
56
+ where a.attrelid = tg_relid
57
+ and a.attnum > 0
58
+ and not a.attisdropped;
59
+
60
+ v_payload := jsonb_build_object(
61
+ 'schema', tg_table_schema,
62
+ 'table', tg_table_name,
63
+ 'type', tg_op,
64
+ 'commit_timestamp', to_char(
65
+ now() at time zone 'utc',
66
+ 'YYYY-MM-DD"T"HH24:MI:SS"Z"'
67
+ ),
68
+ 'columns', coalesce(v_columns, '[]'::jsonb),
69
+ 'record', case
70
+ when tg_op in ('INSERT', 'UPDATE') then to_jsonb(new)
71
+ else null
72
+ end,
73
+ 'old_record', case
74
+ when tg_op in ('UPDATE', 'DELETE') then to_jsonb(old)
75
+ else null
76
+ end
77
+ );
78
+
79
+ v_payload_text := v_payload::text;
80
+ v_payload_bytes := octet_length(v_payload_text);
81
+
82
+ if v_payload_bytes > v_payload_max then
83
+ raise warning
84
+ 'realtime.fire_notify: dropping % event for %.%; payload is % bytes (>%-byte NOTIFY ceiling)',
85
+ tg_op,
86
+ tg_table_schema,
87
+ tg_table_name,
88
+ v_payload_bytes,
89
+ v_payload_max;
90
+ return null;
91
+ end if;
92
+
93
+ perform pg_notify('realtime:changes', v_payload_text);
94
+
95
+ return null;
96
+ end $$;
@@ -0,0 +1,14 @@
1
+ create table if not exists admin.backups (
2
+ id uuid primary key default gen_random_uuid(),
3
+ kind text not null check (kind in ('full', 'schema-only')),
4
+ status text not null default 'running'
5
+ check (status in ('running', 'completed', 'failed')),
6
+ size bigint,
7
+ file_path text,
8
+ error_message text,
9
+ started_at timestamptz not null default now(),
10
+ finished_at timestamptz,
11
+ created_at timestamptz not null default now()
12
+ );
13
+
14
+ grant all on admin.backups to service_role;
supython/passwords.py ADDED
@@ -0,0 +1,15 @@
1
+ from argon2 import PasswordHasher
2
+ from argon2.exceptions import VerificationError, VerifyMismatchError
3
+
4
+ _hasher = PasswordHasher()
5
+
6
+
7
+ def hash_password(password: str) -> str:
8
+ return _hasher.hash(password)
9
+
10
+
11
+ def verify_password(hashed: str, password: str) -> bool:
12
+ try:
13
+ return _hasher.verify(hashed, password)
14
+ except (VerifyMismatchError, VerificationError):
15
+ return False
@@ -0,0 +1,6 @@
1
+ """Realtime module — Phoenix-channels subset over WebSockets, in-process broker."""
2
+
3
+ from .broker import Broker, get_broker, reset_broker
4
+ from .router import router
5
+
6
+ __all__ = ["Broker", "get_broker", "reset_broker", "router"]