@marktiderman/genesis-sites 0.0.0-stage → 0.2.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.
Files changed (43) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +465 -2
  3. package/dist/assets/form-island.c0f6fac859.js +51 -0
  4. package/dist/assets/islands.77d11f5697.js +1 -0
  5. package/dist/assets/sites-forms.4e6aa42497.css +10 -0
  6. package/dist/assets/sites-forms.css +10 -0
  7. package/dist/assets/sites.css +2 -0
  8. package/dist/assets/sites.e1ffd2708b.css +2 -0
  9. package/dist/blocks.d.ts +13 -0
  10. package/dist/blocks.js +26 -0
  11. package/dist/blocks.js.map +1 -0
  12. package/dist/chunk-3VKTBE7S.js +69 -0
  13. package/dist/chunk-3VKTBE7S.js.map +1 -0
  14. package/dist/chunk-C375ZPU2.js +2241 -0
  15. package/dist/chunk-C375ZPU2.js.map +1 -0
  16. package/dist/chunk-IXMY4B56.js +877 -0
  17. package/dist/chunk-IXMY4B56.js.map +1 -0
  18. package/dist/chunk-VZUDRPFG.js +13 -0
  19. package/dist/chunk-VZUDRPFG.js.map +1 -0
  20. package/dist/chunk-ZGWGQGT2.js +595 -0
  21. package/dist/chunk-ZGWGQGT2.js.map +1 -0
  22. package/dist/import.d.ts +130 -0
  23. package/dist/import.js +330 -0
  24. package/dist/import.js.map +1 -0
  25. package/dist/index.d.ts +273 -0
  26. package/dist/index.js +254 -0
  27. package/dist/index.js.map +1 -0
  28. package/dist/registry-D_DCWRTc.d.ts +1030 -0
  29. package/dist/sections.d.ts +802 -0
  30. package/dist/sections.js +6 -0
  31. package/dist/sections.js.map +1 -0
  32. package/dist/store-DkSycDhq.d.ts +443 -0
  33. package/dist/supabase.d.ts +41 -0
  34. package/dist/supabase.js +283 -0
  35. package/dist/supabase.js.map +1 -0
  36. package/dist/web.d.ts +136 -0
  37. package/dist/web.js +479 -0
  38. package/dist/web.js.map +1 -0
  39. package/migrations/0001_genesis_sites.sql +821 -0
  40. package/migrations/0002_genesis_sites_posts.sql +404 -0
  41. package/migrations/tests/0001_genesis_sites.test.sql +349 -0
  42. package/migrations/tests/0002_genesis_sites_posts.test.sql +242 -0
  43. package/package.json +152 -3
