@marktiderman/genesis-supabase 0.3.1 → 0.5.0
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.
- package/README.md +217 -33
- package/dist/blocks.d.ts +8 -1
- package/dist/blocks.js +1 -1
- package/dist/{chunk-T2HGFC4C.js → chunk-A3H5CTYT.js} +11 -3
- package/dist/chunk-A3H5CTYT.js.map +1 -0
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/jobs.d.ts +185 -0
- package/dist/jobs.js +163 -0
- package/dist/jobs.js.map +1 -0
- package/migrations/0007_genesis_jobs.sql +352 -0
- package/migrations/tests/0007_genesis_jobs.test.sql +129 -0
- package/package.json +39 -3
- package/sql/testing/genesis_testing.sql +526 -0
- package/sql/testing/tests/genesis_testing.test.sql +181 -0
- package/dist/chunk-T2HGFC4C.js.map +0 -1
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
-- Tests for genesis_testing.sql against a small sample schema with good and deliberately broken
|
|
2
|
+
-- rules, so each helper is shown both passing and catching a failure (through its probe_*).
|
|
3
|
+
-- Run: load genesis_testing.sql, then this file, in one database with pgTAP.
|
|
4
|
+
begin;
|
|
5
|
+
create extension if not exists pgtap with schema extensions;
|
|
6
|
+
select plan(39);
|
|
7
|
+
|
|
8
|
+
select genesis_testing.create_users();
|
|
9
|
+
|
|
10
|
+
-- A sample app: notes owned by a person, with a server-owned status, a guarded delete that
|
|
11
|
+
-- system work may bypass, a child table with ON DELETE SET NULL, and a private flag.
|
|
12
|
+
create type public.gt_mood as enum ('calm', 'busy');
|
|
13
|
+
create table public.gt_notes (
|
|
14
|
+
id uuid primary key,
|
|
15
|
+
owner uuid not null references auth.users (id),
|
|
16
|
+
body text not null,
|
|
17
|
+
status text not null default 'open',
|
|
18
|
+
private boolean not null default false,
|
|
19
|
+
mood public.gt_mood not null default 'calm',
|
|
20
|
+
tags text[] not null default '{}',
|
|
21
|
+
stage text not null default 'draft' check (stage in ('draft', 'sent'))
|
|
22
|
+
);
|
|
23
|
+
create table public.gt_links (
|
|
24
|
+
id uuid primary key,
|
|
25
|
+
note_id uuid references public.gt_notes (id) on delete set null
|
|
26
|
+
);
|
|
27
|
+
alter table public.gt_notes enable row level security;
|
|
28
|
+
alter table public.gt_links enable row level security;
|
|
29
|
+
create policy "own notes" on public.gt_notes for all to authenticated
|
|
30
|
+
using (owner = (select auth.uid())) with check (owner = (select auth.uid()));
|
|
31
|
+
create policy "links" on public.gt_links for all to authenticated using (true) with check (true);
|
|
32
|
+
grant select, insert, update, delete on public.gt_notes, public.gt_links to authenticated;
|
|
33
|
+
grant select on public.gt_notes to anon;
|
|
34
|
+
|
|
35
|
+
-- status is server-owned: a trigger puts it back for signed-in users.
|
|
36
|
+
create function public.gt_notes_guard() returns trigger language plpgsql as $$
|
|
37
|
+
begin
|
|
38
|
+
if (select auth.uid()) is not null and new.status is distinct from old.status then
|
|
39
|
+
new.status := old.status;
|
|
40
|
+
end if;
|
|
41
|
+
return new;
|
|
42
|
+
end $$;
|
|
43
|
+
create trigger gt_notes_guard before update on public.gt_notes for each row execute function public.gt_notes_guard();
|
|
44
|
+
|
|
45
|
+
-- Deleting is refused unless system work (no user, or gt.hard_delete=on).
|
|
46
|
+
create function public.gt_notes_no_delete() returns trigger language plpgsql as $$
|
|
47
|
+
begin
|
|
48
|
+
if (select auth.uid()) is not null and coalesce(current_setting('gt.hard_delete', true), '') <> 'on' then
|
|
49
|
+
raise exception 'Bin it instead' using errcode = '42501';
|
|
50
|
+
end if;
|
|
51
|
+
return old;
|
|
52
|
+
end $$;
|
|
53
|
+
create trigger gt_notes_no_delete before delete on public.gt_notes for each row execute function public.gt_notes_no_delete();
|
|
54
|
+
|
|
55
|
+
-- A report that respects the private flag, and a broken one that leaks it.
|
|
56
|
+
create function public.gt_report_good() returns table (owner uuid, body text)
|
|
57
|
+
language sql security definer set search_path = '' as $$
|
|
58
|
+
select n.owner, n.body from public.gt_notes n where not n.private
|
|
59
|
+
$$;
|
|
60
|
+
create function public.gt_report_leaky() returns table (owner uuid, body text)
|
|
61
|
+
language sql security definer set search_path = '' as $$
|
|
62
|
+
select n.owner, n.body from public.gt_notes n
|
|
63
|
+
$$;
|
|
64
|
+
grant execute on function public.gt_report_good(), public.gt_report_leaky() to authenticated;
|
|
65
|
+
|
|
66
|
+
insert into public.gt_notes (id, owner, body, private) values
|
|
67
|
+
('00000000-0000-4000-9000-000000000001', genesis_testing.user_id('client'), 'shared note', false),
|
|
68
|
+
('00000000-0000-4000-9000-000000000002', genesis_testing.user_id('client'), 'SECRET-DIARY', true),
|
|
69
|
+
('00000000-0000-4000-9000-000000000003', genesis_testing.user_id('owner'), 'owner note', false);
|
|
70
|
+
insert into public.gt_links values ('00000000-0000-4000-9100-000000000001', '00000000-0000-4000-9000-000000000001');
|
|
71
|
+
|
|
72
|
+
-- Fixtures and acting as someone ---------------------------------------------------------------
|
|
73
|
+
select is(genesis_testing.user_id('client'), '00000000-0000-4000-8000-000000000003'::uuid, 'fixture ids are fixed');
|
|
74
|
+
select is((select count(*) from auth.users where email like 'genesis-testing-%@example.test'), 4::bigint, 'four fixture logins');
|
|
75
|
+
select genesis_testing.sign_in('client');
|
|
76
|
+
select is((select auth.uid()), genesis_testing.user_id('client'), 'sign_in sets auth.uid()');
|
|
77
|
+
select is(current_user::text, 'authenticated', 'sign_in acts as authenticated');
|
|
78
|
+
select genesis_testing.sign_out();
|
|
79
|
+
select is((select auth.uid()), null, 'sign_out clears the user');
|
|
80
|
+
|
|
81
|
+
-- refuses ---------------------------------------------------------------------------------------
|
|
82
|
+
select genesis_testing.refuses(
|
|
83
|
+
$$update public.gt_notes set body = 'x' where id = '00000000-0000-4000-9000-000000000001'$$,
|
|
84
|
+
genesis_testing.user_id('stranger'), 'a stranger changes no row of the client''s note');
|
|
85
|
+
select genesis_testing.refuses(
|
|
86
|
+
$$delete from public.gt_notes where id = '00000000-0000-4000-9000-000000000001'$$,
|
|
87
|
+
genesis_testing.user_id('client'), 'the client can''t delete outright (42501)');
|
|
88
|
+
select is((genesis_testing.probe_refuses(
|
|
89
|
+
$$update public.gt_notes set body = 'x' where id = '00000000-0000-4000-9000-000000000001'$$,
|
|
90
|
+
genesis_testing.user_id('client')) ->> 'pass')::boolean, false,
|
|
91
|
+
'refuses fails when the user can make the change');
|
|
92
|
+
select is((select body from public.gt_notes where id = '00000000-0000-4000-9000-000000000001'), 'shared note',
|
|
93
|
+
'a probe leaves no change behind');
|
|
94
|
+
|
|
95
|
+
-- server_owned ----------------------------------------------------------------------------------
|
|
96
|
+
select genesis_testing.server_owned('public.gt_notes', 'id', '00000000-0000-4000-9000-000000000001',
|
|
97
|
+
array['status'], genesis_testing.user_id('client'));
|
|
98
|
+
select is((genesis_testing.probe_server_owned('public.gt_notes', 'id', '00000000-0000-4000-9000-000000000001',
|
|
99
|
+
'private', genesis_testing.user_id('client')) ->> 'pass')::boolean, false,
|
|
100
|
+
'server_owned catches a column the client can change');
|
|
101
|
+
select is((genesis_testing.probe_server_owned('public.gt_notes', 'id', '00000000-0000-4000-9000-000000000001',
|
|
102
|
+
'nope', genesis_testing.user_id('client')) ->> 'pass')::boolean, false, 'server_owned fails on a missing column');
|
|
103
|
+
select is((genesis_testing.probe_server_owned('public.gt_notes', 'id', '00000000-0000-4000-9000-000000000001',
|
|
104
|
+
'body', genesis_testing.user_id('stranger')) ->> 'pass')::boolean, true,
|
|
105
|
+
'a column RLS keeps the user away from passes too');
|
|
106
|
+
|
|
107
|
+
-- allows and the system paths ------------------------------------------------------------------
|
|
108
|
+
select genesis_testing.allows_system_paths(
|
|
109
|
+
$$delete from public.gt_notes where id = '00000000-0000-4000-9000-000000000001'$$,
|
|
110
|
+
array['gt.hard_delete=on'], genesis_testing.user_id('client'), 'the guard lets system deletes through');
|
|
111
|
+
select is((genesis_testing.probe_allows(
|
|
112
|
+
$$delete from public.gt_notes where id = '00000000-0000-4000-9000-000000000001'$$,
|
|
113
|
+
genesis_testing.user_id('client')) ->> 'pass')::boolean, false, 'allows fails when the guard refuses');
|
|
114
|
+
select genesis_testing.allows(
|
|
115
|
+
$$delete from public.gt_notes where id = '00000000-0000-4000-9000-000000000001'$$, null,
|
|
116
|
+
'ON DELETE SET NULL on a child doesn''t trip a guard', 1);
|
|
117
|
+
select is((genesis_testing.probe_allows(
|
|
118
|
+
$$update public.gt_notes set body = body where id = '00000000-0000-4000-9000-000000000001'$$,
|
|
119
|
+
genesis_testing.user_id('client'), 2) ->> 'pass')::boolean, false, 'allows checks the minimum rows changed');
|
|
120
|
+
|
|
121
|
+
select genesis_testing.allows_system_paths(
|
|
122
|
+
$$select 1 where coalesce(current_setting('gt.mode', true), 'a=b') = 'a=b'$$,
|
|
123
|
+
array['gt.mode=a=b'], genesis_testing.user_id('client'), 'a setting value may contain =');
|
|
124
|
+
|
|
125
|
+
-- never_shows and sees ---------------------------------------------------------------------------
|
|
126
|
+
select genesis_testing.never_shows('select * from public.gt_report_good()', 'SECRET-DIARY',
|
|
127
|
+
genesis_testing.user_id('owner'), 'the good report hides private notes');
|
|
128
|
+
select is((genesis_testing.probe_never_shows('select * from public.gt_report_leaky()', 'SECRET-DIARY',
|
|
129
|
+
genesis_testing.user_id('owner')) ->> 'pass')::boolean, false, 'never_shows catches a leaky report');
|
|
130
|
+
select is((genesis_testing.probe_never_shows($$select 'say "hi"' as v$$, 'say "hi"',
|
|
131
|
+
genesis_testing.user_id('owner')) ->> 'pass')::boolean, false, 'never_shows finds a marker JSON escapes');
|
|
132
|
+
select genesis_testing.never_shows_anon('select * from public.gt_notes', 'SECRET-DIARY',
|
|
133
|
+
'anon sees no notes (RLS, no anon policy)');
|
|
134
|
+
select genesis_testing.sees('select * from public.gt_notes', genesis_testing.user_id('client'), 2,
|
|
135
|
+
'the client sees their two notes');
|
|
136
|
+
select is((genesis_testing.probe_sees('select * from public.gt_notes', genesis_testing.user_id('stranger'), 1)
|
|
137
|
+
->> 'pass')::boolean, false, 'sees fails on the wrong count');
|
|
138
|
+
|
|
139
|
+
-- No false passes ---------------------------------------------------------------------------------
|
|
140
|
+
select is((genesis_testing.probe_refuses(
|
|
141
|
+
$$update public.gt_notes set body = 'x' where id = '00000000-0000-4000-9000-0000000000ff'$$,
|
|
142
|
+
genesis_testing.user_id('stranger')) ->> 'pass')::boolean, false,
|
|
143
|
+
'refuses fails when the control can''t change a row either (wrong id)');
|
|
144
|
+
select is((genesis_testing.probe_refuses(
|
|
145
|
+
$$update public.gt_notse set body = 'x'$$, genesis_testing.user_id('stranger')) ->> 'pass')::boolean, false,
|
|
146
|
+
'refuses fails on an error that isn''t a refusal (a typo)');
|
|
147
|
+
select is((genesis_testing.probe_refuses(
|
|
148
|
+
$$update public.gt_notes set body = 'x' where id = '00000000-0000-4000-9000-000000000001'$$,
|
|
149
|
+
genesis_testing.user_id('stranger'), genesis_testing.user_id('owner')) ->> 'pass')::boolean, false,
|
|
150
|
+
'refuses fails when the named control can''t make the change either');
|
|
151
|
+
select is((genesis_testing.probe_never_shows('select * from public.gt_reprot_good()', 'SECRET-DIARY',
|
|
152
|
+
genesis_testing.user_id('owner')) ->> 'pass')::boolean, false, 'never_shows fails on a typo, not passes');
|
|
153
|
+
select is((genesis_testing.probe_server_owned('public.gt_notes', 'id', '00000000-0000-4000-9000-0000000000ff',
|
|
154
|
+
'status', genesis_testing.user_id('client')) ->> 'pass')::boolean, false,
|
|
155
|
+
'server_owned fails on a key that matches no row');
|
|
156
|
+
select is((genesis_testing.probe_server_owned('public.gt_notes', 'id', '00000000-0000-4000-9000-000000000001',
|
|
157
|
+
'mood', genesis_testing.user_id('client')) ->> 'pass')::boolean, false,
|
|
158
|
+
'server_owned handles enums: a writable enum column fails');
|
|
159
|
+
select is((genesis_testing.probe_server_owned('public.gt_notes', 'id', '00000000-0000-4000-9000-000000000001',
|
|
160
|
+
'tags', genesis_testing.user_id('client')) ->> 'detail'), 'pass a value for tags (type text[]) to try',
|
|
161
|
+
'server_owned asks for a value for an array column instead of passing');
|
|
162
|
+
select is((genesis_testing.probe_server_owned('public.gt_notes', 'id', '00000000-0000-4000-9000-000000000001',
|
|
163
|
+
'tags', genesis_testing.user_id('client'), $$array['x']$$) ->> 'pass')::boolean, false,
|
|
164
|
+
'with a value, a writable array column fails');
|
|
165
|
+
select is((genesis_testing.probe_server_owned('public.gt_notes', 'id', '00000000-0000-4000-9000-000000000001',
|
|
166
|
+
'stage', genesis_testing.user_id('client')) ->> 'pass')::boolean, false,
|
|
167
|
+
'server_owned fails when the tried value breaks a check (the control raises), not passes');
|
|
168
|
+
select is((genesis_testing.probe_server_owned('public.gt_notes', 'id', '00000000-0000-4000-9000-000000000001',
|
|
169
|
+
'stage', genesis_testing.user_id('client'), $$'sent'$$) ->> 'pass')::boolean, false,
|
|
170
|
+
'with a valid value, a writable checked column fails');
|
|
171
|
+
|
|
172
|
+
-- Privileges -------------------------------------------------------------------------------------
|
|
173
|
+
select ok(not has_function_privilege('authenticated', 'genesis_testing.run_as(text, uuid)', 'execute'),
|
|
174
|
+
'API roles can''t run statements through the kit');
|
|
175
|
+
select ok(not has_function_privilege('anon', 'genesis_testing.sign_in(uuid)', 'execute'),
|
|
176
|
+
'API roles can''t sign in as someone');
|
|
177
|
+
select ok(has_function_privilege('authenticated', 'genesis_testing.sign_out()', 'execute'),
|
|
178
|
+
'a test acting as a user can sign out');
|
|
179
|
+
|
|
180
|
+
select * from finish();
|
|
181
|
+
rollback;
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/blocks.ts"],"names":[],"mappings":";;;AAQA,IAAM,OAAA,GAAU,gCAAA;AAST,IAAM,eAAe,WAAA,CAAY;AAAA,EACtC,EAAA,EAAI,SAAA;AAAA,EACJ,SAAA,EAAW,CAAC,KAAA,EAAO,QAAQ,CAAA;AAAA,EAC3B,WAAA,EAAa;AAAA,IACX,UAAA,EAAY;AAAA,MACV,EAAE,OAAA,EAAS,OAAA,EAAS,IAAA,EAAM,8CAAA,EAAgD,MAAM,yDAAA,EAA0D;AAAA;AAAA,MAE1I,EAAE,OAAA,EAAS,OAAA,EAAS,IAAA,EAAM,oDAAA,EAAsD,MAAM,+DAAA;AAAgE,KACxJ;AAAA,IACA,OAAA,EAAS;AAAA,MACP,QAAA,EAAU;AAAA,QACR,EAAE,IAAA,EAAM,eAAA,EAAiB,QAAA,EAAU,CAAC,mBAAmB,CAAA,EAAG,MAAA,EAAQ,IAAA,EAAM,QAAA,EAAU,KAAA,EAAM;AAAA,QACxF,EAAE,IAAA,EAAM,SAAA,EAAW,QAAA,EAAU,CAAC,mBAAmB,CAAA,EAAG,MAAA,EAAQ,IAAA,EAAM,QAAA,EAAU,KAAA,EAAM;AAAA,QAClF,EAAE,IAAA,EAAM,MAAA,EAAQ,QAAA,EAAU,CAAC,mBAAmB,CAAA,EAAG,MAAA,EAAQ,IAAA,EAAM,QAAA,EAAU,KAAA;AAAM,OACjF;AAAA,MACA,KAAA,EAAO;AAAA,QACL,4GAAA;AAAA,QACA;AAAA;AACF;AACF;AAEJ,CAAC;AAQM,IAAM,sBAAsB,WAAA,CAAY;AAAA,EAC7C,EAAA,EAAI,iBAAA;AAAA,EACJ,WAAA,EAAa;AAAA,IACX,UAAA,EAAY,CAAC,EAAE,OAAA,EAAS,SAAS,IAAA,EAAM,6CAAA,EAA+C,IAAA,EAAM,wDAAA,EAA0D,CAAA;AAAA,IACtJ,OAAA,EAAS,EAAE,KAAA,EAAO,CAAC,6FAA6F,CAAA;AAAE;AAEtH,CAAC","file":"chunk-T2HGFC4C.js","sourcesContent":["/**\n * The blocks this package carries the server half of, as `defineBlock`\n * declarations: what each needs in the database, and what it means for the\n * stores' privacy forms. The functions themselves (`deleteMyAccount`,\n * `tenantFeatures`, …) don't change.\n */\nimport { defineBlock } from \"@marktiderman/genesis-features/blocks\";\n\nconst PACKAGE = \"@marktiderman/genesis-supabase\";\n\n/**\n * The account block: sign-in, profile and in-app account deletion. Its\n * screens are the app's (or genesis-ui-native's `AuthScreen`); this names its\n * migrations and its data.\n *\n * @stability Beta\n */\nexport const accountBlock = defineBlock({\n id: \"account\",\n platforms: [\"web\", \"native\"],\n contributes: {\n migrations: [\n { package: PACKAGE, file: \"migrations/0001_genesis_account_deletion.sql\", test: \"migrations/tests/0001_genesis_account_deletion.test.sql\" },\n // With Sign in with Apple, with the genesis-apple-revoke function beside it.\n { package: PACKAGE, file: \"migrations/0003_genesis_apple_token_revocation.sql\", test: \"migrations/tests/0003_genesis_apple_token_revocation.test.sql\" },\n ],\n privacy: {\n collects: [\n { type: \"email address\", purposes: [\"app functionality\"], linked: true, tracking: false },\n { type: \"user id\", purposes: [\"app functionality\"], linked: true, tracking: false },\n { type: \"name\", purposes: [\"app functionality\"], linked: true, tracking: false },\n ],\n notes: [\n \"People delete their account from inside the app; their login and the rows that cascade from it go with it.\",\n \"With Sign in with Apple, the person's Apple tokens are revoked when the account is deleted.\",\n ],\n },\n },\n});\n\n/**\n * The tenant features block: per-tenant on/off switches for shipped\n * features, set by the tenant's admins with a reason.\n *\n * @stability Beta\n */\nexport const tenantFeaturesBlock = defineBlock({\n id: \"tenant-features\",\n contributes: {\n migrations: [{ package: PACKAGE, file: \"migrations/0004_genesis_tenant_features.sql\", test: \"migrations/tests/0004_genesis_tenant_features.test.sql\" }],\n privacy: { notes: [\"Keeps who changed a tenant's switches; a deleted person's changes stay, without the person.\"] },\n },\n});\n"]}
|