@orion-studios/cms 0.5.1 → 0.5.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/sql/bootstrap.sql CHANGED
@@ -35,13 +35,19 @@ create table if not exists cms_pages (
35
35
  title text not null default '',
36
36
  seo jsonb not null default '{}'::jsonb,
37
37
  draft_layout jsonb not null default '[]'::jsonb,
38
+ published_title text,
39
+ published_seo jsonb,
38
40
  published_layout jsonb,
41
+ draft_version_id bigint,
42
+ published_version_id bigint,
43
+ scheduled_version_id bigint,
39
44
  status text not null default 'draft' check (status in ('draft', 'published')),
40
45
  -- true once edited in the Studio; content-as-code sync then leaves the layout alone
41
46
  builder_owned boolean not null default false,
42
47
  created_at timestamptz not null default now(),
43
48
  updated_at timestamptz not null default now(),
44
- published_at timestamptz
49
+ published_at timestamptz,
50
+ publish_at timestamptz
45
51
  );
46
52
  create index if not exists cms_pages_status_idx on cms_pages (status);
47
53
 
@@ -58,6 +64,141 @@ create table if not exists cms_page_versions (
58
64
  create index if not exists cms_page_versions_page_idx
59
65
  on cms_page_versions (page_id, created_at desc);
60
66
 
67
+ -- Additive publication columns for existing databases.
68
+ alter table cms_pages add column if not exists published_title text;
69
+ alter table cms_pages add column if not exists published_seo jsonb;
70
+ alter table cms_pages add column if not exists draft_version_id bigint;
71
+ alter table cms_pages add column if not exists published_version_id bigint;
72
+ alter table cms_pages add column if not exists scheduled_version_id bigint;
73
+ alter table cms_pages add column if not exists publish_at timestamptz;
74
+
75
+ -- Recover the already-public metadata from matching publication history.
76
+ update cms_pages
77
+ set published_layout = '[]'::jsonb
78
+ where status = 'published' and published_layout is null;
79
+
80
+ with published_history as (
81
+ select distinct on (p.id)
82
+ p.id as page_id,
83
+ v.id as version_id,
84
+ v.title,
85
+ v.seo
86
+ from cms_pages p
87
+ join cms_page_versions v
88
+ on v.page_id = p.id
89
+ and v.kind in ('publish', 'sync')
90
+ and v.layout is not distinct from p.published_layout
91
+ where p.status = 'published'
92
+ order by p.id, v.created_at desc, v.id desc
93
+ )
94
+ update cms_pages p
95
+ set published_title = h.title,
96
+ published_seo = h.seo,
97
+ published_version_id = h.version_id
98
+ from published_history h
99
+ where p.id = h.page_id
100
+ and p.published_version_id is null;
101
+
102
+ with inserted as (
103
+ insert into cms_page_versions (page_id, kind, title, seo, layout)
104
+ select id, 'publish', title, seo, published_layout
105
+ from cms_pages
106
+ where status = 'published'
107
+ and published_version_id is null
108
+ returning id, page_id, title, seo
109
+ )
110
+ update cms_pages p
111
+ set published_title = i.title,
112
+ published_seo = i.seo,
113
+ published_version_id = i.id
114
+ from inserted i
115
+ where p.id = i.page_id;
116
+
117
+ with draft_history as (
118
+ select distinct on (p.id)
119
+ p.id as page_id,
120
+ v.id as version_id
121
+ from cms_pages p
122
+ join cms_page_versions v
123
+ on v.page_id = p.id
124
+ and v.title is not distinct from p.title
125
+ and v.seo is not distinct from p.seo
126
+ and v.layout is not distinct from p.draft_layout
127
+ order by p.id, v.created_at desc, v.id desc
128
+ )
129
+ update cms_pages p
130
+ set draft_version_id = h.version_id
131
+ from draft_history h
132
+ where p.id = h.page_id
133
+ and p.draft_version_id is null;
134
+
135
+ with inserted as (
136
+ insert into cms_page_versions (page_id, kind, title, seo, layout)
137
+ select id, 'draft', title, seo, draft_layout
138
+ from cms_pages
139
+ where draft_version_id is null
140
+ returning id, page_id
141
+ )
142
+ update cms_pages p
143
+ set draft_version_id = i.id
144
+ from inserted i
145
+ where p.id = i.page_id;
146
+
147
+ -- A legacy timestamp cannot prove which draft was reviewed. Clear the
148
+ -- incomplete schedule so an editor must explicitly bind an exact version.
149
+ update cms_pages
150
+ set publish_at = null,
151
+ scheduled_version_id = null
152
+ where publish_at is not null
153
+ and scheduled_version_id is null;
154
+
155
+ create unique index if not exists cms_page_versions_page_id_id_idx
156
+ on cms_page_versions (page_id, id);
157
+ create index if not exists cms_pages_scheduled_publish_due_idx
158
+ on cms_pages (publish_at)
159
+ where publish_at is not null and scheduled_version_id is not null;
160
+
161
+ do $publication_constraints$
162
+ begin
163
+ if not exists (select 1 from pg_constraint where conname = 'cms_pages_draft_version_fk' and conrelid = 'cms_pages'::regclass) then
164
+ alter table cms_pages
165
+ add constraint cms_pages_draft_version_fk
166
+ foreign key (id, draft_version_id)
167
+ references cms_page_versions (page_id, id);
168
+ end if;
169
+ if not exists (select 1 from pg_constraint where conname = 'cms_pages_published_version_fk' and conrelid = 'cms_pages'::regclass) then
170
+ alter table cms_pages
171
+ add constraint cms_pages_published_version_fk
172
+ foreign key (id, published_version_id)
173
+ references cms_page_versions (page_id, id);
174
+ end if;
175
+ if not exists (select 1 from pg_constraint where conname = 'cms_pages_scheduled_version_fk' and conrelid = 'cms_pages'::regclass) then
176
+ alter table cms_pages
177
+ add constraint cms_pages_scheduled_version_fk
178
+ foreign key (id, scheduled_version_id)
179
+ references cms_page_versions (page_id, id);
180
+ end if;
181
+ if not exists (select 1 from pg_constraint where conname = 'cms_pages_schedule_version_pair' and conrelid = 'cms_pages'::regclass) then
182
+ alter table cms_pages
183
+ add constraint cms_pages_schedule_version_pair
184
+ check ((publish_at is null) = (scheduled_version_id is null));
185
+ end if;
186
+ if not exists (select 1 from pg_constraint where conname = 'cms_pages_published_state_complete' and conrelid = 'cms_pages'::regclass) then
187
+ alter table cms_pages
188
+ add constraint cms_pages_published_state_complete
189
+ check (
190
+ status <> 'published'
191
+ or (
192
+ published_title is not null
193
+ and published_seo is not null
194
+ and published_layout is not null
195
+ and published_version_id is not null
196
+ )
197
+ );
198
+ end if;
199
+ end
200
+ $publication_constraints$;
201
+
61
202
  create table if not exists cms_globals (
62
203
  key text primary key,
63
204
  label text not null default '',
@@ -172,6 +313,13 @@ create table if not exists cms_activity (
172
313
  );
173
314
  create index if not exists cms_activity_created_idx on cms_activity (created_at desc);
174
315
 
316
+ create table if not exists cms_rate_limits (
317
+ key text primary key,
318
+ window_start timestamptz not null default now(),
319
+ count integer not null default 0
320
+ );
321
+ create index if not exists cms_rate_limits_window_idx on cms_rate_limits (window_start);
322
+
175
323
  -- 2) Functions (atomic write paths, called via RPC with service role) --------
176
324
 
177
325
  create or replace function cms_save_page_draft(
@@ -187,36 +335,51 @@ security definer
187
335
  set search_path = public
188
336
  as $$
189
337
  declare
338
+ current_page cms_pages;
190
339
  result cms_pages;
191
- latest cms_page_versions;
340
+ version_id bigint;
341
+ next_title text;
342
+ next_seo jsonb;
343
+ next_layout jsonb;
192
344
  begin
193
- update cms_pages
194
- set title = coalesce(p_title, title),
195
- seo = coalesce(p_seo, seo),
196
- draft_layout = coalesce(p_layout, draft_layout),
197
- builder_owned = builder_owned or p_mark_builder_owned,
198
- updated_at = now()
345
+ select * into current_page
346
+ from cms_pages
199
347
  where id = p_page_id
200
- returning * into result;
348
+ for update;
201
349
 
202
- if result.id is null then
350
+ if current_page.id is null then
203
351
  raise exception 'page % not found', p_page_id;
204
352
  end if;
205
353
 
206
- -- Autosave fires every few seconds of editing; only snapshot when the
207
- -- content actually changed so history stays meaningful.
208
- select * into latest from cms_page_versions
209
- where page_id = p_page_id
210
- order by created_at desc, id desc limit 1;
354
+ next_title := coalesce(p_title, current_page.title);
355
+ next_seo := coalesce(p_seo, current_page.seo);
356
+ next_layout := coalesce(p_layout, current_page.draft_layout);
357
+
358
+ select id into version_id
359
+ from cms_page_versions
360
+ where page_id = p_page_id
361
+ and title is not distinct from next_title
362
+ and seo is not distinct from next_seo
363
+ and layout is not distinct from next_layout
364
+ order by created_at desc, id desc
365
+ limit 1;
211
366
 
212
- if latest.id is null
213
- or latest.title is distinct from result.title
214
- or latest.seo is distinct from result.seo
215
- or latest.layout is distinct from result.draft_layout then
367
+ if version_id is null then
216
368
  insert into cms_page_versions (page_id, kind, title, seo, layout, created_by)
217
- values (p_page_id, 'draft', result.title, result.seo, result.draft_layout, p_actor);
369
+ values (p_page_id, 'draft', next_title, next_seo, next_layout, p_actor)
370
+ returning id into version_id;
218
371
  end if;
219
372
 
373
+ update cms_pages
374
+ set title = next_title,
375
+ seo = next_seo,
376
+ draft_layout = next_layout,
377
+ draft_version_id = version_id,
378
+ builder_owned = builder_owned or p_mark_builder_owned,
379
+ updated_at = now()
380
+ where id = p_page_id
381
+ returning * into result;
382
+
220
383
  return result;
221
384
  end;
222
385
  $$;
@@ -229,24 +392,65 @@ language plpgsql
229
392
  security definer
230
393
  set search_path = public
231
394
  as $$
395
+ begin
396
+ raise exception 'Page version conflict: expected version is required';
397
+ end;
398
+ $$;
399
+
400
+ create or replace function cms_publish_page(
401
+ p_page_id uuid,
402
+ p_expected_version_id bigint,
403
+ p_actor uuid
404
+ ) returns cms_pages
405
+ language plpgsql
406
+ security definer
407
+ set search_path = public
408
+ as $$
232
409
  declare
410
+ current_page cms_pages;
411
+ reviewed cms_page_versions;
233
412
  result cms_pages;
234
413
  begin
414
+ select * into current_page
415
+ from cms_pages
416
+ where id = p_page_id
417
+ for update;
418
+
419
+ if current_page.id is null then
420
+ raise exception 'page % not found', p_page_id;
421
+ end if;
422
+
423
+ select * into reviewed
424
+ from cms_page_versions
425
+ where id = p_expected_version_id
426
+ and page_id = p_page_id;
427
+
428
+ if reviewed.id is null
429
+ or current_page.draft_version_id is distinct from p_expected_version_id
430
+ or reviewed.title is distinct from current_page.title
431
+ or reviewed.seo is distinct from current_page.seo
432
+ or reviewed.layout is distinct from current_page.draft_layout then
433
+ raise exception 'Page version conflict: expected draft is missing or stale';
434
+ end if;
435
+
436
+ if current_page.status = 'published'
437
+ and current_page.published_version_id = p_expected_version_id then
438
+ raise exception 'Page version conflict: expected draft is already published';
439
+ end if;
440
+
235
441
  update cms_pages
236
- set published_layout = draft_layout,
442
+ set published_title = reviewed.title,
443
+ published_seo = reviewed.seo,
444
+ published_layout = reviewed.layout,
445
+ published_version_id = reviewed.id,
237
446
  status = 'published',
238
447
  published_at = now(),
448
+ publish_at = null,
449
+ scheduled_version_id = null,
239
450
  updated_at = now()
240
451
  where id = p_page_id
241
452
  returning * into result;
242
453
 
243
- if result.id is null then
244
- raise exception 'page % not found', p_page_id;
245
- end if;
246
-
247
- insert into cms_page_versions (page_id, kind, title, seo, layout, created_by)
248
- values (p_page_id, 'publish', result.title, result.seo, result.published_layout, p_actor);
249
-
250
454
  return result;
251
455
  end;
252
456
  $$;
@@ -262,24 +466,29 @@ as $$
262
466
  declare
263
467
  v cms_page_versions;
264
468
  result cms_pages;
469
+ restored_version_id bigint;
265
470
  begin
266
471
  select * into v from cms_page_versions where id = p_version_id;
267
472
  if v.id is null then
268
473
  raise exception 'version % not found', p_version_id;
269
474
  end if;
270
475
 
476
+ perform 1 from cms_pages where id = v.page_id for update;
477
+
478
+ insert into cms_page_versions (page_id, kind, title, seo, layout, created_by)
479
+ values (v.page_id, 'restore', v.title, v.seo, v.layout, p_actor)
480
+ returning id into restored_version_id;
481
+
271
482
  update cms_pages
272
483
  set draft_layout = v.layout,
273
484
  title = v.title,
274
485
  seo = v.seo,
486
+ draft_version_id = restored_version_id,
275
487
  builder_owned = true,
276
488
  updated_at = now()
277
489
  where id = v.page_id
278
490
  returning * into result;
279
491
 
280
- insert into cms_page_versions (page_id, kind, title, seo, layout, created_by)
281
- values (v.page_id, 'restore', v.title, v.seo, v.layout, p_actor);
282
-
283
492
  return result;
284
493
  end;
285
494
  $$;
@@ -299,36 +508,234 @@ set search_path = public
299
508
  as $$
300
509
  declare
301
510
  result cms_pages;
302
- latest cms_page_versions;
511
+ version_id bigint;
512
+ is_new boolean := false;
303
513
  begin
304
- -- builder_owned pages belong to the Studio: sync only ensures they exist.
305
- -- Overwriting title/seo/path here would silently revert Studio edits
306
- -- (SEO drawer, address renames) on the next deploy sync.
307
- insert into cms_pages (slug, path, title, seo, draft_layout, published_layout, status, published_at)
308
- values (p_slug, p_path, p_title, p_seo, p_layout, p_layout, 'published', now())
309
- on conflict (slug) do update
310
- set path = case when cms_pages.builder_owned then cms_pages.path else excluded.path end,
311
- title = case when cms_pages.builder_owned then cms_pages.title else excluded.title end,
312
- seo = case when cms_pages.builder_owned then cms_pages.seo else excluded.seo end,
313
- draft_layout = case when cms_pages.builder_owned then cms_pages.draft_layout else excluded.draft_layout end,
314
- published_layout = case when cms_pages.builder_owned then cms_pages.published_layout else excluded.published_layout end,
315
- updated_at = case when cms_pages.builder_owned then cms_pages.updated_at else now() end
514
+ insert into cms_pages (
515
+ slug, path, title, seo, draft_layout,
516
+ published_title, published_seo, published_layout,
517
+ status, published_at
518
+ )
519
+ values (
520
+ p_slug, p_path, p_title, p_seo, p_layout,
521
+ p_title, p_seo, p_layout,
522
+ 'draft', null
523
+ )
524
+ on conflict (slug) do nothing
316
525
  returning * into result;
317
526
 
318
- -- Snapshot only when the sync actually changed something (idempotent
319
- -- re-syncs and builder-owned pages would otherwise pile up noise rows).
320
- select * into latest from cms_page_versions
321
- where page_id = result.id
322
- order by created_at desc, id desc limit 1;
527
+ if result.id is not null then
528
+ is_new := true;
529
+ else
530
+ select * into result
531
+ from cms_pages
532
+ where slug = p_slug
533
+ for update;
534
+
535
+ -- Builder-owned pages belong to the Studio. Sync only ensures they exist.
536
+ if not result.builder_owned then
537
+ update cms_pages
538
+ set path = p_path,
539
+ title = p_title,
540
+ seo = p_seo,
541
+ draft_layout = p_layout,
542
+ published_title = p_title,
543
+ published_seo = p_seo,
544
+ published_layout = p_layout,
545
+ updated_at = now()
546
+ where id = result.id
547
+ returning * into result;
548
+ end if;
549
+ end if;
550
+
551
+ select id into version_id
552
+ from cms_page_versions
553
+ where page_id = result.id
554
+ and title is not distinct from result.title
555
+ and seo is not distinct from result.seo
556
+ and layout is not distinct from result.draft_layout
557
+ order by created_at desc, id desc
558
+ limit 1;
323
559
 
324
- if latest.id is null
325
- or latest.title is distinct from result.title
326
- or latest.seo is distinct from result.seo
327
- or latest.layout is distinct from result.draft_layout then
560
+ if version_id is null then
328
561
  insert into cms_page_versions (page_id, kind, title, seo, layout)
329
- values (result.id, 'sync', result.title, result.seo, result.draft_layout);
562
+ values (result.id, 'sync', result.title, result.seo, result.draft_layout)
563
+ returning id into version_id;
564
+ end if;
565
+
566
+ if not result.builder_owned then
567
+ update cms_pages
568
+ set draft_version_id = version_id,
569
+ published_version_id = version_id,
570
+ status = case when is_new then 'published' else status end,
571
+ published_at = case when is_new then now() else published_at end
572
+ where id = result.id
573
+ returning * into result;
574
+ end if;
575
+
576
+ return result;
577
+ end;
578
+ $$;
579
+
580
+ create or replace function cms_schedule_page_publish(
581
+ p_page_id uuid,
582
+ p_expected_version_id bigint,
583
+ p_publish_at timestamptz,
584
+ p_actor uuid
585
+ ) returns cms_pages
586
+ language plpgsql
587
+ security definer
588
+ set search_path = public
589
+ as $$
590
+ declare
591
+ current_page cms_pages;
592
+ reviewed cms_page_versions;
593
+ result cms_pages;
594
+ begin
595
+ select * into current_page
596
+ from cms_pages
597
+ where id = p_page_id
598
+ for update;
599
+
600
+ if current_page.id is null then
601
+ raise exception 'page % not found', p_page_id;
602
+ end if;
603
+
604
+ if p_publish_at is null then
605
+ update cms_pages
606
+ set publish_at = null,
607
+ scheduled_version_id = null,
608
+ updated_at = now()
609
+ where id = p_page_id
610
+ returning * into result;
611
+ return result;
330
612
  end if;
331
613
 
614
+ select * into reviewed
615
+ from cms_page_versions
616
+ where id = p_expected_version_id
617
+ and page_id = p_page_id;
618
+
619
+ if reviewed.id is null
620
+ or current_page.draft_version_id is distinct from p_expected_version_id
621
+ or reviewed.title is distinct from current_page.title
622
+ or reviewed.seo is distinct from current_page.seo
623
+ or reviewed.layout is distinct from current_page.draft_layout then
624
+ raise exception 'Page version conflict: expected draft is missing or stale';
625
+ end if;
626
+
627
+ update cms_pages
628
+ set publish_at = p_publish_at,
629
+ scheduled_version_id = reviewed.id,
630
+ updated_at = now()
631
+ where id = p_page_id
632
+ returning * into result;
633
+
634
+ return result;
635
+ end;
636
+ $$;
637
+
638
+ create or replace function cms_publish_due_pages()
639
+ returns setof text
640
+ language plpgsql
641
+ security definer
642
+ set search_path = public
643
+ as $$
644
+ declare
645
+ due record;
646
+ begin
647
+ for due in
648
+ select
649
+ p.id,
650
+ p.path,
651
+ v.id as version_id,
652
+ v.title,
653
+ v.seo,
654
+ v.layout
655
+ from cms_pages p
656
+ join cms_page_versions v
657
+ on v.page_id = p.id
658
+ and v.id = p.scheduled_version_id
659
+ where p.publish_at is not null
660
+ and p.publish_at <= now()
661
+ and p.scheduled_version_id is not null
662
+ order by p.publish_at
663
+ for update of p skip locked
664
+ loop
665
+ update cms_pages
666
+ set published_title = due.title,
667
+ published_seo = due.seo,
668
+ published_layout = due.layout,
669
+ published_version_id = due.version_id,
670
+ status = 'published',
671
+ published_at = now(),
672
+ publish_at = null,
673
+ scheduled_version_id = null,
674
+ updated_at = now()
675
+ where id = due.id;
676
+
677
+ insert into cms_activity (action, subject)
678
+ values ('page.publish.scheduled', due.path);
679
+
680
+ return next due.path;
681
+ end loop;
682
+ return;
683
+ end;
684
+ $$;
685
+
686
+ create or replace function cms_rate_limit_consume(
687
+ p_key text,
688
+ p_max integer,
689
+ p_window_seconds integer
690
+ ) returns boolean
691
+ language plpgsql
692
+ security definer
693
+ set search_path = public
694
+ as $$
695
+ declare
696
+ v_count integer;
697
+ begin
698
+ insert into cms_rate_limits (key, window_start, count)
699
+ values (p_key, now(), 1)
700
+ on conflict (key) do update
701
+ set count = case
702
+ when cms_rate_limits.window_start < now() - make_interval(secs => p_window_seconds)
703
+ then 1
704
+ else cms_rate_limits.count + 1
705
+ end,
706
+ window_start = case
707
+ when cms_rate_limits.window_start < now() - make_interval(secs => p_window_seconds)
708
+ then now()
709
+ else cms_rate_limits.window_start
710
+ end
711
+ returning count into v_count;
712
+
713
+ return v_count <= p_max;
714
+ end;
715
+ $$;
716
+
717
+ create or replace function cms_update_global(
718
+ p_key text,
719
+ p_data jsonb,
720
+ p_actor uuid default null
721
+ ) returns cms_globals
722
+ language plpgsql
723
+ security definer
724
+ set search_path = public
725
+ as $$
726
+ declare
727
+ result cms_globals;
728
+ begin
729
+ insert into cms_globals (key, data, updated_at)
730
+ values (p_key, p_data, now())
731
+ on conflict (key) do update
732
+ set data = excluded.data,
733
+ updated_at = now()
734
+ returning * into result;
735
+
736
+ insert into cms_global_versions (key, data, created_by)
737
+ values (p_key, p_data, p_actor);
738
+
332
739
  return result;
333
740
  end;
334
741
  $$;
@@ -349,6 +756,7 @@ alter table cms_global_versions enable row level security;
349
756
  alter table cms_redirects enable row level security;
350
757
  alter table cms_activity enable row level security;
351
758
  alter table cms_events enable row level security;
759
+ alter table cms_rate_limits enable row level security;
352
760
  -- cms_events: no anon policies or grants — ingest and reads go through the
353
761
  -- API layer only.
354
762
 
@@ -402,8 +810,13 @@ declare
402
810
  fns text[] := array[
403
811
  'cms_save_page_draft(uuid, text, jsonb, jsonb, uuid, boolean)',
404
812
  'cms_publish_page(uuid, uuid)',
813
+ 'cms_publish_page(uuid, bigint, uuid)',
814
+ 'cms_schedule_page_publish(uuid, bigint, timestamptz, uuid)',
405
815
  'cms_restore_page_version(bigint, uuid)',
406
- 'cms_sync_page(text, text, text, jsonb, jsonb)'
816
+ 'cms_sync_page(text, text, text, jsonb, jsonb)',
817
+ 'cms_publish_due_pages()',
818
+ 'cms_rate_limit_consume(text, integer, integer)',
819
+ 'cms_update_global(text, jsonb, uuid)'
407
820
  ];
408
821
  begin
409
822
  foreach fn in array fns loop
@@ -425,23 +838,27 @@ end $fn_lockdown$;
425
838
  -- privileges only cover tables created by `postgres`; when bootstrap runs as a
426
839
  -- site-specific role, the PostgREST roles get nothing — so grant explicitly.
427
840
  -- Wrapped per-role so bootstrap also works on plain Postgres (dev/tests).
841
+ revoke update, delete on cms_page_versions from public;
428
842
  do $grants$
429
843
  begin
430
844
  if exists (select 1 from pg_roles where rolname = 'service_role') then
431
845
  grant select, insert, update, delete on
432
- cms_settings, cms_profiles, cms_pages, cms_page_versions,
846
+ cms_settings, cms_profiles, cms_pages,
433
847
  cms_globals, cms_media, cms_forms, cms_form_submissions,
434
848
  cms_global_versions, cms_redirects, cms_activity, cms_events
435
849
  to service_role;
850
+ revoke update, delete on cms_page_versions from service_role;
851
+ grant select, insert on cms_page_versions to service_role;
436
852
  end if;
437
853
 
438
- -- cms_pages and cms_forms get COLUMN-level read grants: anonymous readers
439
- -- must never see draft_layout (unpublished work) or notify (staff emails).
854
+ -- cms_pages and cms_forms get column-level read grants. Anonymous readers
855
+ -- see published page state only and never form notification addresses.
440
856
  if exists (select 1 from pg_roles where rolname = 'anon') then
441
857
  grant select on cms_globals, cms_media, cms_redirects to anon;
442
858
  revoke select on cms_pages from anon;
443
- grant select (id, slug, path, title, seo, status, published_layout, published_at, created_at, updated_at)
859
+ grant select (id, slug, path, published_title, published_seo, status, published_layout, published_at, created_at, updated_at)
444
860
  on cms_pages to anon;
861
+ revoke all on cms_page_versions from anon;
445
862
  revoke select on cms_forms from anon;
446
863
  grant select (id, slug, title, config, success_message, created_at, updated_at)
447
864
  on cms_forms to anon;
@@ -449,8 +866,9 @@ begin
449
866
  if exists (select 1 from pg_roles where rolname = 'authenticated') then
450
867
  grant select on cms_globals, cms_media, cms_profiles, cms_redirects to authenticated;
451
868
  revoke select on cms_pages from authenticated;
452
- grant select (id, slug, path, title, seo, status, published_layout, published_at, created_at, updated_at)
869
+ grant select (id, slug, path, published_title, published_seo, status, published_layout, published_at, created_at, updated_at)
453
870
  on cms_pages to authenticated;
871
+ revoke all on cms_page_versions from authenticated;
454
872
  revoke select on cms_forms from authenticated;
455
873
  grant select (id, slug, title, config, success_message, created_at, updated_at)
456
874
  on cms_forms to authenticated;