@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.
@@ -0,0 +1,575 @@
1
+ -- SEC-003: exact-version publication and published metadata separation.
2
+
3
+ alter table cms_pages add column if not exists published_title text;
4
+ alter table cms_pages add column if not exists published_seo jsonb;
5
+ alter table cms_pages add column if not exists draft_version_id bigint;
6
+ alter table cms_pages add column if not exists published_version_id bigint;
7
+ alter table cms_pages add column if not exists scheduled_version_id bigint;
8
+
9
+ -- Existing public metadata comes from the newest publish or sync snapshot
10
+ -- whose layout matches what is already public. This avoids promoting a later
11
+ -- draft title or SEO value during migration.
12
+ update cms_pages
13
+ set published_layout = '[]'::jsonb
14
+ where status = 'published' and published_layout is null;
15
+
16
+ with published_history as (
17
+ select distinct on (p.id)
18
+ p.id as page_id,
19
+ v.id as version_id,
20
+ v.title,
21
+ v.seo
22
+ from cms_pages p
23
+ join cms_page_versions v
24
+ on v.page_id = p.id
25
+ and v.kind in ('publish', 'sync')
26
+ and v.layout is not distinct from p.published_layout
27
+ where p.status = 'published'
28
+ order by p.id, v.created_at desc, v.id desc
29
+ )
30
+ update cms_pages p
31
+ set published_title = h.title,
32
+ published_seo = h.seo,
33
+ published_version_id = h.version_id
34
+ from published_history h
35
+ where p.id = h.page_id
36
+ and p.published_version_id is null;
37
+
38
+ -- If no publication history exists, freeze the metadata and layout that were
39
+ -- already public and create an immutable baseline version for them.
40
+ with inserted as (
41
+ insert into cms_page_versions (page_id, kind, title, seo, layout)
42
+ select id, 'publish', title, seo, published_layout
43
+ from cms_pages
44
+ where status = 'published'
45
+ and published_version_id is null
46
+ returning id, page_id, title, seo
47
+ )
48
+ update cms_pages p
49
+ set published_title = i.title,
50
+ published_seo = i.seo,
51
+ published_version_id = i.id
52
+ from inserted i
53
+ where p.id = i.page_id;
54
+
55
+ -- Bind every current draft to an existing exact snapshot when possible.
56
+ with draft_history as (
57
+ select distinct on (p.id)
58
+ p.id as page_id,
59
+ v.id as version_id
60
+ from cms_pages p
61
+ join cms_page_versions v
62
+ on v.page_id = p.id
63
+ and v.title is not distinct from p.title
64
+ and v.seo is not distinct from p.seo
65
+ and v.layout is not distinct from p.draft_layout
66
+ order by p.id, v.created_at desc, v.id desc
67
+ )
68
+ update cms_pages p
69
+ set draft_version_id = h.version_id
70
+ from draft_history h
71
+ where p.id = h.page_id
72
+ and p.draft_version_id is null;
73
+
74
+ with inserted as (
75
+ insert into cms_page_versions (page_id, kind, title, seo, layout)
76
+ select id, 'draft', title, seo, draft_layout
77
+ from cms_pages
78
+ where draft_version_id is null
79
+ returning id, page_id
80
+ )
81
+ update cms_pages p
82
+ set draft_version_id = i.id
83
+ from inserted i
84
+ where p.id = i.page_id;
85
+
86
+ -- A legacy timestamp cannot prove which draft was reviewed. Clear the
87
+ -- incomplete schedule so an editor must explicitly bind an exact version.
88
+ update cms_pages
89
+ set publish_at = null,
90
+ scheduled_version_id = null
91
+ where publish_at is not null
92
+ and scheduled_version_id is null;
93
+
94
+ create unique index if not exists cms_page_versions_page_id_id_idx
95
+ on cms_page_versions (page_id, id);
96
+ create index if not exists cms_pages_scheduled_publish_due_idx
97
+ on cms_pages (publish_at)
98
+ where publish_at is not null and scheduled_version_id is not null;
99
+
100
+ do $constraints$
101
+ begin
102
+ if not exists (
103
+ select 1 from pg_constraint where conname = 'cms_pages_draft_version_fk' and conrelid = 'cms_pages'::regclass
104
+ ) then
105
+ alter table cms_pages
106
+ add constraint cms_pages_draft_version_fk
107
+ foreign key (id, draft_version_id)
108
+ references cms_page_versions (page_id, id);
109
+ end if;
110
+
111
+ if not exists (
112
+ select 1 from pg_constraint where conname = 'cms_pages_published_version_fk' and conrelid = 'cms_pages'::regclass
113
+ ) then
114
+ alter table cms_pages
115
+ add constraint cms_pages_published_version_fk
116
+ foreign key (id, published_version_id)
117
+ references cms_page_versions (page_id, id);
118
+ end if;
119
+
120
+ if not exists (
121
+ select 1 from pg_constraint where conname = 'cms_pages_scheduled_version_fk' and conrelid = 'cms_pages'::regclass
122
+ ) then
123
+ alter table cms_pages
124
+ add constraint cms_pages_scheduled_version_fk
125
+ foreign key (id, scheduled_version_id)
126
+ references cms_page_versions (page_id, id);
127
+ end if;
128
+
129
+ if not exists (
130
+ select 1 from pg_constraint where conname = 'cms_pages_schedule_version_pair' and conrelid = 'cms_pages'::regclass
131
+ ) then
132
+ alter table cms_pages
133
+ add constraint cms_pages_schedule_version_pair
134
+ check ((publish_at is null) = (scheduled_version_id is null));
135
+ end if;
136
+
137
+ if not exists (
138
+ select 1 from pg_constraint where conname = 'cms_pages_published_state_complete' and conrelid = 'cms_pages'::regclass
139
+ ) then
140
+ alter table cms_pages
141
+ add constraint cms_pages_published_state_complete
142
+ check (
143
+ status <> 'published'
144
+ or (
145
+ published_title is not null
146
+ and published_seo is not null
147
+ and published_layout is not null
148
+ and published_version_id is not null
149
+ )
150
+ );
151
+ end if;
152
+ end
153
+ $constraints$;
154
+
155
+ create or replace function cms_save_page_draft(
156
+ p_page_id uuid,
157
+ p_title text,
158
+ p_seo jsonb,
159
+ p_layout jsonb,
160
+ p_actor uuid default null,
161
+ p_mark_builder_owned boolean default true
162
+ ) returns cms_pages
163
+ language plpgsql
164
+ security definer
165
+ set search_path = public
166
+ as $$
167
+ declare
168
+ current_page cms_pages;
169
+ result cms_pages;
170
+ version_id bigint;
171
+ next_title text;
172
+ next_seo jsonb;
173
+ next_layout jsonb;
174
+ begin
175
+ select * into current_page
176
+ from cms_pages
177
+ where id = p_page_id
178
+ for update;
179
+
180
+ if current_page.id is null then
181
+ raise exception 'page % not found', p_page_id;
182
+ end if;
183
+
184
+ next_title := coalesce(p_title, current_page.title);
185
+ next_seo := coalesce(p_seo, current_page.seo);
186
+ next_layout := coalesce(p_layout, current_page.draft_layout);
187
+
188
+ select id into version_id
189
+ from cms_page_versions
190
+ where page_id = p_page_id
191
+ and title is not distinct from next_title
192
+ and seo is not distinct from next_seo
193
+ and layout is not distinct from next_layout
194
+ order by created_at desc, id desc
195
+ limit 1;
196
+
197
+ if version_id is null then
198
+ insert into cms_page_versions (page_id, kind, title, seo, layout, created_by)
199
+ values (p_page_id, 'draft', next_title, next_seo, next_layout, p_actor)
200
+ returning id into version_id;
201
+ end if;
202
+
203
+ update cms_pages
204
+ set title = next_title,
205
+ seo = next_seo,
206
+ draft_layout = next_layout,
207
+ draft_version_id = version_id,
208
+ builder_owned = builder_owned or p_mark_builder_owned,
209
+ updated_at = now()
210
+ where id = p_page_id
211
+ returning * into result;
212
+
213
+ return result;
214
+ end;
215
+ $$;
216
+
217
+ -- The old signature remains only to fail closed for stale application code.
218
+ create or replace function cms_publish_page(
219
+ p_page_id uuid,
220
+ p_actor uuid default null
221
+ ) returns cms_pages
222
+ language plpgsql
223
+ security definer
224
+ set search_path = public
225
+ as $$
226
+ begin
227
+ raise exception 'Page version conflict: expected version is required';
228
+ end;
229
+ $$;
230
+
231
+ create or replace function cms_publish_page(
232
+ p_page_id uuid,
233
+ p_expected_version_id bigint,
234
+ p_actor uuid
235
+ ) returns cms_pages
236
+ language plpgsql
237
+ security definer
238
+ set search_path = public
239
+ as $$
240
+ declare
241
+ current_page cms_pages;
242
+ reviewed cms_page_versions;
243
+ result cms_pages;
244
+ begin
245
+ select * into current_page
246
+ from cms_pages
247
+ where id = p_page_id
248
+ for update;
249
+
250
+ if current_page.id is null then
251
+ raise exception 'page % not found', p_page_id;
252
+ end if;
253
+
254
+ select * into reviewed
255
+ from cms_page_versions
256
+ where id = p_expected_version_id
257
+ and page_id = p_page_id;
258
+
259
+ if reviewed.id is null
260
+ or current_page.draft_version_id is distinct from p_expected_version_id
261
+ or reviewed.title is distinct from current_page.title
262
+ or reviewed.seo is distinct from current_page.seo
263
+ or reviewed.layout is distinct from current_page.draft_layout then
264
+ raise exception 'Page version conflict: expected draft is missing or stale';
265
+ end if;
266
+
267
+ if current_page.status = 'published'
268
+ and current_page.published_version_id = p_expected_version_id then
269
+ raise exception 'Page version conflict: expected draft is already published';
270
+ end if;
271
+
272
+ update cms_pages
273
+ set published_title = reviewed.title,
274
+ published_seo = reviewed.seo,
275
+ published_layout = reviewed.layout,
276
+ published_version_id = reviewed.id,
277
+ status = 'published',
278
+ published_at = now(),
279
+ publish_at = null,
280
+ scheduled_version_id = null,
281
+ updated_at = now()
282
+ where id = p_page_id
283
+ returning * into result;
284
+
285
+ return result;
286
+ end;
287
+ $$;
288
+
289
+ create or replace function cms_schedule_page_publish(
290
+ p_page_id uuid,
291
+ p_expected_version_id bigint,
292
+ p_publish_at timestamptz,
293
+ p_actor uuid
294
+ ) returns cms_pages
295
+ language plpgsql
296
+ security definer
297
+ set search_path = public
298
+ as $$
299
+ declare
300
+ current_page cms_pages;
301
+ reviewed cms_page_versions;
302
+ result cms_pages;
303
+ begin
304
+ select * into current_page
305
+ from cms_pages
306
+ where id = p_page_id
307
+ for update;
308
+
309
+ if current_page.id is null then
310
+ raise exception 'page % not found', p_page_id;
311
+ end if;
312
+
313
+ if p_publish_at is null then
314
+ update cms_pages
315
+ set publish_at = null,
316
+ scheduled_version_id = null,
317
+ updated_at = now()
318
+ where id = p_page_id
319
+ returning * into result;
320
+ return result;
321
+ end if;
322
+
323
+ select * into reviewed
324
+ from cms_page_versions
325
+ where id = p_expected_version_id
326
+ and page_id = p_page_id;
327
+
328
+ if reviewed.id is null
329
+ or current_page.draft_version_id is distinct from p_expected_version_id
330
+ or reviewed.title is distinct from current_page.title
331
+ or reviewed.seo is distinct from current_page.seo
332
+ or reviewed.layout is distinct from current_page.draft_layout then
333
+ raise exception 'Page version conflict: expected draft is missing or stale';
334
+ end if;
335
+
336
+ update cms_pages
337
+ set publish_at = p_publish_at,
338
+ scheduled_version_id = reviewed.id,
339
+ updated_at = now()
340
+ where id = p_page_id
341
+ returning * into result;
342
+
343
+ return result;
344
+ end;
345
+ $$;
346
+
347
+ create or replace function cms_restore_page_version(
348
+ p_version_id bigint,
349
+ p_actor uuid default null
350
+ ) returns cms_pages
351
+ language plpgsql
352
+ security definer
353
+ set search_path = public
354
+ as $$
355
+ declare
356
+ v cms_page_versions;
357
+ result cms_pages;
358
+ restored_version_id bigint;
359
+ begin
360
+ select * into v from cms_page_versions where id = p_version_id;
361
+ if v.id is null then
362
+ raise exception 'version % not found', p_version_id;
363
+ end if;
364
+
365
+ perform 1 from cms_pages where id = v.page_id for update;
366
+
367
+ insert into cms_page_versions (page_id, kind, title, seo, layout, created_by)
368
+ values (v.page_id, 'restore', v.title, v.seo, v.layout, p_actor)
369
+ returning id into restored_version_id;
370
+
371
+ update cms_pages
372
+ set draft_layout = v.layout,
373
+ title = v.title,
374
+ seo = v.seo,
375
+ draft_version_id = restored_version_id,
376
+ builder_owned = true,
377
+ updated_at = now()
378
+ where id = v.page_id
379
+ returning * into result;
380
+
381
+ return result;
382
+ end;
383
+ $$;
384
+
385
+ create or replace function cms_sync_page(
386
+ p_slug text,
387
+ p_path text,
388
+ p_title text,
389
+ p_seo jsonb,
390
+ p_layout jsonb
391
+ ) returns cms_pages
392
+ language plpgsql
393
+ security definer
394
+ set search_path = public
395
+ as $$
396
+ declare
397
+ result cms_pages;
398
+ version_id bigint;
399
+ is_new boolean := false;
400
+ begin
401
+ insert into cms_pages (
402
+ slug, path, title, seo, draft_layout,
403
+ published_title, published_seo, published_layout,
404
+ status, published_at
405
+ )
406
+ values (
407
+ p_slug, p_path, p_title, p_seo, p_layout,
408
+ p_title, p_seo, p_layout,
409
+ 'draft', null
410
+ )
411
+ on conflict (slug) do nothing
412
+ returning * into result;
413
+
414
+ if result.id is not null then
415
+ is_new := true;
416
+ else
417
+ select * into result
418
+ from cms_pages
419
+ where slug = p_slug
420
+ for update;
421
+
422
+ if not result.builder_owned then
423
+ update cms_pages
424
+ set path = p_path,
425
+ title = p_title,
426
+ seo = p_seo,
427
+ draft_layout = p_layout,
428
+ published_title = p_title,
429
+ published_seo = p_seo,
430
+ published_layout = p_layout,
431
+ updated_at = now()
432
+ where id = result.id
433
+ returning * into result;
434
+ end if;
435
+ end if;
436
+
437
+ select id into version_id
438
+ from cms_page_versions
439
+ where page_id = result.id
440
+ and title is not distinct from result.title
441
+ and seo is not distinct from result.seo
442
+ and layout is not distinct from result.draft_layout
443
+ order by created_at desc, id desc
444
+ limit 1;
445
+
446
+ if version_id is null then
447
+ insert into cms_page_versions (page_id, kind, title, seo, layout)
448
+ values (result.id, 'sync', result.title, result.seo, result.draft_layout)
449
+ returning id into version_id;
450
+ end if;
451
+
452
+ if not result.builder_owned then
453
+ update cms_pages
454
+ set draft_version_id = version_id,
455
+ published_version_id = version_id,
456
+ status = case when is_new then 'published' else status end,
457
+ published_at = case when is_new then now() else published_at end
458
+ where id = result.id
459
+ returning * into result;
460
+ end if;
461
+
462
+ return result;
463
+ end;
464
+ $$;
465
+
466
+ create or replace function cms_publish_due_pages()
467
+ returns setof text
468
+ language plpgsql
469
+ security definer
470
+ set search_path = public
471
+ as $$
472
+ declare
473
+ due record;
474
+ begin
475
+ for due in
476
+ select
477
+ p.id,
478
+ p.path,
479
+ v.id as version_id,
480
+ v.title,
481
+ v.seo,
482
+ v.layout
483
+ from cms_pages p
484
+ join cms_page_versions v
485
+ on v.page_id = p.id
486
+ and v.id = p.scheduled_version_id
487
+ where p.publish_at is not null
488
+ and p.publish_at <= now()
489
+ and p.scheduled_version_id is not null
490
+ order by p.publish_at
491
+ for update of p skip locked
492
+ loop
493
+ update cms_pages
494
+ set published_title = due.title,
495
+ published_seo = due.seo,
496
+ published_layout = due.layout,
497
+ published_version_id = due.version_id,
498
+ status = 'published',
499
+ published_at = now(),
500
+ publish_at = null,
501
+ scheduled_version_id = null,
502
+ updated_at = now()
503
+ where id = due.id;
504
+
505
+ insert into cms_activity (action, subject)
506
+ values ('page.publish.scheduled', due.path);
507
+
508
+ return next due.path;
509
+ end loop;
510
+ return;
511
+ end;
512
+ $$;
513
+
514
+ do $fn_lockdown$
515
+ declare
516
+ fn text;
517
+ fns text[] := array[
518
+ 'cms_save_page_draft(uuid, text, jsonb, jsonb, uuid, boolean)',
519
+ 'cms_publish_page(uuid, uuid)',
520
+ 'cms_publish_page(uuid, bigint, uuid)',
521
+ 'cms_schedule_page_publish(uuid, bigint, timestamptz, uuid)',
522
+ 'cms_restore_page_version(bigint, uuid)',
523
+ 'cms_sync_page(text, text, text, jsonb, jsonb)',
524
+ 'cms_publish_due_pages()'
525
+ ];
526
+ begin
527
+ foreach fn in array fns loop
528
+ execute format('revoke execute on function %s from public', fn);
529
+ if exists (select 1 from pg_roles where rolname = 'anon') then
530
+ execute format('revoke execute on function %s from anon', fn);
531
+ end if;
532
+ if exists (select 1 from pg_roles where rolname = 'authenticated') then
533
+ execute format('revoke execute on function %s from authenticated', fn);
534
+ end if;
535
+ if exists (select 1 from pg_roles where rolname = 'service_role') then
536
+ execute format('grant execute on function %s to service_role', fn);
537
+ end if;
538
+ end loop;
539
+ end
540
+ $fn_lockdown$;
541
+
542
+ revoke update, delete on cms_page_versions from public;
543
+ do $version_grants$
544
+ begin
545
+ if exists (select 1 from pg_roles where rolname = 'anon') then
546
+ revoke all on cms_page_versions from anon;
547
+ end if;
548
+ if exists (select 1 from pg_roles where rolname = 'authenticated') then
549
+ revoke all on cms_page_versions from authenticated;
550
+ end if;
551
+ if exists (select 1 from pg_roles where rolname = 'service_role') then
552
+ revoke update, delete on cms_page_versions from service_role;
553
+ grant select, insert on cms_page_versions to service_role;
554
+ end if;
555
+ end
556
+ $version_grants$;
557
+
558
+ do $public_page_grants$
559
+ begin
560
+ if exists (select 1 from pg_roles where rolname = 'anon') then
561
+ revoke select on cms_pages from anon;
562
+ grant select (
563
+ id, slug, path, published_title, published_seo, status,
564
+ published_layout, published_at, created_at, updated_at
565
+ ) on cms_pages to anon;
566
+ end if;
567
+ if exists (select 1 from pg_roles where rolname = 'authenticated') then
568
+ revoke select on cms_pages from authenticated;
569
+ grant select (
570
+ id, slug, path, published_title, published_seo, status,
571
+ published_layout, published_at, created_at, updated_at
572
+ ) on cms_pages to authenticated;
573
+ end if;
574
+ end
575
+ $public_page_grants$;