@marktiderman/genesis-sites 0.0.0-stage → 0.1.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.
@@ -0,0 +1,320 @@
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(74);
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
+ select * from finish();
320
+ rollback;
package/package.json CHANGED
@@ -1,6 +1,147 @@
1
1
  {
2
2
  "name": "@marktiderman/genesis-sites",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
3
+ "version": "0.1.0",
4
+ "private": false,
5
+ "type": "module",
6
+ "sideEffects": [
7
+ "./dist/sections.js",
8
+ "*.css"
9
+ ],
10
+ "description": "Sites and landing pages for Genesis apps: a section registry with zod 4 schemas, 13 server-rendered marketing sections on Genesis UI, a per-request renderer with no hydration, SEO (meta, sitemap, robots, JSON-LD, redirects), publish-time accessibility checks, one-click landing pages from a brand, and a Supabase schema with draft, publish and rollback. Part of the Genesis framework.",
11
+ "repository": {
12
+ "type": "git",
13
+ "url": "git+https://github.com/marktiderman/genesis.git",
14
+ "directory": "packages/sites"
15
+ },
16
+ "license": "MIT",
17
+ "publishConfig": {
18
+ "access": "public",
19
+ "provenance": false
20
+ },
21
+ "main": "./dist/index.js",
22
+ "module": "./dist/index.js",
23
+ "types": "./dist/index.d.ts",
24
+ "exports": {
25
+ "./package.json": "./package.json",
26
+ ".": {
27
+ "types": "./dist/index.d.ts",
28
+ "import": "./dist/index.js"
29
+ },
30
+ "./core": {
31
+ "types": "./dist/index.d.ts",
32
+ "import": "./dist/index.js"
33
+ },
34
+ "./sections": {
35
+ "types": "./dist/sections.d.ts",
36
+ "import": "./dist/sections.js"
37
+ },
38
+ "./web": {
39
+ "types": "./dist/web.d.ts",
40
+ "import": "./dist/web.js"
41
+ },
42
+ "./supabase": {
43
+ "types": "./dist/supabase.d.ts",
44
+ "import": "./dist/supabase.js"
45
+ },
46
+ "./blocks": {
47
+ "types": "./dist/blocks.d.ts",
48
+ "import": "./dist/blocks.js"
49
+ },
50
+ "./sites.css": "./dist/assets/sites.css",
51
+ "./sites-forms.css": "./dist/assets/sites-forms.css"
52
+ },
53
+ "files": [
54
+ "dist",
55
+ "migrations",
56
+ "README.md"
57
+ ],
58
+ "dependencies": {
59
+ "@marktiderman/genesis-features": "^0.2.0"
60
+ },
61
+ "peerDependencies": {
62
+ "@marktiderman/genesis-design-system": "^1.4.1",
63
+ "@marktiderman/genesis-forms": ">=0.6.0 <1.0.0",
64
+ "@marktiderman/genesis-forms-web": ">=0.5.0 <1.0.0",
65
+ "@marktiderman/genesis-ui": "^2.12.2",
66
+ "@supabase/supabase-js": "^2.118.0",
67
+ "lucide-react": "^1.0.0",
68
+ "react": "^19.0.0",
69
+ "react-dom": "^19.0.0",
70
+ "zod": "^4.0.0"
71
+ },
72
+ "peerDependenciesMeta": {
73
+ "@supabase/supabase-js": {
74
+ "optional": true
75
+ }
76
+ },
77
+ "devDependencies": {
78
+ "@marktiderman/genesis-design-system": "^1.4.1",
79
+ "@marktiderman/genesis-forms": "^0.8.0",
80
+ "@marktiderman/genesis-forms-react": "^0.2.0",
81
+ "@marktiderman/genesis-forms-web": "^0.6.1",
82
+ "@marktiderman/genesis-ui": "^2.12.2",
83
+ "@supabase/supabase-js": "^2.118.0",
84
+ "@tailwindcss/cli": "4.3.3",
85
+ "@types/node": "^22",
86
+ "@types/react": "^19.2.14",
87
+ "@types/react-dom": "^19.2.3",
88
+ "axe-core": "^4.14.0",
89
+ "esbuild": "^0.28.1",
90
+ "happy-dom": "^20.9.0",
91
+ "lucide-react": "^1.0.0",
92
+ "radix-ui": "^1.6.7",
93
+ "react": "^19.0.0",
94
+ "react-dom": "^19.0.0",
95
+ "tailwindcss": "4.3.3",
96
+ "tsup": "^8.3.0",
97
+ "typescript": "~6.0.3",
98
+ "vitest": "^4.1.12",
99
+ "zod": "^4.4.3"
100
+ },
101
+ "genesis": {
102
+ "blocks": {
103
+ "sites": {
104
+ "feature": {
105
+ "name": "Sites and landing pages",
106
+ "summary": "Multi-page sites and one-click landing pages from the brand: drafts, preview, publish and rollback, served as cached HTML with no hydration.",
107
+ "keywords": [
108
+ "site builder",
109
+ "landing page",
110
+ "website",
111
+ "seo",
112
+ "sitemap"
113
+ ],
114
+ "audience": [
115
+ "admin",
116
+ "person"
117
+ ]
118
+ },
119
+ "platforms": [
120
+ "web",
121
+ "server"
122
+ ],
123
+ "migrations": [
124
+ {
125
+ "file": "migrations/0001_genesis_sites.sql",
126
+ "test": "migrations/tests/0001_genesis_sites.test.sql"
127
+ }
128
+ ],
129
+ "install": [
130
+ "zod@^4.0.0"
131
+ ],
132
+ "steps": [
133
+ "Expose the genesis_sites schema to the API: add it to `schemas` under [api] in supabase/config.toml, and to Exposed schemas in the dashboard.",
134
+ "Decide who edits a site: replace genesis_sites.can_edit(uuid) (and, if previews should reach more people, genesis_sites.can_view(uuid)) in a migration of your own, after this one (genesis-sites README, Who may edit).",
135
+ "Mount handleSiteRequest from @marktiderman/genesis-sites/web on your server, before your app's routes: it answers the site's paths and returns null for everything else."
136
+ ]
137
+ }
138
+ }
139
+ },
140
+ "scripts": {
141
+ "assets": "node scripts/build-assets.mjs",
142
+ "build": "node scripts/build-assets.mjs && tsup && node ../../scripts/check-exports-resolve.mjs",
143
+ "clean": "rm -rf dist src/web/assets.generated.ts",
144
+ "typecheck": "node scripts/build-assets.mjs --if-missing && tsc --noEmit",
145
+ "test": "node scripts/build-assets.mjs --if-missing && vitest run"
146
+ }
6
147
  }