@orion-studios/cms 0.5.1 → 0.5.3

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,151 @@ 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
+ -- A published version is not a future publication candidate. Clear invalid
156
+ -- schedules left by versions of the scheduling function that omitted this
157
+ -- guard, so repeated bootstrap runs repair existing databases fail closed.
158
+ update cms_pages
159
+ set publish_at = null,
160
+ scheduled_version_id = null,
161
+ updated_at = now()
162
+ where scheduled_version_id is not null
163
+ and scheduled_version_id = published_version_id;
164
+
165
+ create unique index if not exists cms_page_versions_page_id_id_idx
166
+ on cms_page_versions (page_id, id);
167
+ create index if not exists cms_pages_scheduled_publish_due_idx
168
+ on cms_pages (publish_at)
169
+ where publish_at is not null and scheduled_version_id is not null;
170
+
171
+ do $publication_constraints$
172
+ begin
173
+ if not exists (select 1 from pg_constraint where conname = 'cms_pages_draft_version_fk' and conrelid = 'cms_pages'::regclass) then
174
+ alter table cms_pages
175
+ add constraint cms_pages_draft_version_fk
176
+ foreign key (id, draft_version_id)
177
+ references cms_page_versions (page_id, id);
178
+ end if;
179
+ if not exists (select 1 from pg_constraint where conname = 'cms_pages_published_version_fk' and conrelid = 'cms_pages'::regclass) then
180
+ alter table cms_pages
181
+ add constraint cms_pages_published_version_fk
182
+ foreign key (id, published_version_id)
183
+ references cms_page_versions (page_id, id);
184
+ end if;
185
+ if not exists (select 1 from pg_constraint where conname = 'cms_pages_scheduled_version_fk' and conrelid = 'cms_pages'::regclass) then
186
+ alter table cms_pages
187
+ add constraint cms_pages_scheduled_version_fk
188
+ foreign key (id, scheduled_version_id)
189
+ references cms_page_versions (page_id, id);
190
+ end if;
191
+ if not exists (select 1 from pg_constraint where conname = 'cms_pages_schedule_version_pair' and conrelid = 'cms_pages'::regclass) then
192
+ alter table cms_pages
193
+ add constraint cms_pages_schedule_version_pair
194
+ check ((publish_at is null) = (scheduled_version_id is null));
195
+ end if;
196
+ if not exists (select 1 from pg_constraint where conname = 'cms_pages_published_state_complete' and conrelid = 'cms_pages'::regclass) then
197
+ alter table cms_pages
198
+ add constraint cms_pages_published_state_complete
199
+ check (
200
+ status <> 'published'
201
+ or (
202
+ published_title is not null
203
+ and published_seo is not null
204
+ and published_layout is not null
205
+ and published_version_id is not null
206
+ )
207
+ );
208
+ end if;
209
+ end
210
+ $publication_constraints$;
211
+
61
212
  create table if not exists cms_globals (
62
213
  key text primary key,
63
214
  label text not null default '',
@@ -172,6 +323,13 @@ create table if not exists cms_activity (
172
323
  );
173
324
  create index if not exists cms_activity_created_idx on cms_activity (created_at desc);
174
325
 
326
+ create table if not exists cms_rate_limits (
327
+ key text primary key,
328
+ window_start timestamptz not null default now(),
329
+ count integer not null default 0
330
+ );
331
+ create index if not exists cms_rate_limits_window_idx on cms_rate_limits (window_start);
332
+
175
333
  -- 2) Functions (atomic write paths, called via RPC with service role) --------
176
334
 
