@orion-studios/cms 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,458 @@
1
+ -- ============================================================================
2
+ -- Orion CMS v2 — bootstrap schema
3
+ --
4
+ -- Applied once per site (Supabase SQL editor or `npx orion-cms bootstrap`).
5
+ -- Idempotent: safe to re-run. The schema is intentionally small and stable —
6
+ -- block/layout shapes live in JSONB and are validated by the application, so
7
+ -- content-model changes require NO database changes.
8
+ -- ============================================================================
9
+
10
+ -- 1) Tables ------------------------------------------------------------------
11
+
12
+ create table if not exists cms_settings (
13
+ id boolean primary key default true check (id), -- single row
14
+ site_name text not null default '',
15
+ data jsonb not null default '{}'::jsonb,
16
+ updated_at timestamptz not null default now()
17
+ );
18
+ insert into cms_settings (id) values (true) on conflict do nothing;
19
+
20
+ create table if not exists cms_profiles (
21
+ -- Points at auth.users(id). No FK on purpose: adding one requires REFERENCES
22
+ -- on the auth schema, which restricted roles (typical DATABASE_URL users)
23
+ -- don't have. The API layer owns profile lifecycle and integrity.
24
+ user_id uuid primary key,
25
+ role text not null default 'content'
26
+ check (role in ('admin', 'developer', 'editor', 'content')),
27
+ name text not null default '',
28
+ created_at timestamptz not null default now()
29
+ );
30
+
31
+ create table if not exists cms_pages (
32
+ id uuid primary key default gen_random_uuid(),
33
+ slug text not null unique,
34
+ path text not null unique,
35
+ title text not null default '',
36
+ seo jsonb not null default '{}'::jsonb,
37
+ draft_layout jsonb not null default '[]'::jsonb,
38
+ published_layout jsonb,
39
+ status text not null default 'draft' check (status in ('draft', 'published')),
40
+ -- true once edited in the Studio; content-as-code sync then leaves the layout alone
41
+ builder_owned boolean not null default false,
42
+ created_at timestamptz not null default now(),
43
+ updated_at timestamptz not null default now(),
44
+ published_at timestamptz
45
+ );
46
+ create index if not exists cms_pages_status_idx on cms_pages (status);
47
+
48
+ create table if not exists cms_page_versions (
49
+ id bigint generated always as identity primary key,
50
+ page_id uuid not null references cms_pages (id) on delete cascade,
51
+ kind text not null check (kind in ('draft', 'publish', 'restore', 'sync')),
52
+ title text not null default '',
53
+ seo jsonb not null default '{}'::jsonb,
54
+ layout jsonb not null default '[]'::jsonb,
55
+ created_by uuid,
56
+ created_at timestamptz not null default now()
57
+ );
58
+ create index if not exists cms_page_versions_page_idx
59
+ on cms_page_versions (page_id, created_at desc);
60
+
61
+ create table if not exists cms_globals (
62
+ key text primary key,
63
+ label text not null default '',
64
+ data jsonb not null default '{}'::jsonb,
65
+ updated_at timestamptz not null default now()
66
+ );
67
+
68
+ create table if not exists cms_media (
69
+ id uuid primary key default gen_random_uuid(),
70
+ storage_path text not null unique,
71
+ filename text not null,
72
+ alt text not null default '',
73
+ caption text not null default '',
74
+ mime_type text not null default '',
75
+ width integer,
76
+ height integer,
77
+ filesize integer,
78
+ created_at timestamptz not null default now(),
79
+ updated_at timestamptz not null default now()
80
+ );
81
+
82
+ create table if not exists cms_forms (
83
+ id uuid primary key default gen_random_uuid(),
84
+ slug text not null unique,
85
+ title text not null default '',
86
+ config jsonb not null default '{}'::jsonb, -- steps/fields, per forms module contract
87
+ success_message text not null default '',
88
+ created_at timestamptz not null default now(),
89
+ updated_at timestamptz not null default now()
90
+ );
91
+
92
+ -- Notification settings live OUTSIDE config: config is anon-readable (the
93
+ -- public form renderer needs the field list), notify addresses are not.
94
+ alter table cms_forms add column if not exists notify jsonb not null default '{}'::jsonb;
95
+ update cms_forms
96
+ set notify = coalesce(config->'notify', '{}'::jsonb),
97
+ config = config - 'notify'
98
+ where config ? 'notify';
99
+
100
+ create table if not exists cms_form_submissions (
101
+ id bigint generated always as identity primary key,
102
+ form_id uuid not null references cms_forms (id) on delete cascade,
103
+ data jsonb not null default '{}'::jsonb,
104
+ source text not null default 'website',
105
+ created_at timestamptz not null default now()
106
+ );
107
+ create index if not exists cms_form_submissions_form_idx
108
+ on cms_form_submissions (form_id, created_at desc);
109
+
110
+ -- Additive columns (idempotent — bootstrap re-runs safely on existing sites).
111
+ alter table cms_form_submissions add column if not exists read_at timestamptz;
112
+ alter table cms_form_submissions add column if not exists client_key text;
113
+ alter table cms_pages add column if not exists publish_at timestamptz;
114
+
115
+ -- Snapshots of global values, mirroring cms_page_versions for pages.
116
+ create table if not exists cms_global_versions (
117
+ id bigint generated always as identity primary key,
118
+ key text not null,
119
+ data jsonb not null default '{}'::jsonb,
120
+ created_by uuid,
121
+ created_at timestamptz not null default now()
122
+ );
123
+ create index if not exists cms_global_versions_key_idx
124
+ on cms_global_versions (key, created_at desc);
125
+
126
+ -- Redirects: consulted by the site on would-be 404s. Auto-populated on
127
+ -- page path renames, manageable in the Studio.
128
+ create table if not exists cms_redirects (
129
+ id uuid primary key default gen_random_uuid(),
130
+ from_path text not null unique,
131
+ to_path text not null,
132
+ permanent boolean not null default true,
133
+ created_at timestamptz not null default now()
134
+ );
135
+
136
+ -- First-party analytics events. Append-only; pruned after the retention
137
+ -- window by the cron route. session_key rotates daily. visitor_key is an
138
+ -- optional server-side HMAC of a consented first-party cookie; the raw cookie
139
+ -- value is never stored.
140
+ create table if not exists cms_events (
141
+ id bigint generated always as identity primary key,
142
+ session_key text not null default '',
143
+ visitor_key text not null default '',
144
+ type text not null check (type in ('pageview', 'click', 'form', 'not_found')),
145
+ name text not null default '',
146
+ path text not null default '',
147
+ referrer text not null default '',
148
+ utm jsonb not null default '{}'::jsonb,
149
+ device text not null default '',
150
+ region text not null default '',
151
+ city text not null default '',
152
+ meta jsonb not null default '{}'::jsonb,
153
+ created_at timestamptz not null default now()
154
+ );
155
+ create index if not exists cms_events_day_idx on cms_events (created_at desc);
156
+ create index if not exists cms_events_session_idx on cms_events (session_key, created_at);
157
+ create index if not exists cms_events_visitor_idx on cms_events (visitor_key, created_at)
158
+ where visitor_key <> '';
159
+
160
+ -- Links a stored lead to the journey that produced it (same hash scheme).
161
+ alter table cms_form_submissions add column if not exists session_key text;
162
+
163
+ -- Append-only activity log for actions page versions don't cover
164
+ -- (publish/delete/user/media/form/redirect events).
165
+ create table if not exists cms_activity (
166
+ id bigint generated always as identity primary key,
167
+ actor uuid,
168
+ actor_name text not null default '',
169
+ action text not null,
170
+ subject text not null default '',
171
+ created_at timestamptz not null default now()
172
+ );
173
+ create index if not exists cms_activity_created_idx on cms_activity (created_at desc);
174
+
175
+ -- 2) Functions (atomic write paths, called via RPC with service role) --------
176
+
177
+ create or replace function cms_save_page_draft(
178
+ p_page_id uuid,
179
+ p_title text,
180
+ p_seo jsonb,
181
+ p_layout jsonb,
182
+ p_actor uuid default null,
183
+ p_mark_builder_owned boolean default true
184
+ ) returns cms_pages
185
+ language plpgsql
186
+ security definer
187
+ set search_path = public
188
+ as $$
189
+ declare
190
+ result cms_pages;
191
+ latest cms_page_versions;
192
+ 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()
199
+ where id = p_page_id
200
+ returning * into result;
201
+
202
+ if result.id is null then
203
+ raise exception 'page % not found', p_page_id;
204
+ end if;
205
+
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;
211
+
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
216
+ 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);
218
+ end if;
219
+
220
+ return result;
221
+ end;
222
+ $$;
223
+
224
+ create or replace function cms_publish_page(
225
+ p_page_id uuid,
226
+ p_actor uuid default null
227
+ ) returns cms_pages
228
+ language plpgsql
229
+ security definer
230
+ set search_path = public
231
+ as $$
232
+ declare
233
+ result cms_pages;
234
+ begin
235
+ update cms_pages
236
+ set published_layout = draft_layout,
237
+ status = 'published',
238
+ published_at = now(),
239
+ updated_at = now()
240
+ where id = p_page_id
241
+ returning * into result;
242
+
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
+ return result;
251
+ end;
252
+ $$;
253
+
254
+ create or replace function cms_restore_page_version(
255
+ p_version_id bigint,
256
+ p_actor uuid default null
257
+ ) returns cms_pages
258
+ language plpgsql
259
+ security definer
260
+ set search_path = public
261
+ as $$
262
+ declare
263
+ v cms_page_versions;
264
+ result cms_pages;
265
+ begin
266
+ select * into v from cms_page_versions where id = p_version_id;
267
+ if v.id is null then
268
+ raise exception 'version % not found', p_version_id;
269
+ end if;
270
+
271
+ update cms_pages
272
+ set draft_layout = v.layout,
273
+ title = v.title,
274
+ seo = v.seo,
275
+ builder_owned = true,
276
+ updated_at = now()
277
+ where id = v.page_id
278
+ returning * into result;
279
+
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
+ return result;
284
+ end;
285
+ $$;
286
+
287
+ -- Content-as-code sync upsert: creates or updates a page; layout is only
288
+ -- written when the page is not builder-owned (or on create).
289
+ create or replace function cms_sync_page(
290
+ p_slug text,
291
+ p_path text,
292
+ p_title text,
293
+ p_seo jsonb,
294
+ p_layout jsonb
295
+ ) returns cms_pages
296
+ language plpgsql
297
+ security definer
298
+ set search_path = public
299
+ as $$
300
+ declare
301
+ result cms_pages;
302
+ latest cms_page_versions;
303
+ 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
316
+ returning * into result;
317
+
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;
323
+
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
328
+ insert into cms_page_versions (page_id, kind, title, seo, layout)
329
+ values (result.id, 'sync', result.title, result.seo, result.draft_layout);
330
+ end if;
331
+
332
+ return result;
333
+ end;
334
+ $$;
335
+
336
+ -- 3) Row-level security -------------------------------------------------------
337
+ -- Defense-in-depth. The app's API layer (service role) is the primary
338
+ -- enforcement point; anonymous access is read-only and published-only.
339
+
340
+ alter table cms_settings enable row level security;
341
+ alter table cms_profiles enable row level security;
342
+ alter table cms_pages enable row level security;
343
+ alter table cms_page_versions enable row level security;
344
+ alter table cms_globals enable row level security;
345
+ alter table cms_media enable row level security;
346
+ alter table cms_forms enable row level security;
347
+ alter table cms_form_submissions enable row level security;
348
+ alter table cms_global_versions enable row level security;
349
+ alter table cms_redirects enable row level security;
350
+ alter table cms_activity enable row level security;
351
+ alter table cms_events enable row level security;
352
+ -- cms_events: no anon policies or grants — ingest and reads go through the
353
+ -- API layer only.
354
+
355
+ -- Redirects are public data (the site resolves them anonymously).
356
+ drop policy if exists cms_redirects_public_read on cms_redirects;
357
+ create policy cms_redirects_public_read on cms_redirects
358
+ for select using (true);
359
+
360
+ -- Public (anon) read policies: published content only.
361
+ drop policy if exists cms_pages_public_read on cms_pages;
362
+ create policy cms_pages_public_read on cms_pages
363
+ for select using (status = 'published');
364
+
365
+ drop policy if exists cms_globals_public_read on cms_globals;
366
+ create policy cms_globals_public_read on cms_globals
367
+ for select using (true);
368
+
369
+ drop policy if exists cms_media_public_read on cms_media;
370
+ create policy cms_media_public_read on cms_media
371
+ for select using (true);
372
+
373
+ drop policy if exists cms_forms_public_read on cms_forms;
374
+ create policy cms_forms_public_read on cms_forms
375
+ for select using (true);
376
+
377
+ -- Authenticated users can read their own profile. Optional nicety: Studio
378
+ -- reads profiles through the service role, so nothing depends on this policy.
379
+ -- Referencing auth.uid() requires USAGE on the auth schema, which restricted
380
+ -- roles may lack — skip gracefully instead of failing the whole bootstrap.
381
+ do $profiles_policy$
382
+ begin
383
+ drop policy if exists cms_profiles_self_read on cms_profiles;
384
+ execute 'create policy cms_profiles_self_read on cms_profiles '
385
+ || 'for select using (auth.uid() = user_id)';
386
+ exception when insufficient_privilege then
387
+ raise notice 'Skipped cms_profiles_self_read policy (no auth schema access).';
388
+ end $profiles_policy$;
389
+
390
+ -- No anon/authenticated write policies: all writes flow through the API layer
391
+ -- (service role bypasses RLS and enforces the permission matrix in code).
392
+
393
+ -- 6a) Function execution lockdown ---------------------------------------------
394
+ -- The write-path functions are SECURITY DEFINER (they run with their owner's
395
+ -- table privileges). Postgres grants EXECUTE to PUBLIC on new functions by
396
+ -- default and Supabase exposes public-schema functions as RPC, so without an
397
+ -- explicit revoke the anon key could call them and bypass RLS + the API
398
+ -- permission matrix. Only the API layer (service role) may execute them.
399
+ do $fn_lockdown$
400
+ declare
401
+ fn text;
402
+ fns text[] := array[
403
+ 'cms_save_page_draft(uuid, text, jsonb, jsonb, uuid, boolean)',
404
+ 'cms_publish_page(uuid, uuid)',
405
+ 'cms_restore_page_version(bigint, uuid)',
406
+ 'cms_sync_page(text, text, text, jsonb, jsonb)'
407
+ ];
408
+ begin
409
+ foreach fn in array fns loop
410
+ execute format('revoke execute on function %s from public', fn);
411
+ if exists (select 1 from pg_roles where rolname = 'anon') then
412
+ execute format('revoke execute on function %s from anon', fn);
413
+ end if;
414
+ if exists (select 1 from pg_roles where rolname = 'authenticated') then
415
+ execute format('revoke execute on function %s from authenticated', fn);
416
+ end if;
417
+ if exists (select 1 from pg_roles where rolname = 'service_role') then
418
+ execute format('grant execute on function %s to service_role', fn);
419
+ end if;
420
+ end loop;
421
+ end $fn_lockdown$;
422
+
423
+ -- 6) Grants -------------------------------------------------------------------
424
+ -- RLS policies FILTER rows but don't grant table access. Supabase's default
425
+ -- privileges only cover tables created by `postgres`; when bootstrap runs as a
426
+ -- site-specific role, the PostgREST roles get nothing — so grant explicitly.
427
+ -- Wrapped per-role so bootstrap also works on plain Postgres (dev/tests).
428
+ do $grants$
429
+ begin
430
+ if exists (select 1 from pg_roles where rolname = 'service_role') then
431
+ grant select, insert, update, delete on
432
+ cms_settings, cms_profiles, cms_pages, cms_page_versions,
433
+ cms_globals, cms_media, cms_forms, cms_form_submissions,
434
+ cms_global_versions, cms_redirects, cms_activity, cms_events
435
+ to service_role;
436
+ end if;
437
+
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).
440
+ if exists (select 1 from pg_roles where rolname = 'anon') then
441
+ grant select on cms_globals, cms_media, cms_redirects to anon;
442
+ revoke select on cms_pages from anon;
443
+ grant select (id, slug, path, title, seo, status, published_layout, published_at, created_at, updated_at)
444
+ on cms_pages to anon;
445
+ revoke select on cms_forms from anon;
446
+ grant select (id, slug, title, config, success_message, created_at, updated_at)
447
+ on cms_forms to anon;
448
+ end if;
449
+ if exists (select 1 from pg_roles where rolname = 'authenticated') then
450
+ grant select on cms_globals, cms_media, cms_profiles, cms_redirects to authenticated;
451
+ revoke select on cms_pages from authenticated;
452
+ grant select (id, slug, path, title, seo, status, published_layout, published_at, created_at, updated_at)
453
+ on cms_pages to authenticated;
454
+ revoke select on cms_forms from authenticated;
455
+ grant select (id, slug, title, config, success_message, created_at, updated_at)
456
+ on cms_forms to authenticated;
457
+ end if;
458
+ end $grants$;
@@ -0,0 +1,68 @@
1
+ -- Atomic scheduled publishing.
2
+ --
3
+ -- The previous flow read all pages, filtered due ones in JS, then published
4
+ -- and cleared publish_at in separate statements — so two concurrent runs (two
5
+ -- cron invocations, or a cron plus a dashboard open) could publish the same
6
+ -- page twice. This function claims due rows with FOR UPDATE SKIP LOCKED and
7
+ -- publishes + clears + snapshots each in one transaction, so a page is
8
+ -- published at most once no matter how many workers run.
9
+
10
+ -- Only due, scheduled pages need scanning.
11
+ create index if not exists cms_pages_publish_at_due_idx
12
+ on cms_pages (publish_at)
13
+ where publish_at is not null;
14
+
15
+ create or replace function cms_publish_due_pages()
16
+ returns setof text
17
+ language plpgsql
18
+ security definer
19
+ set search_path = public
20
+ as $$
21
+ declare
22
+ due record;
23
+ begin
24
+ for due in
25
+ select id, path
26
+ from cms_pages
27
+ where publish_at is not null
28
+ and publish_at <= now()
29
+ order by publish_at
30
+ for update skip locked
31
+ loop
32
+ update cms_pages
33
+ set published_layout = draft_layout,
34
+ status = 'published',
35
+ published_at = now(),
36
+ publish_at = null,
37
+ updated_at = now()
38
+ where id = due.id;
39
+
40
+ insert into cms_page_versions (page_id, kind, title, seo, layout, created_by)
41
+ select id, 'publish', title, seo, published_layout, null
42
+ from cms_pages
43
+ where id = due.id;
44
+
45
+ insert into cms_activity (action, subject)
46
+ values ('page.publish.scheduled', due.path);
47
+
48
+ return next due.path;
49
+ end loop;
50
+ return;
51
+ end;
52
+ $$;
53
+
54
+ -- Same execution lockdown as the other write functions: only the API's
55
+ -- service role may call it (never anon / authenticated).
56
+ revoke execute on function cms_publish_due_pages() from public;
57
+ do $lockdown$
58
+ begin
59
+ if exists (select 1 from pg_roles where rolname = 'anon') then
60
+ revoke execute on function cms_publish_due_pages() from anon;
61
+ end if;
62
+ if exists (select 1 from pg_roles where rolname = 'authenticated') then
63
+ revoke execute on function cms_publish_due_pages() from authenticated;
64
+ end if;
65
+ if exists (select 1 from pg_roles where rolname = 'service_role') then
66
+ grant execute on function cms_publish_due_pages() to service_role;
67
+ end if;
68
+ end $lockdown$;
@@ -0,0 +1,62 @@
1
+ -- Durable, atomic rate limiting for public endpoints (form submit, analytics
2
+ -- ingest). The previous limiter counted rows in per-lambda memory (no global
3
+ -- limit across serverless instances) or counted submissions then inserted
4
+ -- separately (a count-then-insert race let N concurrent requests all pass).
5
+ --
6
+ -- cms_rate_limit_consume() consumes one token and reports whether the caller is
7
+ -- still within budget in a single atomic upsert — no read-modify-write gap.
8
+
9
+ create table if not exists cms_rate_limits (
10
+ key text primary key,
11
+ window_start timestamptz not null default now(),
12
+ count integer not null default 0
13
+ );
14
+
15
+ create or replace function cms_rate_limit_consume(
16
+ p_key text,
17
+ p_max integer,
18
+ p_window_seconds integer
19
+ ) returns boolean -- true = allowed, false = over the limit
20
+ language plpgsql
21
+ security definer
22
+ set search_path = public
23
+ as $$
24
+ declare
25
+ v_count integer;
26
+ begin
27
+ insert into cms_rate_limits (key, window_start, count)
28
+ values (p_key, now(), 1)
29
+ on conflict (key) do update
30
+ set count = case
31
+ when cms_rate_limits.window_start < now() - make_interval(secs => p_window_seconds)
32
+ then 1
33
+ else cms_rate_limits.count + 1
34
+ end,
35
+ window_start = case
36
+ when cms_rate_limits.window_start < now() - make_interval(secs => p_window_seconds)
37
+ then now()
38
+ else cms_rate_limits.window_start
39
+ end
40
+ returning count into v_count;
41
+
42
+ return v_count <= p_max;
43
+ end;
44
+ $$;
45
+
46
+ -- Lets the cron prune stale rows so the table stays small.
47
+ create index if not exists cms_rate_limits_window_idx on cms_rate_limits (window_start);
48
+
49
+ -- Service-role only, like the other write functions.
50
+ revoke execute on function cms_rate_limit_consume(text, integer, integer) from public;
51
+ do $lockdown$
52
+ begin
53
+ if exists (select 1 from pg_roles where rolname = 'anon') then
54
+ revoke execute on function cms_rate_limit_consume(text, integer, integer) from anon;
55
+ end if;
56
+ if exists (select 1 from pg_roles where rolname = 'authenticated') then
57
+ revoke execute on function cms_rate_limit_consume(text, integer, integer) from authenticated;
58
+ end if;
59
+ if exists (select 1 from pg_roles where rolname = 'service_role') then
60
+ grant execute on function cms_rate_limit_consume(text, integer, integer) to service_role;
61
+ end if;
62
+ end $lockdown$;
@@ -0,0 +1,46 @@
1
+ -- Atomic global update + version snapshot.
2
+ --
3
+ -- Previously updateGlobal upserted cms_globals then inserted the version
4
+ -- snapshot in a separate statement: if the snapshot failed, the global was
5
+ -- changed with no recoverable history. This function does both in one
6
+ -- transaction, so a global is always snapshotted exactly when it changes.
7
+
8
+ create or replace function cms_update_global(
9
+ p_key text,
10
+ p_data jsonb,
11
+ p_actor uuid default null
12
+ ) returns cms_globals
13
+ language plpgsql
14
+ security definer
15
+ set search_path = public
16
+ as $$
17
+ declare
18
+ result cms_globals;
19
+ begin
20
+ insert into cms_globals (key, data, updated_at)
21
+ values (p_key, p_data, now())
22
+ on conflict (key) do update
23
+ set data = excluded.data,
24
+ updated_at = now()
25
+ returning * into result;
26
+
27
+ insert into cms_global_versions (key, data, created_by)
28
+ values (p_key, p_data, p_actor);
29
+
30
+ return result;
31
+ end;
32
+ $$;
33
+
34
+ revoke execute on function cms_update_global(text, jsonb, uuid) from public;
35
+ do $lockdown$
36
+ begin
37
+ if exists (select 1 from pg_roles where rolname = 'anon') then
38
+ revoke execute on function cms_update_global(text, jsonb, uuid) from anon;
39
+ end if;
40
+ if exists (select 1 from pg_roles where rolname = 'authenticated') then
41
+ revoke execute on function cms_update_global(text, jsonb, uuid) from authenticated;
42
+ end if;
43
+ if exists (select 1 from pg_roles where rolname = 'service_role') then
44
+ grant execute on function cms_update_global(text, jsonb, uuid) to service_role;
45
+ end if;
46
+ end $lockdown$;
@@ -0,0 +1,8 @@
1
+ -- Add the pseudonymous, consented cross-day identity used by analytics v0.5.
2
+ -- The browser's raw UUID is HMAC-hashed by the API before this table is touched.
3
+ alter table cms_events
4
+ add column if not exists visitor_key text not null default '';
5
+
6
+ create index if not exists cms_events_visitor_idx
7
+ on cms_events (visitor_key, created_at)
8
+ where visitor_key <> '';