supython 0.5.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- supython/__init__.py +8 -0
- supython/admin/__init__.py +3 -0
- supython/admin/api/__init__.py +24 -0
- supython/admin/api/auth.py +118 -0
- supython/admin/api/auth_templates.py +67 -0
- supython/admin/api/auth_users.py +225 -0
- supython/admin/api/db.py +174 -0
- supython/admin/api/functions.py +92 -0
- supython/admin/api/jobs.py +192 -0
- supython/admin/api/ops.py +224 -0
- supython/admin/api/realtime.py +281 -0
- supython/admin/api/service_auth.py +49 -0
- supython/admin/api/service_auth_templates.py +83 -0
- supython/admin/api/service_auth_users.py +346 -0
- supython/admin/api/service_db.py +214 -0
- supython/admin/api/service_functions.py +287 -0
- supython/admin/api/service_jobs.py +282 -0
- supython/admin/api/service_ops.py +213 -0
- supython/admin/api/service_realtime.py +30 -0
- supython/admin/api/service_storage.py +220 -0
- supython/admin/api/storage.py +117 -0
- supython/admin/api/system.py +37 -0
- supython/admin/audit.py +29 -0
- supython/admin/deps.py +22 -0
- supython/admin/errors.py +16 -0
- supython/admin/schemas.py +310 -0
- supython/admin/session.py +52 -0
- supython/admin/spa.py +38 -0
- supython/admin/static/assets/Alert-dluGVkos.js +49 -0
- supython/admin/static/assets/Audit-Njung3HI.js +2 -0
- supython/admin/static/assets/Backups-DzPlFgrm.js +2 -0
- supython/admin/static/assets/Buckets-ByacGkU1.js +2 -0
- supython/admin/static/assets/Channels-BoIuTtam.js +353 -0
- supython/admin/static/assets/ChevronRight-CtQH1EQ1.js +2 -0
- supython/admin/static/assets/CodeViewer-Bqy7-wvH.js +2 -0
- supython/admin/static/assets/Crons-B67vc39F.js +2 -0
- supython/admin/static/assets/DashboardView-CUTFVL6k.js +2 -0
- supython/admin/static/assets/DataTable-COAAWEft.js +747 -0
- supython/admin/static/assets/DescriptionsItem-P8JUDaBs.js +75 -0
- supython/admin/static/assets/DrawerContent-TpYTFgF1.js +139 -0
- supython/admin/static/assets/Empty-cr2r7e2u.js +25 -0
- supython/admin/static/assets/EmptyState-DeDck-OL.js +2 -0
- supython/admin/static/assets/Grid-hFkp9F4P.js +2 -0
- supython/admin/static/assets/Input-DppYTq9C.js +259 -0
- supython/admin/static/assets/Invoke-DW3Nveeh.js +2 -0
- supython/admin/static/assets/JsonField-DibyJgun.js +2 -0
- supython/admin/static/assets/LoginView-BjLyE3Ds.css +1 -0
- supython/admin/static/assets/LoginView-CoOjECT_.js +111 -0
- supython/admin/static/assets/Logs-D9WYrnIT.js +2 -0
- supython/admin/static/assets/Logs-DS1XPa0h.css +1 -0
- supython/admin/static/assets/Migrations-DOSC2ddQ.js +2 -0
- supython/admin/static/assets/ObjectBrowser-_5w8vOX8.js +2 -0
- supython/admin/static/assets/Queue-CywZs6vI.js +2 -0
- supython/admin/static/assets/RefreshTokens-Ccjr53jg.js +2 -0
- supython/admin/static/assets/RlsEditor-BSlH9vSc.js +2 -0
- supython/admin/static/assets/Routes-BiLXE49D.js +2 -0
- supython/admin/static/assets/Routes-C-ianIGD.css +1 -0
- supython/admin/static/assets/SchemaBrowser-DKy2_KQi.css +1 -0
- supython/admin/static/assets/SchemaBrowser-XFvFbtDB.js +2 -0
- supython/admin/static/assets/Select-DIzZyRZb.js +434 -0
- supython/admin/static/assets/Space-n5-XcguU.js +400 -0
- supython/admin/static/assets/SqlEditor-b8pTsILY.js +3 -0
- supython/admin/static/assets/SqlWorkspace-BUS7IntH.js +104 -0
- supython/admin/static/assets/TableData-CQIagLKn.js +2 -0
- supython/admin/static/assets/Tag-D1fOKpTH.js +72 -0
- supython/admin/static/assets/Templates-BS-ugkdq.js +2 -0
- supython/admin/static/assets/Thing-CEAniuMg.js +107 -0
- supython/admin/static/assets/Users-wzwajhlh.js +2 -0
- supython/admin/static/assets/_plugin-vue_export-helper-DGA9ry_j.js +1 -0
- supython/admin/static/assets/dist-VXIJLCYq.js +13 -0
- supython/admin/static/assets/format-length-CGCY1rMh.js +2 -0
- supython/admin/static/assets/get-Ca6unauB.js +2 -0
- supython/admin/static/assets/index-CeE6v959.js +951 -0
- supython/admin/static/assets/pinia-COXwfrOX.js +2 -0
- supython/admin/static/assets/resources-Bt6thQCD.js +44 -0
- supython/admin/static/assets/use-locale-mtgM0a3a.js +2 -0
- supython/admin/static/assets/use-merged-state-BvhkaHNX.js +2 -0
- supython/admin/static/assets/useConfirm-tMjvBFXR.js +2 -0
- supython/admin/static/assets/useResource-C_rJCY8C.js +2 -0
- supython/admin/static/assets/useTable-CnZc5zhi.js +363 -0
- supython/admin/static/assets/useTable-Dg0XlRlq.css +1 -0
- supython/admin/static/assets/useToast-DsZKx0IX.js +2 -0
- supython/admin/static/assets/utils-sbXoq7Ir.js +2 -0
- supython/admin/static/favicon.svg +1 -0
- supython/admin/static/icons.svg +24 -0
- supython/admin/static/index.html +24 -0
- supython/app.py +149 -0
- supython/auth/__init__.py +3 -0
- supython/auth/_email_job.py +11 -0
- supython/auth/providers/__init__.py +34 -0
- supython/auth/providers/github.py +22 -0
- supython/auth/providers/google.py +19 -0
- supython/auth/providers/oauth.py +56 -0
- supython/auth/providers/registry.py +16 -0
- supython/auth/ratelimit.py +39 -0
- supython/auth/router.py +282 -0
- supython/auth/schemas.py +79 -0
- supython/auth/service.py +587 -0
- supython/body_size.py +184 -0
- supython/cli.py +1653 -0
- supython/client/__init__.py +67 -0
- supython/client/_auth.py +249 -0
- supython/client/_client.py +145 -0
- supython/client/_config.py +92 -0
- supython/client/_functions.py +69 -0
- supython/client/_storage.py +255 -0
- supython/client/py.typed +0 -0
- supython/db.py +151 -0
- supython/db_admin.py +8 -0
- supython/functions/__init__.py +19 -0
- supython/functions/context.py +262 -0
- supython/functions/loader.py +307 -0
- supython/functions/router.py +228 -0
- supython/functions/schemas.py +50 -0
- supython/gen/__init__.py +5 -0
- supython/gen/_introspect.py +137 -0
- supython/gen/types_py.py +270 -0
- supython/gen/types_ts.py +365 -0
- supython/health.py +229 -0
- supython/hooks.py +117 -0
- supython/jobs/__init__.py +31 -0
- supython/jobs/backends.py +97 -0
- supython/jobs/context.py +58 -0
- supython/jobs/cron.py +152 -0
- supython/jobs/cron_inproc.py +118 -0
- supython/jobs/decorators.py +76 -0
- supython/jobs/registry.py +79 -0
- supython/jobs/router.py +136 -0
- supython/jobs/schemas.py +92 -0
- supython/jobs/service.py +311 -0
- supython/jobs/worker.py +219 -0
- supython/jwks.py +257 -0
- supython/keyset.py +279 -0
- supython/logging_config.py +291 -0
- supython/mail.py +33 -0
- supython/mailer.py +65 -0
- supython/migrate.py +81 -0
- supython/migrations/0001_extensions_and_roles.sql +46 -0
- supython/migrations/0002_auth_schema.sql +66 -0
- supython/migrations/0003_demo_todos.sql +42 -0
- supython/migrations/0004_auth_v0_2.sql +47 -0
- supython/migrations/0005_storage_schema.sql +117 -0
- supython/migrations/0006_realtime_schema.sql +206 -0
- supython/migrations/0007_jobs_schema.sql +254 -0
- supython/migrations/0008_jobs_last_error.sql +56 -0
- supython/migrations/0009_auth_rate_limits.sql +33 -0
- supython/migrations/0010_worker_heartbeat.sql +14 -0
- supython/migrations/0011_admin_schema.sql +45 -0
- supython/migrations/0012_auth_banned_until.sql +10 -0
- supython/migrations/0013_email_templates.sql +19 -0
- supython/migrations/0014_realtime_payload_warning.sql +96 -0
- supython/migrations/0015_backups_schema.sql +14 -0
- supython/passwords.py +15 -0
- supython/realtime/__init__.py +6 -0
- supython/realtime/broker.py +814 -0
- supython/realtime/protocol.py +234 -0
- supython/realtime/router.py +184 -0
- supython/realtime/schemas.py +207 -0
- supython/realtime/service.py +261 -0
- supython/realtime/topics.py +175 -0
- supython/realtime/websocket.py +586 -0
- supython/scaffold/__init__.py +5 -0
- supython/scaffold/init_project.py +133 -0
- supython/scaffold/templates/Caddyfile.tmpl +4 -0
- supython/scaffold/templates/README.md.tmpl +22 -0
- supython/scaffold/templates/docker-compose.prod.yml.tmpl +84 -0
- supython/scaffold/templates/docker-compose.yml.tmpl +41 -0
- supython/scaffold/templates/docker_postgres_Dockerfile.tmpl +9 -0
- supython/scaffold/templates/docker_postgres_postgresql.conf.tmpl +3 -0
- supython/scaffold/templates/env.example.tmpl +149 -0
- supython/scaffold/templates/functions_README.md.tmpl +21 -0
- supython/scaffold/templates/gitignore.tmpl +14 -0
- supython/scaffold/templates/migrations/.gitkeep +0 -0
- supython/secretset.py +347 -0
- supython/security_headers.py +78 -0
- supython/settings.py +198 -0
- supython/storage/__init__.py +5 -0
- supython/storage/backends.py +392 -0
- supython/storage/router.py +341 -0
- supython/storage/schemas.py +50 -0
- supython/storage/service.py +445 -0
- supython/storage/signing.py +119 -0
- supython/tokens.py +85 -0
- supython-0.5.0.dist-info/METADATA +714 -0
- supython-0.5.0.dist-info/RECORD +188 -0
- supython-0.5.0.dist-info/WHEEL +4 -0
- supython-0.5.0.dist-info/entry_points.txt +2 -0
- supython-0.5.0.dist-info/licenses/LICENSE +21 -0
supython/migrate.py
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
"""Tiny SQL migration runner for framework DDL only.
|
|
2
|
+
|
|
3
|
+
Applies every `*.sql` file shipped under ``supython/migrations`` in
|
|
4
|
+
lexical order, recording each filename in ``supython.migrations`` so it
|
|
5
|
+
runs exactly once. This is intentionally minimal and intentionally
|
|
6
|
+
scoped: it owns the framework's own schemas (``auth``, ``storage``,
|
|
7
|
+
``realtime``, ``jobs``, ``supython``), not your application's schema
|
|
8
|
+
history.
|
|
9
|
+
|
|
10
|
+
The framework migrations ship inside the installed wheel
|
|
11
|
+
(``src/supython/migrations/``) so ``supython migrate`` works from any
|
|
12
|
+
working directory — no repo checkout required.
|
|
13
|
+
|
|
14
|
+
For app-level migrations, supython recommends **dbmate** (single Go
|
|
15
|
+
binary, raw SQL, no Python deps) — see ``docs/migrations.md``. atlas and
|
|
16
|
+
sqitch are documented as alternates. Alembic is deliberately not
|
|
17
|
+
recommended (no ORM → no autogeneration → no value-add over dbmate).
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
import asyncio
|
|
21
|
+
import logging
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
|
|
24
|
+
import asyncpg
|
|
25
|
+
|
|
26
|
+
from .settings import get_settings
|
|
27
|
+
|
|
28
|
+
logger = logging.getLogger(__name__)
|
|
29
|
+
|
|
30
|
+
DEFAULT_MIGRATIONS_DIR = Path(__file__).resolve().parent / "migrations"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
async def _ensure_table(conn: asyncpg.Connection) -> None:
|
|
34
|
+
await conn.execute(
|
|
35
|
+
"""
|
|
36
|
+
create schema if not exists supython;
|
|
37
|
+
create table if not exists supython.migrations (
|
|
38
|
+
name text primary key,
|
|
39
|
+
applied_at timestamptz not null default now()
|
|
40
|
+
);
|
|
41
|
+
"""
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
async def run_migrations(directory: Path | None = None) -> list[str]:
|
|
46
|
+
"""Apply pending migrations. Returns the list of newly applied filenames."""
|
|
47
|
+
target = directory or DEFAULT_MIGRATIONS_DIR
|
|
48
|
+
if not target.exists():
|
|
49
|
+
raise FileNotFoundError(f"Migrations directory not found: {target}")
|
|
50
|
+
|
|
51
|
+
files = sorted(p for p in target.glob("*.sql"))
|
|
52
|
+
applied: list[str] = []
|
|
53
|
+
|
|
54
|
+
settings = get_settings()
|
|
55
|
+
conn = await asyncpg.connect(settings.database_url)
|
|
56
|
+
try:
|
|
57
|
+
await _ensure_table(conn)
|
|
58
|
+
existing = {
|
|
59
|
+
r["name"]
|
|
60
|
+
for r in await conn.fetch("select name from supython.migrations")
|
|
61
|
+
}
|
|
62
|
+
for path in files:
|
|
63
|
+
if path.name in existing:
|
|
64
|
+
continue
|
|
65
|
+
sql = path.read_text()
|
|
66
|
+
async with conn.transaction():
|
|
67
|
+
await conn.execute(sql)
|
|
68
|
+
await conn.execute(
|
|
69
|
+
"insert into supython.migrations (name) values ($1)",
|
|
70
|
+
path.name,
|
|
71
|
+
)
|
|
72
|
+
applied.append(path.name)
|
|
73
|
+
logger.info("migration applied: %s", path.name)
|
|
74
|
+
finally:
|
|
75
|
+
await conn.close()
|
|
76
|
+
|
|
77
|
+
return applied
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def run_sync(directory: Path | None = None) -> list[str]:
|
|
81
|
+
return asyncio.run(run_migrations(directory))
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
-- Extensions and the role hierarchy that PostgREST + supython depend on.
|
|
2
|
+
--
|
|
3
|
+
-- Roles:
|
|
4
|
+
-- authenticator - the role PostgREST connects to the DB as (LOGIN).
|
|
5
|
+
-- It owns no tables; it switches into one of the roles
|
|
6
|
+
-- below for each request via SET ROLE.
|
|
7
|
+
-- anon - unauthenticated requests (no JWT).
|
|
8
|
+
-- authenticated - any user with a valid JWT issued by supython.
|
|
9
|
+
-- service_role - bypasses RLS; used by supython for admin tasks.
|
|
10
|
+
|
|
11
|
+
create extension if not exists pgcrypto;
|
|
12
|
+
-- pg_cron: required by the jobs module for SQL-level cron scheduling.
|
|
13
|
+
-- The extension is declared here (not in 0007) per project convention:
|
|
14
|
+
-- all extensions are registered in 0001_extensions_and_roles.sql.
|
|
15
|
+
create extension if not exists pg_cron;
|
|
16
|
+
|
|
17
|
+
do $$
|
|
18
|
+
begin
|
|
19
|
+
if not exists (select 1 from pg_roles where rolname = 'anon') then
|
|
20
|
+
create role anon nologin noinherit;
|
|
21
|
+
end if;
|
|
22
|
+
|
|
23
|
+
if not exists (select 1 from pg_roles where rolname = 'authenticated') then
|
|
24
|
+
create role authenticated nologin noinherit;
|
|
25
|
+
end if;
|
|
26
|
+
|
|
27
|
+
if not exists (select 1 from pg_roles where rolname = 'service_role') then
|
|
28
|
+
create role service_role nologin noinherit bypassrls;
|
|
29
|
+
end if;
|
|
30
|
+
|
|
31
|
+
if not exists (select 1 from pg_roles where rolname = 'authenticator') then
|
|
32
|
+
create role authenticator with login password 'authenticator' noinherit;
|
|
33
|
+
end if;
|
|
34
|
+
end
|
|
35
|
+
$$;
|
|
36
|
+
|
|
37
|
+
grant anon, authenticated, service_role to authenticator;
|
|
38
|
+
|
|
39
|
+
grant usage on schema public to anon, authenticated, service_role;
|
|
40
|
+
|
|
41
|
+
alter default privileges in schema public
|
|
42
|
+
grant all on tables to service_role;
|
|
43
|
+
alter default privileges in schema public
|
|
44
|
+
grant all on sequences to service_role;
|
|
45
|
+
alter default privileges in schema public
|
|
46
|
+
grant all on functions to service_role;
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
-- The `auth` schema mirrors Supabase's loose conventions so RLS policies
|
|
2
|
+
-- written for either system tend to work in both.
|
|
3
|
+
|
|
4
|
+
create extension if not exists citext;
|
|
5
|
+
|
|
6
|
+
create schema if not exists auth;
|
|
7
|
+
|
|
8
|
+
grant usage on schema auth to anon, authenticated, service_role;
|
|
9
|
+
|
|
10
|
+
create table if not exists auth.users (
|
|
11
|
+
id uuid primary key default gen_random_uuid(),
|
|
12
|
+
email citext unique not null,
|
|
13
|
+
encrypted_password text,
|
|
14
|
+
email_confirmed_at timestamptz,
|
|
15
|
+
last_sign_in_at timestamptz,
|
|
16
|
+
raw_user_meta_data jsonb not null default '{}'::jsonb,
|
|
17
|
+
created_at timestamptz not null default now(),
|
|
18
|
+
updated_at timestamptz not null default now()
|
|
19
|
+
);
|
|
20
|
+
|
|
21
|
+
create table if not exists auth.refresh_tokens (
|
|
22
|
+
id bigserial primary key,
|
|
23
|
+
user_id uuid not null references auth.users (id) on delete cascade,
|
|
24
|
+
token text unique not null,
|
|
25
|
+
parent text,
|
|
26
|
+
revoked boolean not null default false,
|
|
27
|
+
created_at timestamptz not null default now()
|
|
28
|
+
);
|
|
29
|
+
|
|
30
|
+
create index if not exists refresh_tokens_user_id_idx
|
|
31
|
+
on auth.refresh_tokens (user_id);
|
|
32
|
+
|
|
33
|
+
-- Helpers used by RLS policies. They read the JWT claims that PostgREST
|
|
34
|
+
-- (or the supython service) sets via `SET LOCAL request.jwt.claims = ...`.
|
|
35
|
+
create or replace function auth.uid()
|
|
36
|
+
returns uuid
|
|
37
|
+
language sql
|
|
38
|
+
stable
|
|
39
|
+
as $$
|
|
40
|
+
select nullif(
|
|
41
|
+
current_setting('request.jwt.claims', true)::jsonb ->> 'sub',
|
|
42
|
+
''
|
|
43
|
+
)::uuid
|
|
44
|
+
$$;
|
|
45
|
+
|
|
46
|
+
create or replace function auth.role()
|
|
47
|
+
returns text
|
|
48
|
+
language sql
|
|
49
|
+
stable
|
|
50
|
+
as $$
|
|
51
|
+
select current_setting('request.jwt.claims', true)::jsonb ->> 'role'
|
|
52
|
+
$$;
|
|
53
|
+
|
|
54
|
+
create or replace function auth.email()
|
|
55
|
+
returns text
|
|
56
|
+
language sql
|
|
57
|
+
stable
|
|
58
|
+
as $$
|
|
59
|
+
select current_setting('request.jwt.claims', true)::jsonb ->> 'email'
|
|
60
|
+
$$;
|
|
61
|
+
|
|
62
|
+
grant execute on function auth.uid(), auth.role(), auth.email()
|
|
63
|
+
to anon, authenticated, service_role;
|
|
64
|
+
|
|
65
|
+
grant all on all tables in schema auth to service_role;
|
|
66
|
+
grant all on all sequences in schema auth to service_role;
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
-- A trivial todos table the spike uses to demonstrate the whole point:
|
|
2
|
+
-- ZERO Python CRUD code. Reads, writes, filters, ordering, pagination —
|
|
3
|
+
-- all served by PostgREST under RLS.
|
|
4
|
+
|
|
5
|
+
create table if not exists public.todos (
|
|
6
|
+
id uuid primary key default gen_random_uuid(),
|
|
7
|
+
user_id uuid not null default auth.uid()
|
|
8
|
+
references auth.users (id) on delete cascade,
|
|
9
|
+
title text not null check (length(title) > 0),
|
|
10
|
+
done boolean not null default false,
|
|
11
|
+
created_at timestamptz not null default now()
|
|
12
|
+
);
|
|
13
|
+
|
|
14
|
+
alter table public.todos enable row level security;
|
|
15
|
+
|
|
16
|
+
drop policy if exists "todos: owner can read" on public.todos;
|
|
17
|
+
drop policy if exists "todos: owner can insert" on public.todos;
|
|
18
|
+
drop policy if exists "todos: owner can update" on public.todos;
|
|
19
|
+
drop policy if exists "todos: owner can delete" on public.todos;
|
|
20
|
+
|
|
21
|
+
create policy "todos: owner can read"
|
|
22
|
+
on public.todos for select
|
|
23
|
+
to authenticated
|
|
24
|
+
using (user_id = auth.uid());
|
|
25
|
+
|
|
26
|
+
create policy "todos: owner can insert"
|
|
27
|
+
on public.todos for insert
|
|
28
|
+
to authenticated
|
|
29
|
+
with check (user_id = auth.uid());
|
|
30
|
+
|
|
31
|
+
create policy "todos: owner can update"
|
|
32
|
+
on public.todos for update
|
|
33
|
+
to authenticated
|
|
34
|
+
using (user_id = auth.uid())
|
|
35
|
+
with check (user_id = auth.uid());
|
|
36
|
+
|
|
37
|
+
create policy "todos: owner can delete"
|
|
38
|
+
on public.todos for delete
|
|
39
|
+
to authenticated
|
|
40
|
+
using (user_id = auth.uid());
|
|
41
|
+
|
|
42
|
+
grant select, insert, update, delete on public.todos to authenticated;
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
-- v0.2 auth: OAuth identity mapping, one-time tokens (password reset / magic
|
|
2
|
+
-- link / email OTP), security audit log, and index for refresh-token chain
|
|
3
|
+
-- walks (reuse detection).
|
|
4
|
+
|
|
5
|
+
create table if not exists auth.identities (
|
|
6
|
+
id uuid primary key default gen_random_uuid(),
|
|
7
|
+
user_id uuid not null references auth.users (id) on delete cascade,
|
|
8
|
+
provider text not null,
|
|
9
|
+
provider_user_id text not null,
|
|
10
|
+
identity_data jsonb not null default '{}'::jsonb,
|
|
11
|
+
created_at timestamptz not null default now(),
|
|
12
|
+
constraint identities_provider_user_unique unique (provider, provider_user_id)
|
|
13
|
+
);
|
|
14
|
+
|
|
15
|
+
create table if not exists auth.one_time_tokens (
|
|
16
|
+
id uuid primary key default gen_random_uuid(),
|
|
17
|
+
user_id uuid not null references auth.users (id) on delete cascade,
|
|
18
|
+
type text not null,
|
|
19
|
+
token_hash text not null,
|
|
20
|
+
expires_at timestamptz not null,
|
|
21
|
+
used_at timestamptz,
|
|
22
|
+
created_at timestamptz not null default now(),
|
|
23
|
+
constraint one_time_tokens_type_check
|
|
24
|
+
check (type in ('recover', 'magic_link', 'otp')),
|
|
25
|
+
constraint one_time_tokens_token_hash_unique unique (token_hash)
|
|
26
|
+
);
|
|
27
|
+
|
|
28
|
+
create table if not exists auth.audit_log (
|
|
29
|
+
id uuid primary key default gen_random_uuid(),
|
|
30
|
+
user_id uuid references auth.users (id) on delete set null,
|
|
31
|
+
event text not null,
|
|
32
|
+
ip inet,
|
|
33
|
+
ua text,
|
|
34
|
+
payload jsonb not null default '{}'::jsonb,
|
|
35
|
+
created_at timestamptz not null default now()
|
|
36
|
+
);
|
|
37
|
+
|
|
38
|
+
create index if not exists refresh_tokens_parent_idx
|
|
39
|
+
on auth.refresh_tokens (parent);
|
|
40
|
+
|
|
41
|
+
alter table if exists auth.identities owner to service_role;
|
|
42
|
+
alter table if exists auth.one_time_tokens owner to service_role;
|
|
43
|
+
alter table if exists auth.audit_log owner to service_role;
|
|
44
|
+
|
|
45
|
+
grant all on table auth.identities to service_role;
|
|
46
|
+
grant all on table auth.one_time_tokens to service_role;
|
|
47
|
+
grant all on table auth.audit_log to service_role;
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
-- v0.3 storage: logical buckets and object metadata with RLS. Bytes live in a
|
|
2
|
+
-- backend (local/S3); this schema is the authority for who may read/write.
|
|
3
|
+
|
|
4
|
+
create schema if not exists storage;
|
|
5
|
+
|
|
6
|
+
grant usage on schema storage to anon, authenticated, service_role;
|
|
7
|
+
|
|
8
|
+
create table if not exists storage.buckets (
|
|
9
|
+
id uuid primary key default gen_random_uuid(),
|
|
10
|
+
name text unique not null
|
|
11
|
+
check (name ~ '^[a-z0-9][a-z0-9_-]{0,62}$'),
|
|
12
|
+
owner uuid references auth.users (id) on delete set null,
|
|
13
|
+
public boolean not null default false,
|
|
14
|
+
file_size_limit bigint,
|
|
15
|
+
allowed_mime_types text[],
|
|
16
|
+
created_at timestamptz not null default now(),
|
|
17
|
+
updated_at timestamptz not null default now()
|
|
18
|
+
);
|
|
19
|
+
|
|
20
|
+
create table if not exists storage.objects (
|
|
21
|
+
id uuid primary key default gen_random_uuid(),
|
|
22
|
+
bucket_id uuid not null references storage.buckets (id) on delete cascade,
|
|
23
|
+
name text not null,
|
|
24
|
+
owner uuid not null default auth.uid()
|
|
25
|
+
references auth.users (id) on delete cascade,
|
|
26
|
+
size bigint not null,
|
|
27
|
+
mime_type text,
|
|
28
|
+
etag text,
|
|
29
|
+
metadata jsonb not null default '{}'::jsonb,
|
|
30
|
+
created_at timestamptz not null default now(),
|
|
31
|
+
updated_at timestamptz not null default now(),
|
|
32
|
+
constraint objects_bucket_name_unique unique (bucket_id, name)
|
|
33
|
+
);
|
|
34
|
+
|
|
35
|
+
create index if not exists objects_owner_idx on storage.objects (owner);
|
|
36
|
+
|
|
37
|
+
alter table if exists storage.buckets owner to service_role;
|
|
38
|
+
alter table if exists storage.objects owner to service_role;
|
|
39
|
+
|
|
40
|
+
grant all on all tables in schema storage to service_role;
|
|
41
|
+
|
|
42
|
+
alter table storage.buckets enable row level security;
|
|
43
|
+
alter table storage.objects enable row level security;
|
|
44
|
+
|
|
45
|
+
drop policy if exists "buckets: any authed can read" on storage.buckets;
|
|
46
|
+
drop policy if exists "buckets: anon can read public" on storage.buckets;
|
|
47
|
+
drop policy if exists "buckets: owner can insert" on storage.buckets;
|
|
48
|
+
drop policy if exists "buckets: owner can update" on storage.buckets;
|
|
49
|
+
drop policy if exists "buckets: owner can delete" on storage.buckets;
|
|
50
|
+
|
|
51
|
+
create policy "buckets: any authed can read"
|
|
52
|
+
on storage.buckets for select
|
|
53
|
+
to authenticated
|
|
54
|
+
using (true);
|
|
55
|
+
|
|
56
|
+
-- Lets anon evaluate `bucket_id in (select id from storage.buckets where public)`
|
|
57
|
+
-- when the objects policy runs; without this, anon has no bucket rows under RLS.
|
|
58
|
+
create policy "buckets: anon can read public"
|
|
59
|
+
on storage.buckets for select
|
|
60
|
+
to anon
|
|
61
|
+
using (public);
|
|
62
|
+
|
|
63
|
+
create policy "buckets: owner can insert"
|
|
64
|
+
on storage.buckets for insert
|
|
65
|
+
to authenticated
|
|
66
|
+
with check (owner = auth.uid());
|
|
67
|
+
|
|
68
|
+
create policy "buckets: owner can update"
|
|
69
|
+
on storage.buckets for update
|
|
70
|
+
to authenticated
|
|
71
|
+
using (owner = auth.uid())
|
|
72
|
+
with check (owner = auth.uid());
|
|
73
|
+
|
|
74
|
+
create policy "buckets: owner can delete"
|
|
75
|
+
on storage.buckets for delete
|
|
76
|
+
to authenticated
|
|
77
|
+
using (owner = auth.uid());
|
|
78
|
+
|
|
79
|
+
drop policy if exists "objects: owner can read" on storage.objects;
|
|
80
|
+
drop policy if exists "objects: owner can insert" on storage.objects;
|
|
81
|
+
drop policy if exists "objects: owner can update" on storage.objects;
|
|
82
|
+
drop policy if exists "objects: owner can delete" on storage.objects;
|
|
83
|
+
drop policy if exists "objects: public bucket read" on storage.objects;
|
|
84
|
+
|
|
85
|
+
create policy "objects: owner can read"
|
|
86
|
+
on storage.objects for select
|
|
87
|
+
to authenticated
|
|
88
|
+
using (owner = auth.uid());
|
|
89
|
+
|
|
90
|
+
create policy "objects: owner can insert"
|
|
91
|
+
on storage.objects for insert
|
|
92
|
+
to authenticated
|
|
93
|
+
with check (owner = auth.uid());
|
|
94
|
+
|
|
95
|
+
create policy "objects: owner can update"
|
|
96
|
+
on storage.objects for update
|
|
97
|
+
to authenticated
|
|
98
|
+
using (owner = auth.uid())
|
|
99
|
+
with check (owner = auth.uid());
|
|
100
|
+
|
|
101
|
+
create policy "objects: owner can delete"
|
|
102
|
+
on storage.objects for delete
|
|
103
|
+
to authenticated
|
|
104
|
+
using (owner = auth.uid());
|
|
105
|
+
|
|
106
|
+
create policy "objects: public bucket read"
|
|
107
|
+
on storage.objects for select
|
|
108
|
+
to anon, authenticated
|
|
109
|
+
using (
|
|
110
|
+
bucket_id in (
|
|
111
|
+
select id from storage.buckets where public
|
|
112
|
+
)
|
|
113
|
+
);
|
|
114
|
+
|
|
115
|
+
grant select, insert, update, delete on storage.buckets to authenticated;
|
|
116
|
+
grant select, insert, update, delete on storage.objects to authenticated;
|
|
117
|
+
grant select on storage.buckets, storage.objects to anon;
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
-- v0.4 realtime: in-process broker + Phoenix-channels subset over WebSocket.
|
|
2
|
+
--
|
|
3
|
+
-- Creates the realtime schema with:
|
|
4
|
+
-- realtime.enabled_tables — registry of tables opted into change events
|
|
5
|
+
-- realtime.fire_notify() — AFTER INSERT/UPDATE/DELETE trigger function
|
|
6
|
+
-- realtime.enable(regclass, text) — management helper (service_role only)
|
|
7
|
+
--
|
|
8
|
+
-- The broker listens on the pg_notify channel 'realtime:changes'. The
|
|
9
|
+
-- channel name is also stored in settings.realtime_notify_channel; both
|
|
10
|
+
-- must agree. Changing the channel requires updating this trigger and the
|
|
11
|
+
-- application setting together.
|
|
12
|
+
|
|
13
|
+
create schema if not exists realtime;
|
|
14
|
+
|
|
15
|
+
grant usage on schema realtime to anon, authenticated, service_role;
|
|
16
|
+
|
|
17
|
+
-- ─── registry ────────────────────────────────────────────────────────────────
|
|
18
|
+
|
|
19
|
+
create table if not exists realtime.enabled_tables (
|
|
20
|
+
schema_name text not null,
|
|
21
|
+
table_name text not null,
|
|
22
|
+
pk_columns text[] not null,
|
|
23
|
+
-- owner_column is used for DELETE fan-out under RLS: when a DELETE fires
|
|
24
|
+
-- the row is gone, so the broker falls back to comparing
|
|
25
|
+
-- old_record-><owner_column> against the subscriber's auth.uid().
|
|
26
|
+
-- null means DELETEs are delivered only to service_role subscribers.
|
|
27
|
+
owner_column text,
|
|
28
|
+
created_at timestamptz not null default now(),
|
|
29
|
+
primary key (schema_name, table_name)
|
|
30
|
+
);
|
|
31
|
+
|
|
32
|
+
alter table realtime.enabled_tables owner to service_role;
|
|
33
|
+
|
|
34
|
+
alter table realtime.enabled_tables enable row level security;
|
|
35
|
+
|
|
36
|
+
-- Any authenticated or anonymous client may read the registry to validate
|
|
37
|
+
-- their phx_join config. Writes flow exclusively through realtime.enable()
|
|
38
|
+
-- which is security definer and reserved for service_role callers.
|
|
39
|
+
drop policy if exists "enabled_tables: anyone can read" on realtime.enabled_tables;
|
|
40
|
+
|
|
41
|
+
create policy "enabled_tables: anyone can read"
|
|
42
|
+
on realtime.enabled_tables for select
|
|
43
|
+
to anon, authenticated, service_role
|
|
44
|
+
using (true);
|
|
45
|
+
|
|
46
|
+
grant select on realtime.enabled_tables to anon, authenticated;
|
|
47
|
+
grant all on realtime.enabled_tables to service_role;
|
|
48
|
+
|
|
49
|
+
-- ─── trigger function ─────────────────────────────────────────────────────────
|
|
50
|
+
|
|
51
|
+
-- fire_notify() is attached to every table registered via realtime.enable().
|
|
52
|
+
-- It emits one pg_notify per changed row on the 'realtime:changes' channel.
|
|
53
|
+
--
|
|
54
|
+
-- Payload shape (JSON object; broker enriches nothing — all data lives here):
|
|
55
|
+
-- schema text — tg_table_schema
|
|
56
|
+
-- table text — tg_table_name
|
|
57
|
+
-- type text — INSERT | UPDATE | DELETE
|
|
58
|
+
-- commit_timestamp text — ISO-8601 UTC, e.g. "2026-04-20T12:34:56Z"
|
|
59
|
+
-- columns jsonb array — [{name, type}, …] from pg_attribute cache
|
|
60
|
+
-- record jsonb | null — NEW row for INSERT / UPDATE, null for DELETE
|
|
61
|
+
-- old_record jsonb | null — OLD row for UPDATE / DELETE, null for INSERT
|
|
62
|
+
--
|
|
63
|
+
-- security definer so the function always runs with enough privilege to read
|
|
64
|
+
-- pg_attribute and call pg_notify regardless of the invoking role.
|
|
65
|
+
create or replace function realtime.fire_notify()
|
|
66
|
+
returns trigger language plpgsql security definer as $$
|
|
67
|
+
declare
|
|
68
|
+
v_columns jsonb;
|
|
69
|
+
v_payload jsonb;
|
|
70
|
+
begin
|
|
71
|
+
-- column metadata from the shared-buffer catalog cache (very fast)
|
|
72
|
+
select jsonb_agg(
|
|
73
|
+
jsonb_build_object(
|
|
74
|
+
'name', a.attname,
|
|
75
|
+
'type', format_type(a.atttypid, a.atttypmod)
|
|
76
|
+
)
|
|
77
|
+
order by a.attnum
|
|
78
|
+
)
|
|
79
|
+
into v_columns
|
|
80
|
+
from pg_attribute a
|
|
81
|
+
where a.attrelid = tg_relid
|
|
82
|
+
and a.attnum > 0
|
|
83
|
+
and not a.attisdropped;
|
|
84
|
+
|
|
85
|
+
v_payload := jsonb_build_object(
|
|
86
|
+
'schema', tg_table_schema,
|
|
87
|
+
'table', tg_table_name,
|
|
88
|
+
'type', tg_op,
|
|
89
|
+
'commit_timestamp', to_char(
|
|
90
|
+
now() at time zone 'utc',
|
|
91
|
+
'YYYY-MM-DD"T"HH24:MI:SS"Z"'
|
|
92
|
+
),
|
|
93
|
+
'columns', coalesce(v_columns, '[]'::jsonb),
|
|
94
|
+
'record', case
|
|
95
|
+
when tg_op in ('INSERT', 'UPDATE') then to_jsonb(new)
|
|
96
|
+
else null
|
|
97
|
+
end,
|
|
98
|
+
'old_record', case
|
|
99
|
+
when tg_op in ('UPDATE', 'DELETE') then to_jsonb(old)
|
|
100
|
+
else null
|
|
101
|
+
end
|
|
102
|
+
);
|
|
103
|
+
|
|
104
|
+
perform pg_notify('realtime:changes', v_payload::text);
|
|
105
|
+
|
|
106
|
+
-- AFTER trigger; return value is ignored for row-level AFTER triggers,
|
|
107
|
+
-- but returning null is the conventional choice.
|
|
108
|
+
return null;
|
|
109
|
+
end $$;
|
|
110
|
+
|
|
111
|
+
-- ─── enable() helper ─────────────────────────────────────────────────────────
|
|
112
|
+
|
|
113
|
+
-- realtime.enable(tbl, owner_column):
|
|
114
|
+
-- 1. Resolves the schema + table name and primary-key columns from
|
|
115
|
+
-- the system catalog.
|
|
116
|
+
-- 2. Upserts a row in realtime.enabled_tables.
|
|
117
|
+
-- 3. Drops then recreates the realtime_notify AFTER trigger on the table.
|
|
118
|
+
--
|
|
119
|
+
-- Idempotent: safe to call multiple times; re-registers pk_columns and
|
|
120
|
+
-- owner_column on conflict.
|
|
121
|
+
--
|
|
122
|
+
-- owner_column defaults to 'user_id'. If the column does not exist on the
|
|
123
|
+
-- target table, owner_column is silently set to null (opt-out of the
|
|
124
|
+
-- DELETE short-circuit path; only service_role listeners receive DELETEs).
|
|
125
|
+
--
|
|
126
|
+
-- Raises an exception if the table has no primary key.
|
|
127
|
+
create or replace function realtime.enable(
|
|
128
|
+
tbl regclass,
|
|
129
|
+
owner_column text default 'user_id'
|
|
130
|
+
)
|
|
131
|
+
returns void language plpgsql security definer as $$
|
|
132
|
+
declare
|
|
133
|
+
v_schema text;
|
|
134
|
+
v_table text;
|
|
135
|
+
v_pk_columns text[];
|
|
136
|
+
v_has_owner boolean;
|
|
137
|
+
v_trigger_name text := 'realtime_notify';
|
|
138
|
+
begin
|
|
139
|
+
-- resolve schema + table name
|
|
140
|
+
select n.nspname, c.relname
|
|
141
|
+
into strict v_schema, v_table
|
|
142
|
+
from pg_class c
|
|
143
|
+
join pg_namespace n on n.oid = c.relnamespace
|
|
144
|
+
where c.oid = tbl;
|
|
145
|
+
|
|
146
|
+
-- collect PK columns in index-key order using unnest+ordinality so the
|
|
147
|
+
-- order matches pg_index.indkey exactly (array_position on int2vector is
|
|
148
|
+
-- not reliable across all PG versions)
|
|
149
|
+
select array_agg(a.attname order by k.ord)
|
|
150
|
+
into v_pk_columns
|
|
151
|
+
from pg_index ix
|
|
152
|
+
cross join lateral unnest(ix.indkey) with ordinality as k(attnum, ord)
|
|
153
|
+
join pg_attribute a
|
|
154
|
+
on a.attrelid = ix.indrelid
|
|
155
|
+
and a.attnum = k.attnum
|
|
156
|
+
and a.attnum > 0
|
|
157
|
+
and not a.attisdropped
|
|
158
|
+
where ix.indrelid = tbl
|
|
159
|
+
and ix.indisprimary;
|
|
160
|
+
|
|
161
|
+
if v_pk_columns is null then
|
|
162
|
+
raise exception
|
|
163
|
+
'table % has no primary key — realtime.enable() requires a primary key',
|
|
164
|
+
tbl::text;
|
|
165
|
+
end if;
|
|
166
|
+
|
|
167
|
+
-- silently clear owner_column when the column is absent on the table
|
|
168
|
+
if owner_column is not null then
|
|
169
|
+
select exists(
|
|
170
|
+
select 1
|
|
171
|
+
from pg_attribute
|
|
172
|
+
where attrelid = tbl
|
|
173
|
+
and attname = owner_column
|
|
174
|
+
and attnum > 0
|
|
175
|
+
and not attisdropped
|
|
176
|
+
) into v_has_owner;
|
|
177
|
+
|
|
178
|
+
if not v_has_owner then
|
|
179
|
+
owner_column := null;
|
|
180
|
+
end if;
|
|
181
|
+
end if;
|
|
182
|
+
|
|
183
|
+
-- upsert the registry entry
|
|
184
|
+
insert into realtime.enabled_tables (schema_name, table_name, pk_columns, owner_column)
|
|
185
|
+
values (v_schema, v_table, v_pk_columns, owner_column)
|
|
186
|
+
on conflict (schema_name, table_name) do update
|
|
187
|
+
set pk_columns = excluded.pk_columns,
|
|
188
|
+
owner_column = excluded.owner_column;
|
|
189
|
+
|
|
190
|
+
-- (re-)attach the trigger; drop first for idempotency
|
|
191
|
+
execute format(
|
|
192
|
+
'drop trigger if exists %I on %s',
|
|
193
|
+
v_trigger_name, tbl
|
|
194
|
+
);
|
|
195
|
+
|
|
196
|
+
execute format(
|
|
197
|
+
'create trigger %I
|
|
198
|
+
after insert or update or delete on %s
|
|
199
|
+
for each row execute function realtime.fire_notify()',
|
|
200
|
+
v_trigger_name, tbl
|
|
201
|
+
);
|
|
202
|
+
end $$;
|
|
203
|
+
|
|
204
|
+
-- Only service_role may opt tables in to realtime. The trigger function is
|
|
205
|
+
-- security definer and is invoked automatically; no explicit grant is needed.
|
|
206
|
+
grant execute on function realtime.enable(regclass, text) to service_role;
|