177
335
  create or replace function cms_save_page_draft(
@@ -187,36 +345,51 @@ security definer
187
345
  set search_path = public
188
346
  as $$
189
347
  declare
348
+ current_page cms_pages;
190
349
  result cms_pages;
191
- latest cms_page_versions;
350
+ version_id bigint;
351
+ next_title text;
352
+ next_seo jsonb;
353
+ next_layout jsonb;
192
354
  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()
355
+ select * into current_page
356
+ from cms_pages
199
357
  where id = p_page_id
200
- returning * into result;
358
+ for update;
201
359
 
202
- if result.id is null then
360
+ if current_page.id is null then
203
361
  raise exception 'page % not found', p_page_id;
204
362
  end if;
205
363
 
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;
364
+ next_title := coalesce(p_title, current_page.title);
365
+ next_seo := coalesce(p_seo, current_page.seo);
366
+ next_layout := coalesce(p_layout, current_page.draft_layout);
367
+
368
+ select id into version_id
369
+ from cms_page_versions
370
+ where page_id = p_page_id
371
+ and title is not distinct from next_title
372
+ and seo is not distinct from next_seo
373
+ and layout is not distinct from next_layout
374
+ order by created_at desc, id desc
375
+ limit 1;
211
376
 
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
377
+ if version_id is null then
216
378
  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);
379
+ values (p_page_id, 'draft', next_title, next_seo, next_layout, p_actor)
380
+ returning id into version_id;
218
381
  end if;
219
382
 
383
+ update cms_pages
384
+ set title = next_title,
385
+ seo = next_seo,
386
+ draft_layout = next_layout,
387
+ draft_version_id = version_id,
388
+ builder_owned = builder_owned or p_mark_builder_owned,
389
+ updated_at = now()
390
+ where id = p_page_id
391
+ returning * into result;
392
+
220
393
  return result;
221
394
  end;
222
395
  $$;
@@ -229,24 +402,65 @@ language plpgsql
229
402
  security definer
230
403
  set search_path = public
231
404
  as $$
405
+ begin
406
+ raise exception 'Page version conflict: expected version is required';
407
+ end;
408
+ $$;
409
+
410
+ create or replace function cms_publish_page(
411
+ p_page_id uuid,
412
+ p_expected_version_id bigint,
413
+ p_actor uuid
414
+ ) returns cms_pages
415
+ language plpgsql
416
+ security definer
417
+ set search_path = public
418
+ as $$
232
419
  declare
420
+ current_page cms_pages;
421
+ reviewed cms_page_versions;
233
422
  result cms_pages;
234
423
  begin
424
+ select * into current_page
425
+ from cms_pages
426
+ where id = p_page_id
427
+ for update;
428
+
429
+ if current_page.id is null then
430
+ raise exception 'page % not found', p_page_id;
431
+ end if;
432
+
433
+ select * into reviewed
434
+ from cms_page_versions
435
+ where id = p_expected_version_id
436
+ and page_id = p_page_id;
437
+
438
+ if reviewed.id is null
439
+ or current_page.draft_version_id is distinct from p_expected_version_id
440
+ or reviewed.title is distinct from current_page.title
441
+ or reviewed.seo is distinct from current_page.seo
442
+ or reviewed.layout is distinct from current_page.draft_layout then
443
+ raise exception 'Page version conflict: expected draft is missing or stale';
444
+ end if;
445
+
446
+ if current_page.status = 'published'
447
+ and current_page.published_version_id = p_expected_version_id then
448
+ raise exception 'Page version conflict: expected draft is already published';
449
+ end if;
450
+
235
451
  update cms_pages
236
- set published_layout = draft_layout,
452
+ set published_title = reviewed.title,
453
+ published_seo = reviewed.seo,
454
+ published_layout = reviewed.layout,
455
+ published_version_id = reviewed.id,
237
456
  status = 'published',
238
457
  published_at = now(),
458
+ publish_at = null,
459
+ scheduled_version_id = null,
239
460
  updated_at = now()
240
461
  where id = p_page_id
241
462
  returning * into result;
242
463
 
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
464
  return result;
251
465
  end;
252
466
  $$;
@@ -262,24 +476,29 @@ as $$
262
476
  declare
263
477
  v cms_page_versions;
264
478
  result cms_pages;
479
+ restored_version_id bigint;
265
480
  begin
266
481
  select * into v from cms_page_versions where id = p_version_id;
267
482
  if v.id is null then
268
483
  raise exception 'version % not found', p_version_id;
269
484
  end if;
270
485
 
