@orion-studios/cms 0.5.0 → 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/README.md +24 -1
- package/dist/{chunk-HVJCF2IZ.js → chunk-AYV6KYDP.js} +3 -3
- package/dist/content/index.js +1 -1
- package/dist/forms/react.d.ts +2 -2
- package/dist/server/index.d.ts +4 -3
- package/dist/server/index.js +169 -40
- package/dist/studio/index.d.ts +14 -5
- package/dist/studio/index.js +149 -67
- package/package.json +1 -1
- package/sql/bootstrap.sql +478 -60
- package/sql/migrations/0005_rls_bookkeeping.sql +17 -0
- package/sql/migrations/20260820033957_sec_003_exact_version_publication.sql +575 -0
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
|
-
|
|
340
|
+
version_id bigint;
|
|
341
|
+
next_title text;
|
|
342
|
+
next_seo jsonb;
|
|
343
|
+
next_layout jsonb;
|
|
192
344
|
begin
|
|
193
|
-
|
|
194
|
-
|
|
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
|
-
|
|
348
|
+
for update;
|
|
201
349
|
|
|
202
|
-
if
|
|
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
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
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
|
|
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',
|
|
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
|
|
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
|
-
|
|
511
|
+
version_id bigint;
|
|
512
|
+
is_new boolean := false;
|
|
303
513
|
begin
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
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
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
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
|
|
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,
|
|
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
|
|
439
|
-
--
|
|
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,
|
|
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,
|
|
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;
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
-- Enable RLS on the two bookkeeping tables the original schema missed.
|
|
2
|
+
-- Neither has policies on purpose: no client role has any business touching
|
|
3
|
+
-- them. cms_rate_limits is only written via cms_rate_limit_consume(), which is
|
|
4
|
+
-- SECURITY DEFINER and service-role-execute-only; cms_migrations is only
|
|
5
|
+
-- touched by the migration runner over a direct Postgres connection. With RLS
|
|
6
|
+
-- off, both tables were fully readable/writable by anon via the Data API
|
|
7
|
+
-- (Supabase security advisor: rls_disabled, critical).
|
|
8
|
+
|
|
9
|
+
alter table cms_rate_limits enable row level security;
|
|
10
|
+
|
|
11
|
+
do $rls$
|
|
12
|
+
begin
|
|
13
|
+
if to_regclass('public.cms_migrations') is not null then
|
|
14
|
+
execute 'alter table cms_migrations enable row level security';
|
|
15
|
+
end if;
|
|
16
|
+
end
|
|
17
|
+
$rls$;
|