@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,821 @@
1
+ -- Genesis Sites: the genesis_sites schema (sites, pages, releases, assets)
2
+ --
3
+ -- A site is a draft site document plus one draft per page. A publish freezes
4
+ -- them into an immutable release, and the live site points at it; a rollback
5
+ -- moves the pointer back. The client half is `createSupabaseSiteStore` in
6
+ -- @marktiderman/genesis-sites/supabase, which runs the publish checks
7
+ -- (accessibility, contrast, content) before it calls publish_site.
8
+ --
9
+ -- Everything lives in the genesis_sites schema. Expose it to the API: add it
10
+ -- to `schemas` under [api] in supabase/config.toml, and to Exposed schemas
11
+ -- in the dashboard.
12
+ --
13
+ -- Who may edit is yours. This file names no table of yours; it asks two hooks:
14
+ --
15
+ -- genesis_sites.can_edit(site_id uuid) returns boolean
16
+ -- May the signed-in person change this site (drafts, pages, assets,
17
+ -- publish, rollback)? Ships as a stub that answers false, so a fresh
18
+ -- install fails closed.
19
+ -- genesis_sites.can_view(site_id uuid) returns boolean
20
+ -- May they read its drafts and releases (an editor's preview, a
21
+ -- colleague who reviews)? Ships as a stub that asks can_edit.
22
+ --
23
+ -- Replace them in a migration of your own, after this one, with the same
24
+ -- signature. `create or replace` keeps the grants this file gives; keep
25
+ -- `stable`, and use `security definer set search_path = ''` when the hook
26
+ -- reads tables with row level security of their own.
27
+ --
28
+ -- Name the person in the hook (`auth.uid()`); never rely on your tables'
29
+ -- row level security to filter for you. The write functions below are
30
+ -- security definer, so the hook runs as their owner there, where your RLS
31
+ -- doesn't apply: a hook that asks "is any membership row visible?" would
32
+ -- answer yes for everyone. For example:
33
+ --
34
+ -- create or replace function genesis_sites.can_edit(site_id uuid) returns boolean
35
+ -- language sql stable security definer set search_path = '' as $$
36
+ -- select exists (select 1 from public.brand_members m
37
+ -- join public.brand_sites b on b.brand_id = m.brand_id
38
+ -- where b.site_id = can_edit.site_id and m.user_id = auth.uid() and m.role in ('owner', 'editor'))
39
+ -- $$;
40
+ --
41
+ -- This file (and every later genesis-sites migration) creates a hook only
42
+ -- when it is missing or still exactly the stub, through
43
+ -- genesis_sites.ensure_hook_stubs(), so running it again never overwrites
44
+ -- your version. The same goes for genesis_sites.reserved_paths(): the page
45
+ -- paths a site can't use because it shares its hostname with your app.
46
+ --
47
+ -- Public reads: anon reads only a live site's live release, through
48
+ -- genesis_sites.live_release(site_id). Drafts are never readable by anon;
49
+ -- signed-in people read drafts and releases where can_view says so.
50
+ --
51
+ -- Writes go through the security definer functions below, each asking
52
+ -- can_edit. The tables have row level security on and no write grants to
53
+ -- anon or authenticated, except inserting and deleting asset rows (an
54
+ -- upload's record), under can_edit. Sites are created by the service role
55
+ -- (create_site), from a server job or your own function.
56
+ --
57
+ -- Assets: store files under `sites/<site_id>/...` in your bucket, so your
58
+ -- storage policies can scope by site with can_edit, for example:
59
+ --
60
+ -- create policy "site editors upload" on storage.objects for insert to authenticated
61
+ -- with check (bucket_id = 'site-assets' and (storage.foldername(name))[1] = 'sites'
62
+ -- and lower(storage.extension(name)) in ('png', 'jpg', 'jpeg', 'webp', 'avif', 'gif', 'pdf')
63
+ -- and genesis_sites.can_edit(((storage.foldername(name))[2])::uuid));
64
+ --
65
+ -- and set the bucket's allowed_mime_types to the same image types and PDF:
66
+ -- never SVG or HTML, which a browser runs as a page.
67
+ --
68
+ -- Audit: add your own triggers on these tables if you keep an audit trail;
69
+ -- nothing here depends on one. Every change to what a site serves (publish,
70
+ -- rollback, archive, unarchive) sends `pg_notify('genesis_sites',
71
+ -- {"site_id", "release_id", "status", "action"})`, from a trigger on sites,
72
+ -- for a listener that purges the CDN (or purge from the server that made it).
73
+ --
74
+ -- Checks: publish_site checks shape, size, paths and revisions. The
75
+ -- accessibility and content checks run in TypeScript (`prepareRelease`, in
76
+ -- the stores) before it is called, so they are a guard for editors, not a
77
+ -- security boundary: an editor calling the function directly can skip them,
78
+ -- and `brand_hash` is what the caller reports. Access rules can't be skipped.
79
+ -- A hidden section is left out of the release, so it is never public.
80
+ --
81
+ -- Use: `genesis add sites` copies this file into supabase/migrations under a
82
+ -- new timestamped name. Never edit it once applied; change it with a new migration.
83
+ --
84
+ -- Verify: the pgTAP test beside this file (`tests/0001_genesis_sites.test.sql`).
85
+
86
+ create schema if not exists genesis_sites;
87
+
88
+ comment on schema genesis_sites is 'Genesis Sites: sites, pages, releases and assets. Reached through RLS and the functions in this schema.';
89
+
90
+ grant usage on schema genesis_sites to anon, authenticated, service_role;
91
+
92
+ -- ── Hooks ─────────────────────────────────────────────────────────────────────
93
+
94
+ -- Creates each hook only when it is missing or still exactly the stub this
95
+ -- package ships, so a re-run (or a later genesis-sites migration) never
96
+ -- overwrites the app's own version.
97
+ create or replace function genesis_sites.ensure_hook_stubs()
98
+ returns void
99
+ language plpgsql
100
+ security definer
101
+ set search_path = ''
102
+ as $fn$
103
+ declare
104
+ stubs constant jsonb := jsonb_build_array(
105
+ jsonb_build_object(
106
+ 'name', 'can_edit',
107
+ 'returns', 'boolean',
108
+ 'body', E'\n -- genesis-sites stub: nobody may edit until the app replaces this function.\n select false\n'
109
+ ),
110
+ jsonb_build_object(
111
+ 'name', 'can_view',
112
+ 'returns', 'boolean',
113
+ 'body', E'\n -- genesis-sites stub: whoever may edit may view.\n select genesis_sites.can_edit(site_id)\n'
114
+ )
115
+ );
116
+ reserved_body constant text := E'\n -- genesis-sites stub: the app\'s paths a site page can\'t use.\n select array[''/api'', ''/_server'', ''/auth'', ''/_gs'']::text[]\n';
117
+ stub jsonb;
118
+ current_body text;
119
+ begin
120
+ for stub in select * from jsonb_array_elements(stubs) loop
121
+ select p.prosrc into current_body
122
+ from pg_proc p join pg_namespace n on n.oid = p.pronamespace
123
+ where n.nspname = 'genesis_sites' and p.proname = stub ->> 'name'
124
+ and pg_get_function_identity_arguments(p.oid) = 'site_id uuid';
125
+ if current_body is null or current_body = stub ->> 'body' then
126
+ execute format(
127
+ 'create or replace function genesis_sites.%I(site_id uuid) returns %s language sql stable set search_path = '''' as %L',
128
+ stub ->> 'name', stub ->> 'returns', stub ->> 'body'
129
+ );
130
+ -- By name, each time it writes a stub, so a hook this step had to create again
131
+ -- never keeps Postgres's default (execute for public).
132
+ execute format('revoke execute on function genesis_sites.%I(uuid) from public, anon', stub ->> 'name');
133
+ execute format('grant execute on function genesis_sites.%I(uuid) to authenticated, service_role', stub ->> 'name');
134
+ end if;
135
+ current_body := null;
136
+ end loop;
137
+
138
+ select p.prosrc into current_body
139
+ from pg_proc p join pg_namespace n on n.oid = p.pronamespace
140
+ where n.nspname = 'genesis_sites' and p.proname = 'reserved_paths' and pg_get_function_identity_arguments(p.oid) = '';
141
+ if current_body is null or current_body = reserved_body then
142
+ execute format('create or replace function genesis_sites.reserved_paths() returns text[] language sql stable set search_path = '''' as %L', reserved_body);
143
+ revoke execute on function genesis_sites.reserved_paths() from public, anon;
144
+ grant execute on function genesis_sites.reserved_paths() to authenticated, service_role;
145
+ end if;
146
+ end;
147
+ $fn$;
148
+
149
+ revoke execute on function genesis_sites.ensure_hook_stubs() from public, anon, authenticated;
150
+
151
+ select genesis_sites.ensure_hook_stubs();
152
+
153
+ comment on function genesis_sites.can_edit(uuid) is
154
+ 'May the signed-in person edit this site? Replace it in your own migration (same signature). The stub answers false. Genesis Sites.';
155
+ comment on function genesis_sites.can_view(uuid) is
156
+ 'May the signed-in person read this site''s drafts and releases? Replace it in your own migration (same signature). The stub asks can_edit. Genesis Sites.';
157
+ comment on function genesis_sites.reserved_paths() is
158
+ 'Path prefixes a page can''t use, because the site shares its hostname with the app. Replace it to add yours. Genesis Sites.';
159
+
160
+ -- ── Tables ────────────────────────────────────────────────────────────────────
161
+
162
+ create table genesis_sites.sites (
163
+ id uuid primary key default gen_random_uuid(),
164
+ name text not null constraint sites_name_length check (char_length(btrim(name)) between 1 and 120),
165
+ slug text not null unique constraint sites_slug_format check (slug ~ '^[a-z0-9]+(-[a-z0-9]+)*$' and char_length(slug) <= 63),
166
+ -- The site document (SiteDoc): theme, header, menu, footer, SEO defaults, redirects.
167
+ draft jsonb not null constraint sites_draft_shape check (jsonb_typeof(draft) = 'object' and octet_length(draft::text) <= 524288),
168
+ draft_rev integer not null default 1,
169
+ live_release_id uuid,
170
+ status text not null default 'draft' constraint sites_status check (status in ('draft', 'live', 'archived')),
171
+ created_at timestamptz not null default now(),
172
+ updated_at timestamptz not null default now()
173
+ );
174
+
175
+ comment on table genesis_sites.sites is 'A site: its draft site document and the release it serves. Genesis Sites.';
176
+
177
+ create table genesis_sites.site_pages (
178
+ id uuid primary key default gen_random_uuid(),
179
+ site_id uuid not null references genesis_sites.sites (id) on delete cascade,
180
+ path text not null constraint site_pages_path_format check (
181
+ char_length(path) <= 200 and path ~ '^/([a-z0-9]+(-[a-z0-9]+)*(/[a-z0-9]+(-[a-z0-9]+)*){0,7})?$'
182
+ ),
183
+ kind text not null default 'page' constraint site_pages_kind check (kind in ('page', 'landing')),
184
+ -- The page document (PageDoc): its sections and SEO.
185
+ draft jsonb not null constraint site_pages_draft_shape check (jsonb_typeof(draft) = 'object' and octet_length(draft::text) <= 524288),
186
+ draft_rev integer not null default 1,
187
+ sort integer not null default 0,
188
+ deleted_at timestamptz,
189
+ created_at timestamptz not null default now(),
190
+ updated_at timestamptz not null default now()
191
+ );
192
+
193
+ create unique index site_pages_one_per_path on genesis_sites.site_pages (site_id, path) where deleted_at is null;
194
+ create index site_pages_site on genesis_sites.site_pages (site_id, sort);
195
+
196
+ comment on table genesis_sites.site_pages is 'A site''s pages and their drafts. A deleted page keeps its row (deleted_at). Genesis Sites.';
197
+
198
+ create table genesis_sites.site_releases (
199
+ id uuid primary key default gen_random_uuid(),
200
+ site_id uuid not null references genesis_sites.sites (id) on delete cascade,
201
+ number integer not null constraint site_releases_number check (number >= 1),
202
+ -- Everything the release serves: { schemaVersion, site, pages: [{ id, path, kind, doc }] }.
203
+ snapshot jsonb not null,
204
+ note text constraint site_releases_note_length check (char_length(note) <= 500),
205
+ brand_hash text constraint site_releases_brand_hash check (brand_hash ~ '^[0-9a-f]{1,64}$'),
206
+ created_by uuid references auth.users (id) on delete set null,
207
+ created_at timestamptz not null default now(),
208
+ published_at timestamptz,
209
+ unique (site_id, number),
210
+ unique (id, site_id)
211
+ );
212
+
213
+ comment on table genesis_sites.site_releases is 'Immutable published snapshots of a site. The live site points at one. Genesis Sites.';
214
+
215
+ -- The live release is one of the site's own releases.
216
+ alter table genesis_sites.sites
217
+ add constraint sites_live_release foreign key (live_release_id, id) references genesis_sites.site_releases (id, site_id);
218
+
219
+ create table genesis_sites.site_assets (
220
+ id uuid primary key default gen_random_uuid(),
221
+ site_id uuid not null references genesis_sites.sites (id) on delete cascade,
222
+ -- The storage key: always under sites/<site_id>/.
223
+ path text not null unique constraint site_assets_path check (
224
+ char_length(path) <= 512 and starts_with(path, 'sites/' || site_id::text || '/') and path !~ '(^|/)\.\.?(/|$)' and path !~ '[\\[:cntrl:]]'
225
+ ),
226
+ -- Images and PDFs. No SVG: an SVG can carry script.
227
+ mime text not null constraint site_assets_mime check (mime in ('image/png', 'image/jpeg', 'image/webp', 'image/avif', 'image/gif', 'application/pdf')),
228
+ width integer constraint site_assets_width check (width between 1 and 20000),
229
+ height integer constraint site_assets_height check (height between 1 and 20000),
230
+ alt text constraint site_assets_alt check (char_length(alt) <= 300),
231
+ blurhash text constraint site_assets_blurhash check (char_length(blurhash) <= 100),
232
+ created_by uuid default auth.uid() references auth.users (id) on delete set null,
233
+ created_at timestamptz not null default now()
234
+ );
235
+
236
+ create index site_assets_site on genesis_sites.site_assets (site_id);
237
+
238
+ comment on table genesis_sites.site_assets is 'Files a site uses (the record of an upload under sites/<site_id>/). Genesis Sites.';
239
+
240
+ create or replace function genesis_sites.touch()
241
+ returns trigger
242
+ language plpgsql
243
+ set search_path = ''
244
+ as $$
245
+ begin
246
+ new.updated_at := now();
247
+ return new;
248
+ end;
249
+ $$;
250
+
251
+ create trigger genesis_touch before update on genesis_sites.sites for each row execute function genesis_sites.touch();
252
+ create trigger genesis_touch before update on genesis_sites.site_pages for each row execute function genesis_sites.touch();
253
+
254
+ -- A release never changes once written (rollback moves the site's pointer instead).
255
+ create or replace function genesis_sites.release_is_immutable()
256
+ returns trigger
257
+ language plpgsql
258
+ set search_path = ''
259
+ as $$
260
+ begin
261
+ raise exception 'genesis_sites: a release can''t be changed; publish a new one' using errcode = '42501';
262
+ end;
263
+ $$;
264
+
265
+ create trigger genesis_immutable before update on genesis_sites.site_releases for each row execute function genesis_sites.release_is_immutable();
266
+
267
+ -- What a site serves changed (publish, rollback, archive, unarchive): tell a listener to purge its cache.
268
+ create or replace function genesis_sites.notify_served_change()
269
+ returns trigger
270
+ language plpgsql
271
+ set search_path = ''
272
+ as $$
273
+ begin
274
+ perform pg_notify('genesis_sites', jsonb_build_object(
275
+ 'site_id', new.id,
276
+ 'release_id', new.live_release_id,
277
+ 'status', new.status,
278
+ 'action', case
279
+ when new.status = 'archived' then 'archive'
280
+ when old.status = 'archived' then 'unarchive'
281
+ when new.live_release_id is distinct from old.live_release_id
282
+ and (select r.number from genesis_sites.site_releases r where r.id = new.live_release_id)
283
+ < coalesce((select r.number from genesis_sites.site_releases r where r.id = old.live_release_id), 0)
284
+ then 'rollback'
285
+ else 'publish'
286
+ end
287
+ )::text);
288
+ return new;
289
+ end;
290
+ $$;
291
+
292
+ create trigger genesis_notify_served after update of status, live_release_id on genesis_sites.sites
293
+ for each row when (old.status is distinct from new.status or old.live_release_id is distinct from new.live_release_id)
294
+ execute function genesis_sites.notify_served_change();
295
+
296
+ -- ── Row level security ────────────────────────────────────────────────────────
297
+
298
+ alter table genesis_sites.sites enable row level security;
299
+ alter table genesis_sites.site_pages enable row level security;
300
+ alter table genesis_sites.site_releases enable row level security;
301
+ alter table genesis_sites.site_assets enable row level security;
302
+
303
+ create policy "viewers read the site" on genesis_sites.sites
304
+ for select to authenticated using (genesis_sites.can_view(id));
305
+ create policy "viewers read its pages" on genesis_sites.site_pages
306
+ for select to authenticated using (deleted_at is null and genesis_sites.can_view(site_id));
307
+ create policy "viewers read its releases" on genesis_sites.site_releases
308
+ for select to authenticated using (genesis_sites.can_view(site_id));
309
+ create policy "viewers read its assets" on genesis_sites.site_assets
310
+ for select to authenticated using (genesis_sites.can_view(site_id));
311
+ create policy "editors record uploads" on genesis_sites.site_assets
312
+ for insert to authenticated with check (genesis_sites.can_edit(site_id));
313
+ create policy "editors remove uploads" on genesis_sites.site_assets
314
+ for delete to authenticated using (genesis_sites.can_edit(site_id));
315
+
316
+ -- ── Helpers ───────────────────────────────────────────────────────────────────
317
+
318
+ create or replace function genesis_sites.assert_can_edit(site uuid)
319
+ returns void
320
+ language plpgsql
321
+ stable
322
+ security definer
323
+ set search_path = ''
324
+ as $$
325
+ begin
326
+ if auth.uid() is null then
327
+ raise exception 'genesis_sites: not signed in' using errcode = '42501';
328
+ end if;
329
+ if site is null or not coalesce(genesis_sites.can_edit(site), false) then
330
+ raise exception 'genesis_sites: you can''t edit this site' using errcode = '42501',
331
+ hint = 'genesis_sites.can_edit(uuid) decides; replace its stub in your own migration.';
332
+ end if;
333
+ end;
334
+ $$;
335
+
336
+ -- Not one of the app's reserved paths (the path itself or anything under it).
337
+ create or replace function genesis_sites.assert_not_reserved(path text)
338
+ returns void
339
+ language plpgsql
340
+ stable
341
+ security definer
342
+ set search_path = ''
343
+ as $$
344
+ declare
345
+ prefix text;
346
+ -- Like isReservedPath: lower case, no query or fragment, no trailing slash except on the root itself.
347
+ probe text := lower(split_part(split_part(coalesce(path, ''), '?', 1), '#', 1));
348
+ begin
349
+ if char_length(probe) > 1 then
350
+ probe := rtrim(probe, '/');
351
+ end if;
352
+ foreach prefix in array coalesce(genesis_sites.reserved_paths(), '{}'::text[]) loop
353
+ prefix := lower(prefix);
354
+ if char_length(prefix) > 1 then
355
+ prefix := rtrim(prefix, '/');
356
+ end if;
357
+ if prefix = '' then
358
+ continue;
359
+ end if;
360
+ -- A reserved "/" reserves the root only, as in isReservedPath.
361
+ if probe = prefix or (prefix <> '/' and starts_with(probe, prefix || '/')) then
362
+ raise exception 'genesis_sites: "%" is reserved for the app', path using errcode = '22023';
363
+ end if;
364
+ end loop;
365
+ end;
366
+ $$;
367
+
368
+ -- A page path's shape, and not one of the app's reserved paths.
369
+ create or replace function genesis_sites.assert_page_path(path text)
370
+ returns void
371
+ language plpgsql
372
+ stable
373
+ security definer
374
+ set search_path = ''
375
+ as $$
376
+ begin
377
+ if path is null or char_length(path) > 200 or path !~ '^/([a-z0-9]+(-[a-z0-9]+)*(/[a-z0-9]+(-[a-z0-9]+)*){0,7})?$' then
378
+ raise exception 'genesis_sites: "%" isn''t a page path (lowercase segments, like /about)', path using errcode = '22023';
379
+ end if;
380
+ perform genesis_sites.assert_not_reserved(path);
381
+ end;
382
+ $$;
383
+
384
+ -- Every redirect's `from` is a site path (an old address, any case) that isn't one of the app's
385
+ -- reserved paths: a redirect from /auth or a reserved prefix would take over the app's own route.
386
+ create or replace function genesis_sites.assert_redirects(doc jsonb)
387
+ returns void
388
+ language plpgsql
389
+ stable
390
+ security definer
391
+ set search_path = ''
392
+ as $$
393
+ declare
394
+ r jsonb;
395
+ from_path text;
396
+ begin
397
+ if doc -> 'redirects' is null or jsonb_typeof(doc -> 'redirects') = 'null' then
398
+ return;
399
+ end if;
400
+ if jsonb_typeof(doc -> 'redirects') <> 'array' then
401
+ raise exception 'genesis_sites: redirects must be a list' using errcode = '22023';
402
+ end if;
403
+ for r in select value from jsonb_array_elements(doc -> 'redirects') loop
404
+ from_path := r ->> 'from';
405
+ if from_path is null or char_length(from_path) > 500 or left(from_path, 1) <> '/' or left(from_path, 2) = '//' or from_path ~ '[[:space:][:cntrl:]\\]' then
406
+ raise exception 'genesis_sites: "%" isn''t a redirect source (a site path starting with /)', from_path using errcode = '22023';
407
+ end if;
408
+ perform genesis_sites.assert_not_reserved(from_path);
409
+ end loop;
410
+ end;
411
+ $$;
412
+
413
+ create or replace function genesis_sites.assert_doc(doc jsonb, what text)
414
+ returns void
415
+ language plpgsql
416
+ immutable
417
+ set search_path = ''
418
+ as $$
419
+ begin
420
+ if doc is null or jsonb_typeof(doc) <> 'object' or octet_length(doc::text) > 524288 then
421
+ raise exception 'genesis_sites: the % document must be a JSON object of at most 512 KiB', what using errcode = '22023';
422
+ end if;
423
+ if doc -> 'schemaVersion' is distinct from '1'::jsonb then
424
+ raise exception 'genesis_sites: the % document needs schemaVersion 1', what using errcode = '22023';
425
+ end if;
426
+ if what = 'page' and (jsonb_typeof(doc -> 'content') is distinct from 'array' or jsonb_typeof(doc #> '{root,props,title}') is distinct from 'string') then
427
+ raise exception 'genesis_sites: a page document needs root.props.title and a content list' using errcode = '22023';
428
+ end if;
429
+ if what = 'site' and jsonb_typeof(doc -> 'name') is distinct from 'string' then
430
+ raise exception 'genesis_sites: a site document needs a name' using errcode = '22023';
431
+ end if;
432
+ end;
433
+ $$;
434
+
435
+ create or replace function genesis_sites.release_json(r genesis_sites.site_releases, with_snapshot boolean)
436
+ returns jsonb
437
+ language sql
438
+ stable
439
+ set search_path = ''
440
+ as $$
441
+ select jsonb_build_object(
442
+ 'id', r.id, 'siteId', r.site_id, 'number', r.number, 'note', r.note,
443
+ 'createdAt', r.created_at, 'publishedAt', r.published_at, 'brandHash', r.brand_hash
444
+ ) || case when with_snapshot then jsonb_build_object('snapshot', r.snapshot) else '{}'::jsonb end
445
+ $$;
446
+
447
+ -- ── Writes (security definer, each asking can_edit) ───────────────────────────
448
+
449
+ -- Save a page's draft if nobody saved since `expected_rev`. Returns the new
450
+ -- revision; raises GS409 (with the current revision in DETAIL) when someone did.
451
+ create or replace function genesis_sites.save_page_draft(page_id uuid, expected_rev integer, doc jsonb)
452
+ returns integer
453
+ language plpgsql
454
+ security definer
455
+ set search_path = ''
456
+ as $$
457
+ declare
458
+ page genesis_sites.site_pages;
459
+ begin
460
+ -- can_edit first, then the row lock: someone who can't edit learns nothing and locks nothing.
461
+ perform genesis_sites.assert_can_edit((select p.site_id from genesis_sites.site_pages p where p.id = page_id));
462
+ select * into page from genesis_sites.site_pages p where p.id = page_id and p.deleted_at is null for update;
463
+ if not found then
464
+ raise exception 'genesis_sites: no page %', page_id using errcode = 'P0002';
465
+ end if;
466
+ -- The app may have reserved this page's path since the page was made (genesis_sites.reserved_paths()).
467
+ perform genesis_sites.assert_page_path(page.path);
468
+ perform genesis_sites.assert_doc(doc, 'page');
469
+ if page.draft_rev is distinct from expected_rev then
470
+ raise exception 'genesis_sites: someone saved this page first' using errcode = 'GS409', detail = page.draft_rev::text;
471
+ end if;
472
+ update genesis_sites.site_pages p set draft = doc, draft_rev = p.draft_rev + 1 where p.id = page_id;
473
+ return page.draft_rev + 1;
474
+ end;
475
+ $$;
476
+
477
+ create or replace function genesis_sites.save_site_draft(site_id uuid, expected_rev integer, doc jsonb)
478
+ returns integer
479
+ language plpgsql
480
+ security definer
481
+ set search_path = ''
482
+ as $$
483
+ declare
484
+ site genesis_sites.sites;
485
+ begin
486
+ perform genesis_sites.assert_can_edit(site_id);
487
+ select * into site from genesis_sites.sites s where s.id = save_site_draft.site_id for update;
488
+ if not found then
489
+ raise exception 'genesis_sites: no site %', site_id using errcode = 'P0002';
490
+ end if;
491
+ perform genesis_sites.assert_doc(doc, 'site');
492
+ perform genesis_sites.assert_redirects(doc);
493
+ if site.draft_rev is distinct from expected_rev then
494
+ raise exception 'genesis_sites: someone saved this site first' using errcode = 'GS409', detail = site.draft_rev::text;
495
+ end if;
496
+ update genesis_sites.sites s set draft = doc, draft_rev = s.draft_rev + 1 where s.id = save_site_draft.site_id;
497
+ return site.draft_rev + 1;
498
+ end;
499
+ $$;
500
+
501
+ -- Drop the redirect away from `path` (a page lives there now), bumping the draft's revision if there was one.
502
+ create or replace function genesis_sites.drop_redirect_from(site uuid, path text)
503
+ returns void
504
+ language plpgsql
505
+ security definer
506
+ set search_path = ''
507
+ as $$
508
+ begin
509
+ update genesis_sites.sites s
510
+ set draft = jsonb_set(s.draft, '{redirects}', coalesce((
511
+ select jsonb_agg(r order by i) from jsonb_array_elements(coalesce(s.draft -> 'redirects', '[]'::jsonb)) with ordinality as e(r, i)
512
+ where r ->> 'from' is distinct from path), '[]'::jsonb)),
513
+ draft_rev = s.draft_rev + 1
514
+ where s.id = site and exists (select 1 from jsonb_array_elements(coalesce(s.draft -> 'redirects', '[]'::jsonb)) r where r ->> 'from' = path);
515
+ end;
516
+ $$;
517
+
518
+ create or replace function genesis_sites.create_page(site_id uuid, path text, kind text, doc jsonb)
519
+ returns uuid
520
+ language plpgsql
521
+ security definer
522
+ set search_path = ''
523
+ as $$
524
+ declare
525
+ new_id uuid;
526
+ begin
527
+ perform genesis_sites.assert_can_edit(site_id);
528
+ perform genesis_sites.assert_page_path(path);
529
+ perform genesis_sites.assert_doc(doc, 'page');
530
+ perform 1 from genesis_sites.sites s where s.id = create_page.site_id for update;
531
+ if not found then
532
+ raise exception 'genesis_sites: no site %', site_id using errcode = 'P0002';
533
+ end if;
534
+ if exists (select 1 from genesis_sites.site_pages p where p.site_id = create_page.site_id and p.path = create_page.path and p.deleted_at is null) then
535
+ raise exception 'genesis_sites: a page is already at %', path using errcode = '23505';
536
+ end if;
537
+ insert into genesis_sites.site_pages (site_id, path, kind, draft, sort)
538
+ values (site_id, path, coalesce(kind, 'page'), doc,
539
+ coalesce((select max(p.sort) + 1 from genesis_sites.site_pages p where p.site_id = create_page.site_id and p.deleted_at is null), 0))
540
+ returning id into new_id;
541
+ perform genesis_sites.drop_redirect_from(site_id, path);
542
+ return new_id;
543
+ end;
544
+ $$;
545
+
546
+ -- Move a page; the old path redirects to the new one (301), with no chains.
547
+ create or replace function genesis_sites.move_page(page_id uuid, new_path text)
548
+ returns void
549
+ language plpgsql
550
+ security definer
551
+ set search_path = ''
552
+ as $$
553
+ declare
554
+ page genesis_sites.site_pages;
555
+ begin
556
+ perform genesis_sites.assert_can_edit((select p.site_id from genesis_sites.site_pages p where p.id = page_id));
557
+ -- The site's row first, then the page's: the order publish_site locks them in, so the two never deadlock.
558
+ perform 1 from genesis_sites.sites s where s.id = (select p.site_id from genesis_sites.site_pages p where p.id = page_id) for update;
559
+ select * into page from genesis_sites.site_pages p where p.id = page_id and p.deleted_at is null for update;
560
+ if not found then
561
+ raise exception 'genesis_sites: no page %', page_id using errcode = 'P0002';
562
+ end if;
563
+ perform genesis_sites.assert_page_path(new_path);
564
+ if page.path = '/' then
565
+ raise exception 'genesis_sites: the home page stays at /' using errcode = '22023';
566
+ end if;
567
+ if page.path = new_path then
568
+ return;
569
+ end if;
570
+ if exists (select 1 from genesis_sites.site_pages p where p.site_id = page.site_id and p.path = new_path and p.deleted_at is null) then
571
+ raise exception 'genesis_sites: a page is already at %', new_path using errcode = '23505';
572
+ end if;
573
+ update genesis_sites.site_pages p set path = new_path, draft_rev = p.draft_rev + 1 where p.id = page_id;
574
+ update genesis_sites.sites s
575
+ set draft = jsonb_set(s.draft, '{redirects}', coalesce((
576
+ select jsonb_agg(r order by i) from (
577
+ select case when e.r ->> 'to' = page.path then jsonb_set(e.r, '{to}', to_jsonb(new_path)) else e.r end as r, e.i
578
+ from jsonb_array_elements(coalesce(s.draft -> 'redirects', '[]'::jsonb)) with ordinality as e(r, i)
579
+ where e.r ->> 'from' is distinct from new_path and e.r ->> 'from' is distinct from page.path
580
+ ) m
581
+ where m.r ->> 'from' is distinct from m.r ->> 'to'), '[]'::jsonb)
582
+ || jsonb_build_array(jsonb_build_object('from', page.path, 'to', new_path, 'status', 301))),
583
+ draft_rev = s.draft_rev + 1
584
+ where s.id = page.site_id;
585
+ end;
586
+ $$;
587
+
588
+ create or replace function genesis_sites.delete_page(page_id uuid)
589
+ returns void
590
+ language plpgsql
591
+ security definer
592
+ set search_path = ''
593
+ as $$
594
+ declare
595
+ page genesis_sites.site_pages;
596
+ begin
597
+ perform genesis_sites.assert_can_edit((select p.site_id from genesis_sites.site_pages p where p.id = page_id));
598
+ select * into page from genesis_sites.site_pages p where p.id = page_id and p.deleted_at is null for update;
599
+ if not found then
600
+ return;
601
+ end if;
602
+ if page.path = '/' then
603
+ raise exception 'genesis_sites: the home page can''t be deleted' using errcode = '22023';
604
+ end if;
605
+ update genesis_sites.site_pages p set deleted_at = now() where p.id = page_id;
606
+ end;
607
+ $$;
608
+
609
+ -- A list of section nodes without the hidden ones: a release never carries a hidden section.
610
+ create or replace function genesis_sites.visible_nodes(nodes jsonb)
611
+ returns jsonb
612
+ language sql
613
+ immutable
614
+ set search_path = ''
615
+ as $$
616
+ select coalesce(jsonb_agg(n order by i), '[]'::jsonb)
617
+ from jsonb_array_elements(case when jsonb_typeof(nodes) = 'array' then nodes else '[]'::jsonb end) with ordinality as e(n, i)
618
+ where coalesce(n ->> 'hidden', 'false') <> 'true'
619
+ $$;
620
+
621
+ -- Publish: the drafts exactly as the caller checked them (`expected_site_rev`
622
+ -- and `expected_pages`, `{ "<page id>": rev }` for every page) become a new
623
+ -- release, and the live site points at it. Raises GS409 if anything changed
624
+ -- since the check, so what goes live is what was checked.
625
+ create or replace function genesis_sites.publish_site(site_id uuid, expected_site_rev integer, expected_pages jsonb, note text default null, brand_hash text default null)
626
+ returns jsonb
627
+ language plpgsql
628
+ security definer
629
+ set search_path = ''
630
+ as $$
631
+ declare
632
+ site genesis_sites.sites;
633
+ release genesis_sites.site_releases;
634
+ page record;
635
+ page_count integer := 0;
636
+ begin
637
+ perform genesis_sites.assert_can_edit(site_id);
638
+ select * into site from genesis_sites.sites s where s.id = publish_site.site_id for update;
639
+ if not found then
640
+ raise exception 'genesis_sites: no site %', site_id using errcode = 'P0002';
641
+ end if;
642
+ if site.status = 'archived' then
643
+ raise exception 'genesis_sites: an archived site can''t be published' using errcode = '22023';
644
+ end if;
645
+ perform genesis_sites.assert_doc(site.draft, 'site');
646
+ perform genesis_sites.assert_redirects(site.draft);
647
+ if site.draft_rev is distinct from expected_site_rev then
648
+ raise exception 'genesis_sites: the site changed since it was checked' using errcode = 'GS409', detail = site.draft_rev::text;
649
+ end if;
650
+ if expected_pages is null or jsonb_typeof(expected_pages) <> 'object' then
651
+ raise exception 'genesis_sites: expected_pages is { "<page id>": rev } for every page' using errcode = '22023';
652
+ end if;
653
+ for page in select p.id, p.path, p.draft, p.draft_rev from genesis_sites.site_pages p where p.site_id = publish_site.site_id and p.deleted_at is null for update loop
654
+ page_count := page_count + 1;
655
+ if (expected_pages ->> page.id::text) is distinct from page.draft_rev::text then
656
+ raise exception 'genesis_sites: the page at % changed since it was checked', page.path using errcode = 'GS409';
657
+ end if;
658
+ perform genesis_sites.assert_page_path(page.path);
659
+ perform genesis_sites.assert_doc(page.draft, 'page');
660
+ end loop;
661
+ if page_count <> (select count(*) from jsonb_object_keys(expected_pages)) then
662
+ raise exception 'genesis_sites: the pages changed since they were checked' using errcode = 'GS409';
663
+ end if;
664
+ if not exists (select 1 from genesis_sites.site_pages p where p.site_id = publish_site.site_id and p.path = '/' and p.deleted_at is null) then
665
+ raise exception 'genesis_sites: the site has no home page (/)' using errcode = '22023';
666
+ end if;
667
+ if note is not null and char_length(note) > 500 then
668
+ raise exception 'genesis_sites: a release note has at most 500 characters' using errcode = '22023';
669
+ end if;
670
+
671
+ insert into genesis_sites.site_releases (site_id, number, snapshot, note, brand_hash, created_by, published_at)
672
+ values (
673
+ site_id,
674
+ coalesce((select max(r.number) from genesis_sites.site_releases r where r.site_id = publish_site.site_id), 0) + 1,
675
+ jsonb_build_object(
676
+ 'schemaVersion', 1,
677
+ 'site', site.draft
678
+ || jsonb_build_object('header', genesis_sites.visible_nodes(site.draft -> 'header'), 'footer', genesis_sites.visible_nodes(site.draft -> 'footer')),
679
+ 'pages', coalesce((
680
+ select jsonb_agg(jsonb_build_object('id', p.id, 'path', p.path, 'kind', p.kind,
681
+ 'doc', p.draft || jsonb_build_object('content', genesis_sites.visible_nodes(p.draft -> 'content')))
682
+ order by p.sort, p.path)
683
+ from genesis_sites.site_pages p where p.site_id = publish_site.site_id and p.deleted_at is null), '[]'::jsonb)
684
+ ),
685
+ nullif(btrim(note), ''),
686
+ brand_hash,
687
+ (select u.id from auth.users u where u.id = auth.uid()),
688
+ now()
689
+ )
690
+ returning * into release;
691
+ update genesis_sites.sites s set live_release_id = release.id, status = 'live' where s.id = publish_site.site_id;
692
+ return genesis_sites.release_json(release, false);
693
+ end;
694
+ $$;
695
+
696
+ -- Point the live site at an earlier release.
697
+ create or replace function genesis_sites.rollback_site(site_id uuid, release_id uuid)
698
+ returns jsonb
699
+ language plpgsql
700
+ security definer
701
+ set search_path = ''
702
+ as $$
703
+ declare
704
+ release genesis_sites.site_releases;
705
+ current_status text;
706
+ begin
707
+ perform genesis_sites.assert_can_edit(site_id);
708
+ select s.status into current_status from genesis_sites.sites s where s.id = rollback_site.site_id for update;
709
+ if not found then
710
+ raise exception 'genesis_sites: no site %', site_id using errcode = 'P0002';
711
+ end if;
712
+ -- Archiving is a takedown: an editor can't bring the site back by rolling back.
713
+ if current_status = 'archived' then
714
+ raise exception 'genesis_sites: an archived site can''t be rolled back' using errcode = '22023';
715
+ end if;
716
+ select * into release from genesis_sites.site_releases r where r.id = release_id and r.site_id = rollback_site.site_id;
717
+ if not found then
718
+ raise exception 'genesis_sites: release % isn''t one of this site''s', release_id using errcode = 'P0002';
719
+ end if;
720
+ update genesis_sites.sites s set live_release_id = release.id, status = 'live' where s.id = rollback_site.site_id;
721
+ return genesis_sites.release_json(release, false);
722
+ end;
723
+ $$;
724
+
725
+ -- Create a site with its pages (the service role: a seed, a server job, your own function).
726
+ create or replace function genesis_sites.create_site(name text, slug text, draft jsonb, pages jsonb)
727
+ returns uuid
728
+ language plpgsql
729
+ security definer
730
+ set search_path = ''
731
+ as $$
732
+ declare
733
+ new_id uuid;
734
+ page jsonb;
735
+ i integer := 0;
736
+ begin
737
+ perform genesis_sites.assert_doc(draft, 'site');
738
+ if pages is null or jsonb_typeof(pages) <> 'array' or not exists (select 1 from jsonb_array_elements(pages) p where p ->> 'path' = '/') then
739
+ raise exception 'genesis_sites: a site starts with its pages, including / ' using errcode = '22023';
740
+ end if;
741
+ insert into genesis_sites.sites (name, slug, draft) values (name, slug, draft) returning id into new_id;
742
+ for page in select * from jsonb_array_elements(pages) loop
743
+ perform genesis_sites.assert_page_path(page ->> 'path');
744
+ perform genesis_sites.assert_doc(page -> 'doc', 'page');
745
+ insert into genesis_sites.site_pages (site_id, path, kind, draft, sort)
746
+ values (new_id, page ->> 'path', coalesce(page ->> 'kind', 'page'), page -> 'doc', i);
747
+ i := i + 1;
748
+ end loop;
749
+ return new_id;
750
+ end;
751
+ $$;
752
+
753
+ -- ── The public read ───────────────────────────────────────────────────────────
754
+
755
+ -- A live site's live release, for anyone (no session needed). Drafts never,
756
+ -- and not the release note, which is for the site's editors.
757
+ create or replace function genesis_sites.live_release(site_id uuid)
758
+ returns jsonb
759
+ language sql
760
+ stable
761
+ security definer
762
+ set search_path = ''
763
+ as $$
764
+ select genesis_sites.release_json(r, true) || jsonb_build_object('note', null)
765
+ from genesis_sites.sites s
766
+ join genesis_sites.site_releases r on r.id = s.live_release_id and r.site_id = s.id
767
+ where s.id = live_release.site_id and s.status = 'live'
768
+ $$;
769
+
770
+ comment on function genesis_sites.live_release(uuid) is
771
+ 'The live release of a live site (its snapshot), or null. Public: anon may call it. Never returns drafts. Genesis Sites.';
772
+
773
+ -- ── Privileges, by name ───────────────────────────────────────────────────────
774
+
775
+ revoke all on table genesis_sites.sites, genesis_sites.site_pages, genesis_sites.site_releases, genesis_sites.site_assets from public, anon, authenticated;
776
+ grant select on table genesis_sites.sites, genesis_sites.site_pages, genesis_sites.site_releases, genesis_sites.site_assets to authenticated;
777
+ grant insert (site_id, path, mime, width, height, alt, blurhash), delete on table genesis_sites.site_assets to authenticated;
778
+ revoke all on table genesis_sites.sites, genesis_sites.site_pages, genesis_sites.site_releases, genesis_sites.site_assets from service_role;
779
+ grant select, insert, update, delete on table genesis_sites.sites, genesis_sites.site_pages, genesis_sites.site_assets to service_role;
780
+ -- Releases: the service role reads and purges them (a live one is protected by its foreign key), never rewrites them.
781
+ grant select, insert, delete on table genesis_sites.site_releases to service_role;
782
+
783
+ -- The hooks: the policies ask them as the signed-in person.
784
+ revoke execute on function genesis_sites.can_edit(uuid) from public, anon;
785
+ grant execute on function genesis_sites.can_edit(uuid) to authenticated, service_role;
786
+ revoke execute on function genesis_sites.can_view(uuid) from public, anon;
787
+ grant execute on function genesis_sites.can_view(uuid) to authenticated, service_role;
788
+ revoke execute on function genesis_sites.reserved_paths() from public, anon;
789
+ grant execute on function genesis_sites.reserved_paths() to authenticated, service_role;
790
+
791
+ revoke execute on function genesis_sites.save_page_draft(uuid, integer, jsonb) from public, anon;
792
+ grant execute on function genesis_sites.save_page_draft(uuid, integer, jsonb) to authenticated;
793
+ revoke execute on function genesis_sites.save_site_draft(uuid, integer, jsonb) from public, anon;
794
+ grant execute on function genesis_sites.save_site_draft(uuid, integer, jsonb) to authenticated;
795
+ revoke execute on function genesis_sites.create_page(uuid, text, text, jsonb) from public, anon;
796
+ grant execute on function genesis_sites.create_page(uuid, text, text, jsonb) to authenticated;
797
+ revoke execute on function genesis_sites.move_page(uuid, text) from public, anon;
798
+ grant execute on function genesis_sites.move_page(uuid, text) to authenticated;
799
+ revoke execute on function genesis_sites.delete_page(uuid) from public, anon;
800
+ grant execute on function genesis_sites.delete_page(uuid) to authenticated;
801
+ revoke execute on function genesis_sites.publish_site(uuid, integer, jsonb, text, text) from public, anon;
802
+ grant execute on function genesis_sites.publish_site(uuid, integer, jsonb, text, text) to authenticated;
803
+ revoke execute on function genesis_sites.rollback_site(uuid, uuid) from public, anon;
804
+ grant execute on function genesis_sites.rollback_site(uuid, uuid) to authenticated;
805
+ revoke execute on function genesis_sites.create_site(text, text, jsonb, jsonb) from public, anon, authenticated;
806
+ grant execute on function genesis_sites.create_site(text, text, jsonb, jsonb) to service_role;
807
+ revoke execute on function genesis_sites.live_release(uuid) from public;
808
+ grant execute on function genesis_sites.live_release(uuid) to anon, authenticated, service_role;
809
+
810
+ -- Internal: only the functions above call these.
811
+ revoke execute on function genesis_sites.assert_can_edit(uuid) from public, anon, authenticated;
812
+ revoke execute on function genesis_sites.assert_page_path(text) from public, anon, authenticated;
813
+ revoke execute on function genesis_sites.assert_not_reserved(text) from public, anon, authenticated;
814
+ revoke execute on function genesis_sites.assert_redirects(jsonb) from public, anon, authenticated;
815
+ revoke execute on function genesis_sites.assert_doc(jsonb, text) from public, anon, authenticated;
816
+ revoke execute on function genesis_sites.drop_redirect_from(uuid, text) from public, anon, authenticated;
817
+ revoke execute on function genesis_sites.release_json(genesis_sites.site_releases, boolean) from public, anon, authenticated;
818
+ revoke execute on function genesis_sites.touch() from public, anon, authenticated;
819
+ revoke execute on function genesis_sites.release_is_immutable() from public, anon, authenticated;
820
+ revoke execute on function genesis_sites.notify_served_change() from public, anon, authenticated;
821
+ revoke execute on function genesis_sites.visible_nodes(jsonb) from public, anon, authenticated;