486
+ perform 1 from cms_pages where id = v.page_id for update;
487
+
488
+ insert into cms_page_versions (page_id, kind, title, seo, layout, created_by)
489
+ values (v.page_id, 'restore', v.title, v.seo, v.layout, p_actor)
490
+ returning id into restored_version_id;
491
+
271
492
  update cms_pages
272
493
  set draft_layout = v.layout,
273
494
  title = v.title,
274
495
  seo = v.seo,
496
+ draft_version_id = restored_version_id,
275
497
  builder_owned = true,
276
498
  updated_at = now()
277
499
  where id = v.page_id
278
500
  returning * into result;
279
501
 
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
502
  return result;
284
503
  end;
285
504
  $$;
@@ -299,36 +518,238 @@ set search_path = public
299
518
  as $$
300
519
  declare
301
520
  result cms_pages;
302
- latest cms_page_versions;
521
+ version_id bigint;
522
+ is_new boolean := false;
303
523
  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
524
+ insert into cms_pages (
525
+ slug, path, title, seo, draft_layout,
526
+ published_title, published_seo, published_layout,
527
+ status, published_at
528
+ )
529
+ values (
530
+ p_slug, p_path, p_title, p_seo, p_layout,
531
+ p_title, p_seo, p_layout,
532
+ 'draft', null
533
+ )
534
+ on conflict (slug) do nothing
316
535
  returning * into result;
317
536
 
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;
537
+ if result.id is not null then
538
+ is_new := true;
539
+ else
540
+ select * into result
541
+ from cms_pages
542
+ where slug = p_slug
543
+ for update;
544
+
545
+ -- Builder-owned pages belong to the Studio. Sync only ensures they exist.
546
+ if not result.builder_owned then
547
+ update cms_pages
548
+ set path = p_path,
549
+ title = p_title,
550
+ seo = p_seo,
551
+ draft_layout = p_layout,
552
+ published_title = p_title,
553
+ published_seo = p_seo,
554
+ published_layout = p_layout,
555
+ updated_at = now()
556
+ where id = result.id
557
+ returning * into result;
558
+ end if;
559
+ end if;
323
560
 
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
561
+ select id into version_id
562
+ from cms_page_versions
563
+ where page_id = result.id
564
+ and title is not distinct from result.title
565
+ and seo is not distinct from result.seo
566
+ and layout is not distinct from result.draft_layout
567
+ order by created_at desc, id desc
568
+ limit 1;
569
+
570
+ if version_id is null then
328
571
  insert into cms_page_versions (page_id, kind, title, seo, layout)
329
- values (result.id, 'sync', result.title, result.seo, result.draft_layout);
572
+ values (result.id, 'sync', result.title, result.seo, result.draft_layout)
573
+ returning id into version_id;
574
+ end if;
575
+
576
+ if not result.builder_owned then
577
+ update cms_pages
578
+ set draft_version_id = version_id,
579
+ published_version_id = version_id,
580
+ status = case when is_new then 'published' else status end,
581
+ published_at = case when is_new then now() else published_at end
582
+ where id = result.id
583
+ returning * into result;
584
+ end if;
585
+
586
+ return result;
587
+ end;
588
+ $$;
589
+
590
+ create or replace function cms_schedule_page_publish(
591
+ p_page_id uuid,
592
+ p_expected_version_id bigint,
593
+ p_publish_at timestamptz,
594
+ p_actor uuid
595
+ ) returns cms_pages
596
+ language plpgsql
597
+ security definer
598
+ set search_path = public
599
+ as $$
600
+ declare
601
+ current_page cms_pages;
602
+ reviewed cms_page_versions;
603
+ result cms_pages;
604
+ begin
605
+ select * into current_page
606
+ from cms_pages
607
+ where id = p_page_id
608
+ for update;
609
+
610
+ if current_page.id is null then
611
+ raise exception 'page % not found', p_page_id;
612
+ end if;
613
+
614
+ if p_publish_at is null then
615
+ update cms_pages
616
+ set publish_at = null,
617
+ scheduled_version_id = null,
618
+ updated_at = now()
619
+ where id = p_page_id
620
+ returning * into result;
621
+ return result;
622
+ end if;
623
+
624
+ select * into reviewed
625
+ from cms_page_versions
626
+ where id = p_expected_version_id
627
+ and page_id = p_page_id;
628
+
629
+ if reviewed.id is null
630
+ or current_page.draft_version_id is distinct from p_expected_version_id
631
+ or reviewed.title is distinct from current_page.title
632
+ or reviewed.seo is distinct from current_page.seo
633
+ or reviewed.layout is distinct from current_page.draft_layout then
634
+ raise exception 'Page version conflict: expected draft is missing or stale';
635
+ end if;
636
+
637
+ if current_page.published_version_id = p_expected_version_id then
638
+ raise exception 'Page version conflict: expected draft is already published';
330
639
  end if;