@@ -0,0 +1,349 @@
1
+ -- @feat:sites-store
2
+ -- pgTAP test for 0001_genesis_sites.sql.
3
+ -- Copy into your app's `supabase/tests/database/` and run `supabase test db`.
4
+ -- It writes only its own users, sites and hook versions, and rolls everything
5
+ -- back. If your app already replaced the hooks (genesis_sites.can_edit,
6
+ -- can_view), they are replaced again inside this transaction only.
7
+ --
8
+ -- Signing in: auth.uid() reads request.jwt.claims (and the legacy
9
+ -- request.jwt.claim.sub on older images), so pg_temp.sign_in sets both.
10
+ begin;
11
+ create extension if not exists pgtap with schema extensions;
12
+ select plan(79);
13
+
14
+ -- People: e1 edits site A, b2 may view site A, c3 is nobody's.
15
+ insert into auth.users (id, aud, role, email, instance_id)
16
+ select ('00000000-0000-4000-8000-0000000000' || n)::uuid, 'authenticated', 'authenticated', 'genesis-sites-' || n || '@example.test', '00000000-0000-0000-0000-000000000000'
17
+ from unnest(array['e1', 'b2', 'c3']) n;
18
+
19
+ create function pg_temp.person(n text) returns uuid language sql immutable as $$ select ('00000000-0000-4000-8000-0000000000' || n)::uuid $$;
20
+ create function pg_temp.sign_in(n text) returns void language plpgsql as $$
21
+ begin
22
+ perform set_config('request.jwt.claims', json_build_object('sub', pg_temp.person(n), 'role', 'authenticated')::text, true);
23
+ perform set_config('request.jwt.claim.sub', pg_temp.person(n)::text, true);
24
+ perform set_config('role', 'authenticated', true);
25
+ end $$;
26
+ create function pg_temp.as_anon() returns void language plpgsql as $$
27
+ begin
28
+ perform set_config('request.jwt.claims', '{"role":"anon"}', true);
29
+ perform set_config('request.jwt.claim.sub', '', true);
30
+ perform set_config('role', 'anon', true);
31
+ end $$;
32
+ create function pg_temp.page(title text) returns jsonb language sql immutable as $$
33
+ select jsonb_build_object('schemaVersion', 1, 'root', jsonb_build_object('props', jsonb_build_object('title', title)), 'content', '[]'::jsonb)
34
+ $$;
35
+ grant execute on function pg_temp.person(text), pg_temp.sign_in(text), pg_temp.as_anon(), pg_temp.page(text) to anon, authenticated, service_role;
36
+
37
+ -- A site to work on, made the way a host's server job makes one.
38
+ create temp table t (k text primary key, v uuid);
39
+ grant select on t to anon, authenticated;
40
+ grant insert on t to authenticated;
41
+ insert into t select 'a', genesis_sites.create_site('Riverside Gym', 'riverside-test',
42
+ '{"schemaVersion":1,"name":"Riverside Gym","redirects":[]}'::jsonb,
43
+ jsonb_build_array(jsonb_build_object('path', '/', 'doc', pg_temp.page('Home')), jsonb_build_object('path', '/about', 'doc', pg_temp.page('About'))));
44
+ insert into t select 'home', id from genesis_sites.site_pages where site_id = (select v from t where k = 'a') and path = '/';
45
+ insert into t select 'about', id from genesis_sites.site_pages where site_id = (select v from t where k = 'a') and path = '/about';
46
+ create function pg_temp.id(k text) returns uuid language sql stable as $$ select v from t where t.k = id.k $$;
47
+ grant execute on function pg_temp.id(text) to anon, authenticated;
48
+
49
+ -- Shape ---------------------------------------------------------------------------
50
+ select has_schema('genesis_sites', 'the genesis_sites schema exists');
51
+ select has_table('genesis_sites', 'sites', 'sites exists');
52
+ select has_table('genesis_sites', 'site_pages', 'site_pages exists');
53
+ select has_table('genesis_sites', 'site_releases', 'site_releases exists');
54
+ select has_table('genesis_sites', 'site_assets', 'site_assets exists');
55
+ select has_trigger('genesis_sites', 'sites', 'genesis_notify_served', 'a change to what a site serves notifies a cache purger');
56
+ select ok((select bool_and(relrowsecurity) from pg_class where oid in ('genesis_sites.sites'::regclass, 'genesis_sites.site_pages'::regclass, 'genesis_sites.site_releases'::regclass, 'genesis_sites.site_assets'::regclass)), 'RLS is on for every table');
57
+
58
+ -- Privileges ----------------------------------------------------------------------
59
+ select ok(
60
+ not has_table_privilege('anon', 'genesis_sites.sites', 'select,insert,update,delete')
61
+ and not has_table_privilege('anon', 'genesis_sites.site_pages', 'select,insert,update,delete')
62
+ and not has_table_privilege('anon', 'genesis_sites.site_releases', 'select,insert,update,delete')
63
+ and not has_table_privilege('anon', 'genesis_sites.site_assets', 'select,insert,update,delete'),
64
+ 'anon has no privilege on any table'
65
+ );
66
+ select ok(
67
+ not has_table_privilege('authenticated', 'genesis_sites.sites', 'insert,update,delete')
68
+ and not has_table_privilege('authenticated', 'genesis_sites.site_pages', 'insert,update,delete')
69
+ and not has_table_privilege('authenticated', 'genesis_sites.site_releases', 'insert,update,delete'),
70
+ 'authenticated writes sites, pages and releases only through the functions'
71
+ );
72
+ select ok(not has_table_privilege('service_role', 'genesis_sites.site_releases', 'update'), 'nobody rewrites a release, not even the service role');
73
+ select ok(has_function_privilege('anon', 'genesis_sites.live_release(uuid)', 'execute'), 'anon may read a live release');
74
+ select ok(
75
+ not has_function_privilege('anon', 'genesis_sites.save_page_draft(uuid, integer, jsonb)', 'execute')
76
+ and not has_function_privilege('anon', 'genesis_sites.publish_site(uuid, integer, jsonb, text, text)', 'execute')
77
+ and not has_function_privilege('anon', 'genesis_sites.rollback_site(uuid, uuid)', 'execute')
78
+ and not has_function_privilege('anon', 'genesis_sites.can_edit(uuid)', 'execute'),
79
+ 'anon may call no write function and no hook'
80
+ );
81
+ select ok(
82
+ not has_function_privilege('authenticated', 'genesis_sites.create_site(text, text, jsonb, jsonb)', 'execute')
83
+ and has_function_privilege('service_role', 'genesis_sites.create_site(text, text, jsonb, jsonb)', 'execute'),
84
+ 'only the service role creates sites'
85
+ );
86
+ select ok(
87
+ not has_function_privilege('authenticated', 'genesis_sites.ensure_hook_stubs()', 'execute')
88
+ and not has_function_privilege('authenticated', 'genesis_sites.assert_can_edit(uuid)', 'execute')
89
+ and not has_function_privilege('authenticated', 'genesis_sites.drop_redirect_from(uuid, text)', 'execute'),
90
+ 'the internal helpers are granted to nobody'
91
+ );
92
+ select ok(
93
+ not exists (
94
+ select 1 from pg_proc p join pg_namespace n on n.oid = p.pronamespace, aclexplode(coalesce(p.proacl, acldefault('f', p.proowner))) a
95
+ where n.nspname = 'genesis_sites' and a.grantee = 0
96
+ ),
97
+ 'public may execute no function in the schema'
98
+ );
99
+
100
+ -- The stubs fail closed -----------------------------------------------------------
101
+ select pg_temp.sign_in('e1');
102
+ select is(genesis_sites.can_edit(pg_temp.id('a')), false, 'the can_edit stub answers false');
103
+ select is(genesis_sites.can_view(pg_temp.id('a')), false, 'the can_view stub asks can_edit');
104
+ select throws_ok(
105
+ format('select genesis_sites.save_page_draft(%L, 1, %L)', pg_temp.id('home'), pg_temp.page('Mine')),
106
+ '42501', null, 'with the stub, nobody saves a draft'
107
+ );
108
+ select is((select count(*)::int from genesis_sites.sites), 0, 'with the stub, nobody reads a site');
109
+ reset role;
110
+
111
+ -- The app's own hooks: an overridden can_edit is respected ------------------------
112
+ create table public.genesis_test_site_roles (site_id uuid, user_id uuid, role text);
113
+ insert into public.genesis_test_site_roles values (pg_temp.id('a'), pg_temp.person('e1'), 'editor'), (pg_temp.id('a'), pg_temp.person('b2'), 'viewer');
114
+ create or replace function genesis_sites.can_edit(site_id uuid) returns boolean
115
+ language sql stable security definer set search_path = '' as $$
116
+ select exists (select 1 from public.genesis_test_site_roles r where r.site_id = can_edit.site_id and r.user_id = auth.uid() and r.role = 'editor')
117
+ $$;
118
+ create or replace function genesis_sites.can_view(site_id uuid) returns boolean
119
+ language sql stable security definer set search_path = '' as $$
120
+ select exists (select 1 from public.genesis_test_site_roles r where r.site_id = can_view.site_id and r.user_id = auth.uid())
121
+ $$;
122
+ select ok(has_function_privilege('authenticated', 'genesis_sites.can_edit(uuid)', 'execute'), 'create or replace keeps the hook''s grant');
123
+ select genesis_sites.ensure_hook_stubs();
124
+ select ok(
125
+ (select prosrc from pg_proc where oid = 'genesis_sites.can_edit(uuid)'::regprocedure) like '%genesis_test_site_roles%',
126
+ 'running the migration''s stub step again leaves the app''s can_edit alone'
127
+ );
128
+ select ok(
129
+ (select prosrc from pg_proc where oid = 'genesis_sites.can_view(uuid)'::regprocedure) like '%genesis_test_site_roles%',
130
+ '... and its can_view'
131
+ );
132
+
133
+ -- Drafts: compare-and-swap ---------------------------------------------------------
134
+ select pg_temp.sign_in('e1');
135
+ select is(genesis_sites.save_page_draft(pg_temp.id('home'), 1, pg_temp.page('Welcome')), 2, 'an editor saves a draft and gets the next revision');
136
+ select throws_ok(
137
+ format('select genesis_sites.save_page_draft(%L, 1, %L)', pg_temp.id('home'), pg_temp.page('Stale')),
138
+ 'GS409', 'genesis_sites: someone saved this page first', 'a save from an old revision is a conflict'
139
+ );
140
+ select is((select draft #>> '{root,props,title}' from genesis_sites.site_pages where id = pg_temp.id('home')), 'Welcome', '... and changes nothing');
141
+ select throws_ok(
142
+ format('select genesis_sites.save_page_draft(%L, 2, %L)', pg_temp.id('home'), '{"schemaVersion":2}'),
143
+ '22023', null, 'a document that isn''t a page is refused'
144
+ );
145
+ select throws_ok(
146
+ format('select genesis_sites.save_page_draft(%L, 2, %L::jsonb)', pg_temp.id('home'), jsonb_build_object('schemaVersion', 1, 'root', jsonb_build_object('props', jsonb_build_object('title', repeat('x', 600000))), 'content', '[]'::jsonb)),
147
+ '22023', null, 'a page over 512 KiB is refused'
148
+ );
149
+ select is((select count(*)::int from genesis_sites.site_pages), 2, 'an editor reads the site''s pages');
150
+
151
+ -- Paths ------------------------------------------------------------------------------
152
+ select throws_ok(format('select genesis_sites.create_page(%L, %L, %L, %L)', pg_temp.id('a'), '/api/hooks', 'page', pg_temp.page('X')), '22023', null, 'a reserved path is refused');
153
+ select throws_ok(format('select genesis_sites.create_page(%L, %L, %L, %L)', pg_temp.id('a'), '/auth', 'page', pg_temp.page('X')), '22023', null, '... the reserved prefix itself too');
154
+ select throws_ok(format('select genesis_sites.create_page(%L, %L, %L, %L)', pg_temp.id('a'), '/About/', 'page', pg_temp.page('X')), '22023', null, 'a path with capitals or a trailing slash is refused');
155
+ select throws_ok(format('select genesis_sites.create_page(%L, %L, %L, %L)', pg_temp.id('a'), '/about', 'page', pg_temp.page('X')), '23505', null, 'two pages can''t share a path');
156
+ select lives_ok(format('select genesis_sites.create_page(%L, %L, %L, %L)', pg_temp.id('a'), '/apiary', 'page', pg_temp.page('Bees')), 'a path that only starts like a reserved one is fine');
157
+ insert into t select 'apiary', id from genesis_sites.site_pages where site_id = pg_temp.id('a') and path = '/apiary';
158
+ reset role;
159
+
160
+ -- The app reserves a path later: its own reserved_paths(), which the stub step leaves alone.
161
+ create or replace function genesis_sites.reserved_paths() returns text[] language sql stable set search_path = '' as $$
162
+ select array['/api', '/_server', '/auth', '/_gs', '/apiary']::text[]
163
+ $$;
164
+ select genesis_sites.ensure_hook_stubs();
165
+ select ok(
166
+ (select prosrc from pg_proc where oid = 'genesis_sites.reserved_paths()'::regprocedure) like '%/apiary%',
167
+ 'running the stub step again leaves the app''s reserved_paths alone'
168
+ );
169
+ select pg_temp.sign_in('e1');
170
+ select throws_ok(
171
+ format('select genesis_sites.save_page_draft(%L, 1, %L)', pg_temp.id('apiary'), pg_temp.page('Bees')),
172
+ '22023', null, 'a draft save checks the page''s path against the app''s reserved paths again'
173
+ );
174
+ reset role;
175
+
176
+ -- A hook the stub step has to create again gets its grants by name, never Postgres's default.
177
+ drop function genesis_sites.reserved_paths();
178
+ select genesis_sites.ensure_hook_stubs();
179
+ select ok(
180
+ not has_function_privilege('anon', 'genesis_sites.reserved_paths()', 'execute')
181
+ and has_function_privilege('authenticated', 'genesis_sites.reserved_paths()', 'execute')
182
+ and not exists (
183
+ select 1 from pg_proc p, aclexplode(coalesce(p.proacl, acldefault('f', p.proowner))) a
184
+ where p.oid = 'genesis_sites.reserved_paths()'::regprocedure and a.grantee = 0
185
+ ),
186
+ 'a re-created hook is granted to authenticated by name, not to public or anon'
187
+ );
188
+ select pg_temp.sign_in('e1');
189
+ select lives_ok(
190
+ format('select genesis_sites.save_page_draft(%L, 1, %L)', pg_temp.id('apiary'), pg_temp.page('Bees')),
191
+ '... and with the default list back, the page saves again'
192
+ );
193
+
194
+ -- Moving a page leaves a redirect ------------------------------------------------------
195
+ select lives_ok(format('select genesis_sites.move_page(%L, %L)', pg_temp.id('about'), '/our-story'), 'an editor moves a page');
196
+ select is(
197
+ (select draft -> 'redirects' from genesis_sites.sites where id = pg_temp.id('a')),
198
+ '[{"to": "/our-story", "from": "/about", "status": 301}]'::jsonb,
199
+ 'the old path redirects to the new one'
200
+ );
201
+ select lives_ok(format('select genesis_sites.move_page(%L, %L)', pg_temp.id('about'), '/story'), 'and moves it again');
202
+ select is(
203
+ (select draft -> 'redirects' from genesis_sites.sites where id = pg_temp.id('a')),
204
+ '[{"to": "/story", "from": "/about", "status": 301}, {"to": "/story", "from": "/our-story", "status": 301}]'::jsonb,
205
+ 'earlier redirects point straight at the new path: no chains'
206
+ );
207
+ select lives_ok(format('select genesis_sites.move_page(%L, %L)', pg_temp.id('about'), '/about'), 'moving it back');
208
+ select is(
209
+ (select draft -> 'redirects' from genesis_sites.sites where id = pg_temp.id('a')),
210
+ '[{"to": "/about", "from": "/our-story", "status": 301}, {"to": "/about", "from": "/story", "status": 301}]'::jsonb,
211
+ '... drops the redirect away from the page''s path'
212
+ );
213
+ select throws_ok(format('select genesis_sites.move_page(%L, %L)', pg_temp.id('home'), '/home'), '22023', null, 'the home page stays at /');
214
+ select throws_ok(format('select genesis_sites.delete_page(%L)', pg_temp.id('home')), '22023', null, 'the home page can''t be deleted');
215
+
216
+ -- Publishing -------------------------------------------------------------------------
217
+ create function pg_temp.revs() returns jsonb language sql stable as $$
218
+ select jsonb_object_agg(id::text, draft_rev) from genesis_sites.site_pages where site_id = pg_temp.id('a') and deleted_at is null
219
+ $$;
220
+ create function pg_temp.site_rev() returns integer language sql stable as $$ select draft_rev from genesis_sites.sites where id = pg_temp.id('a') $$;
221
+ grant execute on function pg_temp.revs(), pg_temp.site_rev() to authenticated;
222
+
223
+ select throws_ok(
224
+ format('select genesis_sites.publish_site(%L, %s, %L)', pg_temp.id('a'), pg_temp.site_rev() - 1, pg_temp.revs()),
225
+ 'GS409', null, 'a publish of an older site draft than was checked is refused'
226
+ );
227
+ select throws_ok(
228
+ format('select genesis_sites.publish_site(%L, %s, %L)', pg_temp.id('a'), pg_temp.site_rev(), '{}'),
229
+ 'GS409', null, 'a publish that missed a page is refused'
230
+ );
231
+ select is(
232
+ (genesis_sites.publish_site(pg_temp.id('a'), pg_temp.site_rev(), pg_temp.revs(), 'First release', 'abc123')) ->> 'number',
233
+ '1',
234
+ 'an editor publishes release 1'
235
+ );
236
+ insert into t select 'r1', id from genesis_sites.site_releases where site_id = pg_temp.id('a') and number = 1;
237
+ select is((select status from genesis_sites.sites where id = pg_temp.id('a')), 'live', 'the site is live');
238
+ select is((select created_by from genesis_sites.site_releases where id = pg_temp.id('r1')), pg_temp.person('e1'), 'the release records who published it');
239
+ select throws_ok(format('update genesis_sites.site_releases set note = %L where id = %L', 'changed', pg_temp.id('r1')), '42501', null, 'a release can''t be edited');
240
+
241
+ -- Drafts keep changing; the live site doesn't.
242
+ select genesis_sites.save_page_draft(pg_temp.id('home'), (select draft_rev from genesis_sites.site_pages where id = pg_temp.id('home')), pg_temp.page('Draft only'));
243
+ reset role;
244
+
245
+ -- The public read --------------------------------------------------------------------
246
+ select pg_temp.as_anon();
247
+ select is((genesis_sites.live_release(pg_temp.id('a'))) ->> 'number', '1', 'anon reads the live release with no session');
248
+ select is((genesis_sites.live_release(pg_temp.id('a'))) #>> '{snapshot,pages,0,doc,root,props,title}', 'Welcome', '... which is what was published');
249
+ select ok((genesis_sites.live_release(pg_temp.id('a')))::text not like '%Draft only%', '... never the draft saved since');
250
+ select is((genesis_sites.live_release(pg_temp.id('a'))) ->> 'note', null, '... and not the editors'' release note');
251
+ select throws_ok('select * from genesis_sites.site_pages', '42501', null, 'anon can''t read drafts');
252
+ select throws_ok('select * from genesis_sites.site_releases', '42501', null, 'anon can''t list releases');
253
+ select throws_ok(format('select genesis_sites.save_page_draft(%L, 1, %L)', pg_temp.id('home'), pg_temp.page('X')), '42501', null, 'anon can''t save');
254
+ reset role;
255
+
256
+ -- Viewers and outsiders --------------------------------------------------------------
257
+ select pg_temp.sign_in('b2');
258
+ select is((select count(*)::int from genesis_sites.site_pages), 3, 'a viewer (the app''s can_view) reads the drafts');
259
+ select throws_ok(format('select genesis_sites.save_page_draft(%L, 99, %L)', pg_temp.id('home'), pg_temp.page('X')), '42501', null, '... but can''t save');
260
+ select throws_ok(format('select genesis_sites.rollback_site(%L, %L)', pg_temp.id('a'), pg_temp.id('r1')), '42501', null, '... or roll back');
261
+ reset role;
262
+ select pg_temp.sign_in('c3');
263
+ select is((select count(*)::int from genesis_sites.sites) + (select count(*)::int from genesis_sites.site_releases), 0, 'an outsider reads no site and no release');
264
+ select throws_ok(format('select genesis_sites.publish_site(%L, 1, %L)', pg_temp.id('a'), '{}'), '42501', null, 'an outsider can''t publish');
265
+ select throws_ok(
266
+ format('select genesis_sites.save_page_draft(%L, 1, %L)', gen_random_uuid(), pg_temp.page('X')),
267
+ '42501', null, 'a page that doesn''t exist looks the same as one you can''t edit'
268
+ );
269
+ select throws_ok(
270
+ format('insert into genesis_sites.site_assets (site_id, path, mime) values (%L, %L, %L)', pg_temp.id('a'), 'sites/' || pg_temp.id('a') || '/x.png', 'image/png'),
271
+ '42501', null, 'an outsider can''t record an upload'
272
+ );
273
+ reset role;
274
+
275
+ -- Rollback ---------------------------------------------------------------------------
276
+ select pg_temp.sign_in('e1');
277
+ select genesis_sites.save_page_draft(pg_temp.id('home'), (select draft_rev from genesis_sites.site_pages where id = pg_temp.id('home')),
278
+ jsonb_build_object('schemaVersion', 1, 'root', jsonb_build_object('props', jsonb_build_object('title', 'Welcome')),
279
+ 'content', jsonb_build_array(jsonb_build_object('id', 'secret', 'type', 'hero', 'v', 1, 'hidden', true, 'props', jsonb_build_object('heading', 'Hidden plans')))));
280
+ select is((genesis_sites.publish_site(pg_temp.id('a'), pg_temp.site_rev(), pg_temp.revs())) ->> 'number', '2', 'a second publish is release 2');
281
+ select ok(
282
+ (select snapshot::text from genesis_sites.site_releases where site_id = pg_temp.id('a') and number = 2) not like '%Hidden plans%',
283
+ 'a hidden section is left out of the release, so it is never public'
284
+ );
285
+ select is((genesis_sites.rollback_site(pg_temp.id('a'), pg_temp.id('r1'))) ->> 'number', '1', 'rolling back points the site at release 1');
286
+ reset role;
287
+ select pg_temp.as_anon();
288
+ select is((genesis_sites.live_release(pg_temp.id('a'))) #>> '{snapshot,pages,0,doc,root,props,title}', 'Welcome', 'the public read serves release 1 again');
289
+ reset role;
290
+
291
+ -- Assets -----------------------------------------------------------------------------
292
+ select pg_temp.sign_in('e1');
293
+ select lives_ok(
294
+ format('insert into genesis_sites.site_assets (site_id, path, mime, alt) values (%L, %L, %L, %L)', pg_temp.id('a'), 'sites/' || pg_temp.id('a') || '/hero.jpg', 'image/jpeg', 'The gym floor'),
295
+ 'an editor records an upload under sites/<site_id>/'
296
+ );
297
+ select throws_ok(
298
+ format('insert into genesis_sites.site_assets (site_id, path, mime) values (%L, %L, %L)', pg_temp.id('a'), 'sites/other/x.png', 'image/png'),
299
+ '23514', null, 'an asset outside its site''s folder is refused'
300
+ );
301
+ select throws_ok(
302
+ format('insert into genesis_sites.site_assets (site_id, path, mime) values (%L, %L, %L)', pg_temp.id('a'), 'sites/' || pg_temp.id('a') || '/x.svg', 'image/svg+xml'),
303
+ '23514', null, 'an SVG is refused'
304
+ );
305
+ reset role;
306
+
307
+ -- Archived sites serve nothing ---------------------------------------------------------
308
+ update genesis_sites.sites set status = 'archived' where id = pg_temp.id('a');
309
+ select pg_temp.as_anon();
310
+ select is(genesis_sites.live_release(pg_temp.id('a')), null, 'an archived site has no public release');
311
+ reset role;
312
+ select pg_temp.sign_in('e1');
313
+ select throws_ok(
314
+ format('select genesis_sites.rollback_site(%L, %L)', pg_temp.id('a'), pg_temp.id('r1')),
315
+ '22023', null, 'an editor can''t bring an archived site back by rolling back'
316
+ );
317
+ reset role;
318
+
319
+ reset role;
320
+ select pg_temp.sign_in('e1');
321
+ -- A redirect can't start on one of the app's paths: it would take over the app's own route.
322
+ create function pg_temp.site_with_redirect(from_path text) returns jsonb language sql immutable as $$
323
+ select jsonb_build_object('schemaVersion', 1, 'name', 'Riverside Gym', 'redirects', jsonb_build_array(jsonb_build_object('from', from_path, 'to', '/', 'status', 301)))
324
+ $$;
325
+ grant execute on function pg_temp.site_with_redirect(text) to authenticated;
326
+ select throws_like(
327
+ format('select genesis_sites.save_site_draft(%L, (select draft_rev from genesis_sites.sites where id = %L), %L)', pg_temp.id('a'), pg_temp.id('a'), pg_temp.site_with_redirect('/auth/sign-in')),
328
+ '%reserved for the app%', 'a redirect from a reserved path is refused'
329
+ );
330
+ select throws_like(
331
+ format('select genesis_sites.save_site_draft(%L, (select draft_rev from genesis_sites.sites where id = %L), %L)', pg_temp.id('a'), pg_temp.id('a'), pg_temp.site_with_redirect('/API?x=1')),
332
+ '%reserved for the app%', '... in any case, and with a query string'
333
+ );
334
+ select lives_ok(
335
+ format('select genesis_sites.save_site_draft(%L, (select draft_rev from genesis_sites.sites where id = %L), %L)', pg_temp.id('a'), pg_temp.id('a'), pg_temp.site_with_redirect('/Old_Page.html')),
336
+ 'a redirect from an old address that isn''t reserved is fine'
337
+ );
338
+ reset role;
339
+
340
+ -- A host that reserves "/" itself reserves the root only (as isReservedPath does), not every path.
341
+ reset role;
342
+ create or replace function genesis_sites.reserved_paths() returns text[] language sql stable set search_path = '' as $$
343
+ select array['/']::text[]
344
+ $$;
345
+ select throws_like('select genesis_sites.assert_not_reserved(''/?x=1'')', '%reserved for the app%', 'a reserved root refuses the root, with a query string too');
346
+ select lives_ok('select genesis_sites.assert_not_reserved(''/about'')', '... and leaves other paths alone');
347
+
348
+ select * from finish();
349
+ rollback;
@@ -0,0 +1,242 @@
1
+ -- @feat:sites-blog
2
+ -- pgTAP test for 0002_genesis_sites_posts.sql (after 0001).
3
+ -- Copy into your app's `supabase/tests/database/` and run `supabase test db`.
4
+ -- It writes only its own users, sites and hook versions, and rolls everything
5
+ -- back. If your app already replaced the hooks (genesis_sites.can_edit,
6
+ -- can_view), they are replaced again inside this transaction only.
7
+ begin;
8
+ create extension if not exists pgtap with schema extensions;
9
+ select plan(51);
10
+
11
+ -- People: e1 edits site A, b2 may view it, c3 is nobody's.
12
+ insert into auth.users (id, aud, role, email, instance_id)
13
+ select ('00000000-0000-4000-8000-0000000002' || n)::uuid, 'authenticated', 'authenticated', 'genesis-sites-posts-' || n || '@example.test', '00000000-0000-0000-0000-000000000000'
14
+ from unnest(array['e1', 'b2', 'c3']) n;
15
+
16
+ create function pg_temp.person(n text) returns uuid language sql immutable as $$ select ('00000000-0000-4000-8000-0000000002' || n)::uuid $$;
17
+ create function pg_temp.sign_in(n text) returns void language plpgsql as $$
18
+ begin
19
+ perform set_config('request.jwt.claims', json_build_object('sub', pg_temp.person(n), 'role', 'authenticated')::text, true);
20
+ perform set_config('request.jwt.claim.sub', pg_temp.person(n)::text, true);
21
+ perform set_config('role', 'authenticated', true);
22
+ end $$;
23
+ create function pg_temp.as_anon() returns void language plpgsql as $$
24
+ begin
25
+ perform set_config('request.jwt.claims', '{"role":"anon"}', true);
26
+ perform set_config('request.jwt.claim.sub', '', true);
27
+ perform set_config('role', 'anon', true);
28
+ end $$;
29
+ create function pg_temp.page(title text) returns jsonb language sql immutable as $$
30
+ select jsonb_build_object('schemaVersion', 1, 'root', jsonb_build_object('props', jsonb_build_object('title', title)), 'content', '[]'::jsonb)
31
+ $$;
32
+ create function pg_temp.post(title text) returns jsonb language sql immutable as $$
33
+ select jsonb_build_object('schemaVersion', 1, 'title', title, 'excerpt', 'About ' || title,
34
+ 'body', jsonb_build_object('type', 'doc', 'content', jsonb_build_array(jsonb_build_object('type', 'paragraph', 'content', jsonb_build_array(jsonb_build_object('type', 'text', 'text', title || ' body'))))))
35
+ $$;
36
+ grant execute on function pg_temp.person(text), pg_temp.sign_in(text), pg_temp.as_anon(), pg_temp.page(text), pg_temp.post(text) to anon, authenticated, service_role;
37
+
38
+ create temp table t (k text primary key, v uuid);
39
+ grant select, insert on t to anon, authenticated;
40
+ insert into t select 'a', genesis_sites.create_site('Hilltop Fitness', 'hilltop-posts-test',
41
+ '{"schemaVersion":1,"name":"Hilltop Fitness","redirects":[]}'::jsonb,
42
+ jsonb_build_array(jsonb_build_object('path', '/', 'doc', pg_temp.page('Home'))));
43
+ create function pg_temp.id(k text) returns uuid language sql stable as $$ select v from t where t.k = id.k $$;
44
+ grant execute on function pg_temp.id(text) to anon, authenticated;
45
+
46
+ -- The app's hooks: e1 edits site A, b2 views it.
47
+ create table public.genesis_test_post_roles (site_id uuid, user_id uuid, role text);
48
+ insert into public.genesis_test_post_roles values (pg_temp.id('a'), pg_temp.person('e1'), 'editor'), (pg_temp.id('a'), pg_temp.person('b2'), 'viewer');
49
+ create or replace function genesis_sites.can_edit(site_id uuid) returns boolean
50
+ language sql stable security definer set search_path = '' as $$
51
+ select exists (select 1 from public.genesis_test_post_roles r where r.site_id = can_edit.site_id and r.user_id = auth.uid() and r.role = 'editor')
52
+ $$;
53
+ create or replace function genesis_sites.can_view(site_id uuid) returns boolean
54
+ language sql stable security definer set search_path = '' as $$
55
+ select exists (select 1 from public.genesis_test_post_roles r where r.site_id = can_view.site_id and r.user_id = auth.uid())
56
+ $$;
57
+
58
+ -- Shape ---------------------------------------------------------------------------
59
+ select has_table('genesis_sites', 'site_posts', 'site_posts exists');
60
+ select ok((select relrowsecurity from pg_class where oid = 'genesis_sites.site_posts'::regclass), 'RLS is on for site_posts');
61
+ select has_trigger('genesis_sites', 'site_posts', 'genesis_notify_post', 'a change to what a post serves notifies a cache purger');
62
+ select ok(
63
+ not has_table_privilege('anon', 'genesis_sites.site_posts', 'select,insert,update,delete')
64
+ and not has_table_privilege('authenticated', 'genesis_sites.site_posts', 'insert,update,delete'),
65
+ 'anon has no privilege on site_posts, and authenticated writes only through the functions'
66
+ );
67
+ select ok(
68
+ has_function_privilege('anon', 'genesis_sites.live_posts(uuid)', 'execute')
69
+ and has_function_privilege('anon', 'genesis_sites.live_post(uuid, text)', 'execute'),
70
+ 'anon may read published posts'
71
+ );
72
+ select ok(
73
+ not has_function_privilege('anon', 'genesis_sites.save_post(uuid, uuid, text, integer, jsonb)', 'execute')
74
+ and not has_function_privilege('anon', 'genesis_sites.publish_post(uuid, integer)', 'execute')
75
+ and not has_function_privilege('anon', 'genesis_sites.import_posts(uuid, jsonb)', 'execute')
76
+ and not has_function_privilege('authenticated', 'genesis_sites.assert_post(text, jsonb)', 'execute'),
77
+ 'anon calls no post write, and the helpers are granted to nobody'
78
+ );
79
+ select ok(
80
+ not exists (
81
+ select 1 from pg_proc p join pg_namespace n on n.oid = p.pronamespace, aclexplode(coalesce(p.proacl, acldefault('f', p.proowner))) a
82
+ where n.nspname = 'genesis_sites' and a.grantee = 0
83
+ ),
84
+ 'public may execute no function in the schema'
85
+ );
86
+ select ok(
87
+ (select prosrc from pg_proc where oid = 'genesis_sites.can_edit(uuid)'::regprocedure) like '%genesis_test_post_roles%',
88
+ 'the migration left the app''s can_edit alone'
89
+ );
90
+
91
+ -- The post page --------------------------------------------------------------------
92
+ select pg_temp.sign_in('e1');
93
+ select lives_ok(format('select genesis_sites.create_page(%L, %L, %L, %L)', pg_temp.id('a'), '/blog', 'post', pg_temp.page('Blog')), 'an editor makes the post page');
94
+ select throws_ok(format('select genesis_sites.create_page(%L, %L, %L, %L)', pg_temp.id('a'), '/news', 'post', pg_temp.page('News')), '23505', null, 'a site has one post page');
95
+ select throws_ok(format('select genesis_sites.create_page(%L, %L, %L, %L)', pg_temp.id('a'), '/kinds', 'feed', pg_temp.page('X')), '23514', null, 'an unknown page kind is refused');
96
+ reset role;
97
+ select throws_ok(
98
+ format('update genesis_sites.site_pages set path = %L where site_id = %L and kind = %L', '/', pg_temp.id('a'), 'post'),
99
+ '23514', null, 'the post page is never the home page'
100
+ );
101
+
102
+ -- Drafts ---------------------------------------------------------------------------
103
+ select pg_temp.sign_in('e1');
104
+ insert into t select 'p1', (genesis_sites.save_post(pg_temp.id('a'), null, 'first-post', null, pg_temp.post('First post')) ->> 'id')::uuid;
105
+ select is((select status from genesis_sites.site_posts where id = pg_temp.id('p1')), 'draft', 'a new post is a draft');
106
+ select throws_ok(
107
+ format('select genesis_sites.save_post(%L, null, %L, null, %L)', pg_temp.id('a'), 'first-post', pg_temp.post('Again')),
108
+ '23505', null, 'two posts can''t share a slug'
109
+ );
110
+ select throws_ok(
111
+ format('select genesis_sites.save_post(%L, null, %L, null, %L)', pg_temp.id('a'), 'Bad Slug', pg_temp.post('X')),
112
+ '22023', null, 'a slug with capitals or spaces is refused'
113
+ );
114
+ select throws_ok(
115
+ format('select genesis_sites.save_post(%L, null, %L, null, %L)', pg_temp.id('a'), 'no-body', '{"schemaVersion":1,"title":"No body"}'),
116
+ '22023', null, 'a post with no rich text body is refused'
117
+ );
118
+ select is((genesis_sites.save_post(pg_temp.id('a'), pg_temp.id('p1'), 'first-post', 1, pg_temp.post('First post, edited')) ->> 'rev')::int, 2, 'a save gets the next revision');
119
+ select throws_ok(
120
+ format('select genesis_sites.save_post(%L, %L, %L, 1, %L)', pg_temp.id('a'), pg_temp.id('p1'), 'first-post', pg_temp.post('Stale')),
121
+ 'GS409', 'genesis_sites: someone saved this post first', 'a save from an old revision is a conflict'
122
+ );
123
+ select lives_ok(
124
+ format('select genesis_sites.save_post(%L, %L, %L, 2, %L)', pg_temp.id('a'), pg_temp.id('p1'), 'first-steps', pg_temp.post('First post, edited')),
125
+ 'a draft that was never published may change its slug'
126
+ );
127
+
128
+ -- Publishing -----------------------------------------------------------------------
129
+ select throws_ok(format('select genesis_sites.publish_post(%L, 1)', pg_temp.id('p1')), 'GS409', null, 'publishing a revision other than the one checked is refused');
130
+ select is((genesis_sites.publish_post(pg_temp.id('p1'), 3)) ->> 'status', 'published', 'an editor publishes the post');
131
+ select isnt((select published_at from genesis_sites.site_posts where id = pg_temp.id('p1')), null, '... and it has a publish date');
132
+ select throws_ok(
133
+ format('select genesis_sites.save_post(%L, %L, %L, 3, %L)', pg_temp.id('a'), pg_temp.id('p1'), 'renamed', pg_temp.post('X')),
134
+ '22023', null, 'a published post keeps its slug'
135
+ );
136
+ select genesis_sites.save_post(pg_temp.id('a'), pg_temp.id('p1'), 'first-steps', 3, pg_temp.post('Draft only edit'));
137
+ insert into t select 'p2', (genesis_sites.save_post(pg_temp.id('a'), null, 'secret-draft', null, pg_temp.post('Secret draft')) ->> 'id')::uuid;
138
+ reset role;
139
+
140
+ -- The public read: nothing until the site is live -----------------------------------
141
+ select pg_temp.as_anon();
142
+ select is(genesis_sites.live_posts(pg_temp.id('a')), '[]'::jsonb, 'a site that isn''t live shows no posts');
143
+ reset role;
144
+ select pg_temp.sign_in('e1');
145
+ select genesis_sites.publish_site(pg_temp.id('a'), (select draft_rev from genesis_sites.sites where id = pg_temp.id('a')),
146
+ (select jsonb_object_agg(id::text, draft_rev) from genesis_sites.site_pages where site_id = pg_temp.id('a') and deleted_at is null));
147
+ reset role;
148
+ select pg_temp.as_anon();
149
+ select is(jsonb_array_length(genesis_sites.live_posts(pg_temp.id('a'))), 1, 'anon lists the one published post of a live site');
150
+ select is((genesis_sites.live_posts(pg_temp.id('a'))) #>> '{0,title}', 'First post, edited', '... as it was published');
151
+ select ok((genesis_sites.live_posts(pg_temp.id('a')))::text not like '%Draft only edit%' and (genesis_sites.live_posts(pg_temp.id('a')))::text not like '%Secret draft%', '... never a draft, or a draft edit since');
152
+ select ok((genesis_sites.live_posts(pg_temp.id('a'))) -> 0 -> 'body' is null, '... and the list carries no bodies');
153
+ select is((genesis_sites.live_post(pg_temp.id('a'), 'first-steps')) #>> '{doc,title}', 'First post, edited', 'anon reads one published post');
154
+ select is(genesis_sites.live_post(pg_temp.id('a'), 'secret-draft'), null, 'a draft post isn''t readable by anon');
155
+ select throws_ok('select * from genesis_sites.site_posts', '42501', null, 'anon can''t read the posts table');
156
+ reset role;
157
+
158
+ -- Viewers and outsiders --------------------------------------------------------------
159
+ select pg_temp.sign_in('b2');
160
+ select is((select count(*)::int from genesis_sites.site_posts), 2, 'a viewer (the app''s can_view) reads the drafts');
161
+ select throws_ok(format('select genesis_sites.publish_post(%L, 4)', pg_temp.id('p2')), '42501', null, '... but can''t publish');
162
+ reset role;
163
+ select pg_temp.sign_in('c3');
164
+ select is((select count(*)::int from genesis_sites.site_posts), 0, 'an outsider reads no post');
165
+ select throws_ok(
166
+ format('select genesis_sites.save_post(%L, null, %L, null, %L)', pg_temp.id('a'), 'mine', pg_temp.post('Mine')),
167
+ '42501', null, 'an outsider can''t write a post'
168
+ );
169
+ select throws_ok(
170
+ format('select genesis_sites.save_post(%L, %L, %L, 1, %L)', gen_random_uuid(), pg_temp.id('p1'), 'first-steps', pg_temp.post('X')),
171
+ '42501', null, 'naming another site doesn''t reach its posts'
172
+ );
173
+ select throws_ok(format('select genesis_sites.import_posts(%L, %L)', pg_temp.id('a'), '[]'), '42501', null, 'an outsider can''t import');
174
+ reset role;
175
+
176
+ -- Taking a post down -----------------------------------------------------------------
177
+ select pg_temp.sign_in('e1');
178
+ select is((genesis_sites.unpublish_post(pg_temp.id('p1'))) ->> 'status', 'draft', 'an editor takes a post down');
179
+ reset role;
180
+ select pg_temp.as_anon();
181
+ select is(genesis_sites.live_post(pg_temp.id('a'), 'first-steps'), null, '... and anon can''t read it any more');
182
+ reset role;
183
+ select pg_temp.sign_in('e1');
184
+ select genesis_sites.publish_post(pg_temp.id('p1'), (select draft_rev from genesis_sites.site_posts where id = pg_temp.id('p1')));
185
+ select lives_ok(format('select genesis_sites.delete_post(%L)', pg_temp.id('p1')), 'an editor deletes a post');
186
+ reset role;
187
+ select pg_temp.as_anon();
188
+ select is(jsonb_array_length(genesis_sites.live_posts(pg_temp.id('a'))), 0, 'a deleted post is gone from the public read');
189
+ reset role;
190
+
191
+ -- Import -----------------------------------------------------------------------------
192
+ select pg_temp.sign_in('e1');
193
+ select is(
194
+ jsonb_array_length((genesis_sites.import_posts(pg_temp.id('a'), jsonb_build_array(
195
+ jsonb_build_object('slug', 'old-news', 'doc', pg_temp.post('Old news'), 'status', 'published', 'publishedAt', '2023-03-14T09:00:00Z'),
196
+ jsonb_build_object('slug', 'later', 'doc', pg_temp.post('Later'), 'status', 'draft'),
197
+ jsonb_build_object('slug', 'secret-draft', 'doc', pg_temp.post('Clash')),
198
+ jsonb_build_object('slug', 'Bad Slug', 'doc', pg_temp.post('Bad')),
199
+ jsonb_build_object('slug', 'bad-date', 'doc', pg_temp.post('Bad date'), 'publishedAt', 'yesterday-ish')
200
+ ))) -> 'imported'),
201
+ 2,
202
+ 'an import brings in the posts that fit'
203
+ );
204
+ select is(
205
+ (select published_at from genesis_sites.site_posts where site_id = pg_temp.id('a') and slug = 'old-news'),
206
+ '2023-03-14T09:00:00Z'::timestamptz,
207
+ '... keeping the old site''s date'
208
+ );
209
+ select is((select status from genesis_sites.site_posts where site_id = pg_temp.id('a') and slug = 'later'), 'draft', '... a draft when asked');
210
+ select is(
211
+ (genesis_sites.import_posts(pg_temp.id('a'), jsonb_build_array(jsonb_build_object('slug', 'no-status', 'doc', pg_temp.post('No status')))) #>> '{imported,0,status}'),
212
+ 'draft',
213
+ '... and a draft when it doesn''t say: an import publishes only when asked'
214
+ );
215
+ select is((select count(*)::int from genesis_sites.site_posts where site_id = pg_temp.id('a') and slug in ('bad-date', 'Bad Slug')), 0, '... and skips the ones that don''t');
216
+ select is(
217
+ (genesis_sites.import_posts(pg_temp.id('a'), jsonb_build_array(
218
+ jsonb_build_object('slug', 'never', 'doc', pg_temp.post('Never'), 'publishedAt', '-infinity'),
219
+ jsonb_build_object('slug', 'ancient', 'doc', pg_temp.post('Ancient'), 'publishedAt', '0044-03-15T00:00:00Z BC')
220
+ ))) -> 'skipped' @> '[{"slug": "never"}, {"slug": "ancient"}]'::jsonb,
221
+ true,
222
+ 'an import refuses infinity and dates before 1970'
223
+ );
224
+ select is(
225
+ (genesis_sites.import_posts(pg_temp.id('a'), jsonb_build_array(jsonb_build_object('slug', 'future', 'doc', pg_temp.post('Future'), 'status', 'published', 'publishedAt', '2999-01-01T00:00:00Z'))) #>> '{imported,0,status}'),
226
+ 'published',
227
+ 'a post dated in the future is imported'
228
+ );
229
+ select ok((select published_at <= now() from genesis_sites.site_posts where site_id = pg_temp.id('a') and slug = 'future'), '... dated no later than now');
230
+ reset role;
231
+
232
+ -- Archived sites serve no posts -------------------------------------------------------
233
+ update genesis_sites.sites set status = 'archived' where id = pg_temp.id('a');
234
+ select pg_temp.as_anon();
235
+ select is(genesis_sites.live_posts(pg_temp.id('a')), '[]'::jsonb, 'an archived site shows no posts');
236
+ reset role;
237
+ select pg_temp.sign_in('e1');
238
+ select throws_ok(format('select genesis_sites.publish_post(%L, 1)', pg_temp.id('p2')), '22023', null, 'an editor can''t publish a post on an archived site');
239
+ reset role;
240
+
241
+ select * from finish();
242
+ rollback;