331
640
 
641
+ update cms_pages
642
+ set publish_at = p_publish_at,
643
+ scheduled_version_id = reviewed.id,
644
+ updated_at = now()
645
+ where id = p_page_id
646
+ returning * into result;
647
+
648
+ return result;
649
+ end;
650
+ $$;
651
+
652
+ create or replace function cms_publish_due_pages()
653
+ returns setof text
654
+ language plpgsql
655
+ security definer
656
+ set search_path = public
657
+ as $$
658
+ declare
659
+ due record;
660
+ begin
661
+ for due in
662
+ select
663
+ p.id,
664
+ p.path,
665
+ v.id as version_id,
666
+ v.title,
667
+ v.seo,
668
+ v.layout
669
+ from cms_pages p
670
+ join cms_page_versions v
671
+ on v.page_id = p.id
672
+ and v.id = p.scheduled_version_id
673
+ where p.publish_at is not null
674
+ and p.publish_at <= now()
675
+ and p.scheduled_version_id is not null
676
+ order by p.publish_at
677
+ for update of p skip locked
678
+ loop
679
+ update cms_pages
680
+ set published_title = due.title,
681
+ published_seo = due.seo,
682
+ published_layout = due.layout,
683
+ published_version_id = due.version_id,
684
+ status = 'published',
685
+ published_at = now(),
686
+ publish_at = null,
687
+ scheduled_version_id = null,
688
+ updated_at = now()
689
+ where id = due.id;
690
+
691
+ insert into cms_activity (action, subject)
692
+ values ('page.publish.scheduled', due.path);
693
+
694
+ return next due.path;
695
+ end loop;
696
+ return;
697
+ end;
698
+ $$;
699
+
700
+ create or replace function cms_rate_limit_consume(
701
+ p_key text,
702
+ p_max integer,
703
+ p_window_seconds integer
704
+ ) returns boolean
705
+ language plpgsql
706
+ security definer
707
+ set search_path = public
708
+ as $$
709
+ declare
710
+ v_count integer;
711
+ begin
712
+ insert into cms_rate_limits (key, window_start, count)
713
+ values (p_key, now(), 1)
714
+ on conflict (key) do update
715
+ set count = case
716
+ when cms_rate_limits.window_start < now() - make_interval(secs => p_window_seconds)
717
+ then 1
718
+ else cms_rate_limits.count + 1
719
+ end,
720
+ window_start = case
721
+ when cms_rate_limits.window_start < now() - make_interval(secs => p_window_seconds)
722
+ then now()
723
+ else cms_rate_limits.window_start
724
+ end
725
+ returning count into v_count;
726
+
727
+ return v_count <= p_max;
728
+ end;
729
+ $$;
730
+
731
+ create or replace function cms_update_global(
732
+ p_key text,
733
+ p_data jsonb,
734
+ p_actor uuid default null
735
+ ) returns cms_globals
736
+ language plpgsql
737
+ security definer
738
+ set search_path = public
739
+ as $$
740
+ declare
741
+ result cms_globals;
742
+ begin
743
+ insert into cms_globals (key, data, updated_at)
744
+ values (p_key, p_data, now())
745
+ on conflict (key) do update
746
+ set data = excluded.data,
747
+ updated_at = now()
748
+ returning * into result;
749
+
750
+ insert into cms_global_versions (key, data, created_by)
751
+ values (p_key, p_data, p_actor);
752
+
332
753
  return result;
333
754
  end;
334
755
  $$;
@@ -349,6 +770,7 @@ alter table cms_global_versions enable row level security;
349
770
  alter table cms_redirects enable row level security;
350
771
  alter table cms_activity enable row level security;
351
772
  alter table cms_events enable row level security;
773
+ alter table cms_rate_limits enable row level security;
352
774
  -- cms_events: no anon policies or grants — ingest and reads go through the
353
775
  -- API layer only.
354
776
 
@@ -402,8 +824,13 @@ declare
402
824
  fns text[] := array[
403
825
  'cms_save_page_draft(uuid, text, jsonb, jsonb, uuid, boolean)',
404
826
  'cms_publish_page(uuid, uuid)',
827
+ 'cms_publish_page(uuid, bigint, uuid)',
828
+ 'cms_schedule_page_publish(uuid, bigint, timestamptz, uuid)',
405
829
  'cms_restore_page_version(bigint, uuid)',
406
- 'cms_sync_page(text, text, text, jsonb, jsonb)'
830
+ 'cms_sync_page(text, text, text, jsonb, jsonb)',
831
+ 'cms_publish_due_pages()',
832
+ 'cms_rate_limit_consume(text, integer, integer)',
833
+ 'cms_update_global(text, jsonb, uuid)'
407
834
  ];
408
835
  begin
409
836
  foreach fn in array fns loop
@@ -425,23 +852,27 @@ end $fn_lockdown$;
425
852
  -- privileges only cover tables created by `postgres`; when bootstrap runs as a
426
853
  -- site-specific role, the PostgREST roles get nothing — so grant explicitly.
427
854
  -- Wrapped per-role so bootstrap also works on plain Postgres (dev/tests).
855
+ revoke update, delete on cms_page_versions from public;
428
856
  do $grants$
429
857
  begin
430
858
  if exists (select 1 from pg_roles where rolname = 'service_role') then
431
859
  grant select, insert, update, delete on
432
- cms_settings, cms_profiles, cms_pages, cms_page_versions,
860
+ cms_settings, cms_profiles, cms_pages,
433
861
  cms_globals, cms_media, cms_forms, cms_form_submissions,
434
862
  cms_global_versions, cms_redirects, cms_activity, cms_events
435
863
  to service_role;
864
+ revoke update, delete on cms_page_versions from service_role;
865
+ grant select, insert on cms_page_versions to service_role;
436
866
  end if;
437
867
 
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).
868
+ -- cms_pages and cms_forms get column-level read grants. Anonymous readers
869
+ -- see published page state only and never form notification addresses.
440
870
  if exists (select 1 from pg_roles where rolname = 'anon') then
441
871
  grant select on cms_globals, cms_media, cms_redirects to anon;
442
872
  revoke select on cms_pages from anon;
443
- grant select (id, slug, path, title, seo, status, published_layout, published_at, created_at, updated_at)
873
+ grant select (id, slug, path, published_title, published_seo, status, published_layout, published_at, created_at, updated_at)
444
874
  on cms_pages to anon;
875
+ revoke all on cms_page_versions from anon;
445
876
  revoke select on cms_forms from anon;
446
877
  grant select (id, slug, title, config, success_message, created_at, updated_at)
447
878
  on cms_forms to anon;
@@ -449,8 +880,9 @@ begin
449
880
  if exists (select 1 from pg_roles where rolname = 'authenticated') then
450
881
  grant select on cms_globals, cms_media, cms_profiles, cms_redirects to authenticated;
451
882
  revoke select on cms_pages from authenticated;
452
- grant select (id, slug, path, title, seo, status, published_layout, published_at, created_at, updated_at)
883
+ grant select (id, slug, path, published_title, published_seo, status, published_layout, published_at, created_at, updated_at)
453
884
  on cms_pages to authenticated;
885
+ revoke all on cms_page_versions from authenticated;
454
886
  revoke select on cms_forms from authenticated;
455
887
  grant select (id, slug, title, config, success_message, created_at, updated_at)
456
888
  on cms_forms to authenticated;