@orion-studios/cms 0.5.4 → 0.5.6

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.
@@ -2,7 +2,7 @@ import * as react from 'react';
2
2
  import { ComponentType, InputHTMLAttributes } from 'react';
3
3
  import { z } from 'zod';
4
4
  import { SupabaseClient, Session } from '@supabase/supabase-js';
5
- import { F as FormConfig, a as FormNotifyConfig } from '../submission-CzrfXu17.js';
5
+ import { F as FormConfig, a as FormNotifyConfig } from '../submission-CKZgx1h7.js';
6
6
 
7
7
  /**
8
8
  * Editor field derivation: turns a block's Zod schema into a default editor
@@ -395,7 +395,6 @@ declare function createStudioApi(options: {
395
395
  success: true;
396
396
  }>;
397
397
  previewToken: (id: string) => Promise<{
398
- token: string;
399
398
  path: string;
400
399
  url: string;
401
400
  }>;
@@ -2,8 +2,9 @@
2
2
  "use client";
3
3
  import {
4
4
  FORM_FIELD_TYPES,
5
- FormRenderer
6
- } from "../chunk-WQDHEQDE.js";
5
+ FormRenderer,
6
+ getAutoReplyEmailFields
7
+ } from "../chunk-ULE565KD.js";
7
8
 
8
9
  // src/studio/Studio.tsx
9
10
  import { useCallback as useCallback5, useEffect as useEffect11, useMemo as useMemo5, useState as useState12 } from "react";
@@ -988,6 +989,7 @@ function FormEditor({
988
989
  const [notifyEmails, setNotifyEmails] = useState5("");
989
990
  const [notifySubject, setNotifySubject] = useState5("");
990
991
  const [autoReply, setAutoReply] = useState5(false);
992
+ const [autoReplyEmailField, setAutoReplyEmailField] = useState5("");
991
993
  const [dirty, setDirty] = useState5(false);
992
994
  const [message, setMessage] = useState5("");
993
995
  const [error, setError] = useState5("");
@@ -1005,6 +1007,10 @@ function FormEditor({
1005
1007
  setNotifyEmails((notify.emails || []).join(", "));
1006
1008
  setNotifySubject(notify.subject || "");
1007
1009
  setAutoReply(notify.autoReply === true);
1010
+ const emailFields = getAutoReplyEmailFields(loadedConfig);
1011
+ setAutoReplyEmailField(
1012
+ notify.autoReplyEmailField && emailFields.includes(notify.autoReplyEmailField) ? notify.autoReplyEmailField : emailFields.length === 1 ? emailFields[0] : ""
1013
+ );
1008
1014
  }, (e) => setError(e.message));
1009
1015
  }, [api, slug]);
1010
1016
  const touch = () => {
@@ -1027,6 +1033,12 @@ function FormEditor({
1027
1033
  const save = async () => {
1028
1034
  setError("");
1029
1035
  const emails = notifyEmails.split(/[,\s]+/).map((entry) => entry.trim()).filter(Boolean);
1036
+ const emailFields = getAutoReplyEmailFields(config);
1037
+ const selectedEmailField = emailFields.includes(autoReplyEmailField) ? autoReplyEmailField : emailFields.length === 1 ? emailFields[0] : "";
1038
+ if (autoReply && !selectedEmailField) {
1039
+ setError("Choose one declared email field before enabling auto-reply.");
1040
+ return;
1041
+ }
1030
1042
  try {
1031
1043
  const { form: saved } = await api.updateForm(slug, {
1032
1044
  title,
@@ -1034,7 +1046,8 @@ function FormEditor({
1034
1046
  notify: {
1035
1047
  emails,
1036
1048
  ...notifySubject.trim() ? { subject: notifySubject.trim() } : {},
1037
- autoReply
1049
+ autoReply,
1050
+ ...autoReply && selectedEmailField ? { autoReplyEmailField: selectedEmailField } : {}
1038
1051
  },
1039
1052
  successMessage
1040
1053
  });
@@ -1048,6 +1061,8 @@ function FormEditor({
1048
1061
  if (!form && !error) return /* @__PURE__ */ jsx5("div", { className: "ost-loading", children: "Loading form\u2026" });
1049
1062
  if (error && !form) return /* @__PURE__ */ jsx5("div", { className: "ost-error", children: error });
1050
1063
  const steps = config.steps || [];
1064
+ const autoReplyEmailFields = getAutoReplyEmailFields(config);
1065
+ const selectedAutoReplyEmailField = autoReplyEmailFields.includes(autoReplyEmailField) ? autoReplyEmailField : autoReplyEmailFields.length === 1 ? autoReplyEmailFields[0] : "";
1051
1066
  return /* @__PURE__ */ jsxs5("div", { className: "ost-view ost-view-wide", children: [
1052
1067
  /* @__PURE__ */ jsxs5("header", { className: "ost-view-header", children: [
1053
1068
  /* @__PURE__ */ jsxs5("div", { className: "ost-row", children: [
@@ -1212,7 +1227,26 @@ function FormEditor({
1212
1227
  ),
1213
1228
  "Auto-reply to the submitter with the success message"
1214
1229
  ] }),
1215
- /* @__PURE__ */ jsx5("p", { className: "ost-muted", children: "Requires the site's email sending to be configured (RESEND_API_KEY)." })
1230
+ autoReply ? /* @__PURE__ */ jsxs5("label", { className: "ost-label", children: [
1231
+ "Auto-reply email field",
1232
+ /* @__PURE__ */ jsxs5(
1233
+ "select",
1234
+ {
1235
+ className: "ost-input",
1236
+ disabled: !canWrite || autoReplyEmailFields.length === 0,
1237
+ onChange: (event) => {
1238
+ setAutoReplyEmailField(event.target.value);
1239
+ touch();
1240
+ },
1241
+ value: selectedAutoReplyEmailField,
1242
+ children: [
1243
+ autoReplyEmailFields.length !== 1 ? /* @__PURE__ */ jsx5("option", { value: "", children: "Choose an email field" }) : null,
1244
+ autoReplyEmailFields.map((fieldName) => /* @__PURE__ */ jsx5("option", { value: fieldName, children: fieldName }, fieldName))
1245
+ ]
1246
+ }
1247
+ )
1248
+ ] }) : null,
1249
+ /* @__PURE__ */ jsx5("p", { className: "ost-muted", children: "Auto-reply requires a declared email field and configured email sending (RESEND_API_KEY)." })
1216
1250
  ] })
1217
1251
  ] }),
1218
1252
  /* @__PURE__ */ jsxs5("div", { className: "ost-form-preview", children: [
@@ -2225,7 +2259,7 @@ function PageEditor({
2225
2259
  setBusy("preview");
2226
2260
  try {
2227
2261
  const { url } = await api.previewToken(pageId);
2228
- window.open(url, "_blank", "noopener");
2262
+ window.open(url, "_blank", "noopener,noreferrer");
2229
2263
  } catch (previewError) {
2230
2264
  setError(previewError instanceof Error ? previewError.message : "Preview failed.");
2231
2265
  } finally {
@@ -3379,7 +3413,7 @@ function MediaView({
3379
3413
  /* @__PURE__ */ jsx11(
3380
3414
  "input",
3381
3415
  {
3382
- accept: "image/*,application/pdf",
3416
+ accept: "image/jpeg,image/png,image/webp,image/gif,image/avif",
3383
3417
  hidden: true,
3384
3418
  onChange: async (event) => {
3385
3419
  const file = event.currentTarget.files?.[0];
@@ -3493,12 +3527,12 @@ function MediaDetail({
3493
3527
  children: saving ? "Saving\u2026" : "Save details"
3494
3528
  }
3495
3529
  ),
3496
- canReplace ? /* @__PURE__ */ jsxs11("label", { className: "ost-btn", title: "The new file keeps every existing reference. CDN caching can take up to an hour to show the new version everywhere.", children: [
3530
+ canReplace && media.mime_type.startsWith("image/") ? /* @__PURE__ */ jsxs11("label", { className: "ost-btn", title: "The new file keeps every existing reference. CDN caching can take up to an hour to show the new version everywhere.", children: [
3497
3531
  replacing ? "Replacing\u2026" : "Replace file",
3498
3532
  /* @__PURE__ */ jsx11(
3499
3533
  "input",
3500
3534
  {
3501
- accept: media.mime_type.startsWith("image/") ? "image/*" : "application/pdf",
3535
+ accept: "image/jpeg,image/png,image/webp,image/gif,image/avif",
3502
3536
  hidden: true,
3503
3537
  onChange: async (event) => {
3504
3538
  const file = event.currentTarget.files?.[0];
@@ -24,6 +24,8 @@ type FormNotifyConfig = {
24
24
  subject?: string;
25
25
  /** Send the success message back to the submitter's email field. */
26
26
  autoReply?: boolean;
27
+ /** Declared email field used for staff reply-to and auto-replies. */
28
+ autoReplyEmailField?: string;
27
29
  };
28
30
  type FormConfig = {
29
31
  steps?: Array<{
@@ -33,12 +35,19 @@ type FormConfig = {
33
35
  notify?: FormNotifyConfig;
34
36
  };
35
37
  type RateLimitStore = {
36
- isLimited(key: string, now: number): boolean | Promise<boolean>;
38
+ isLimited(key: string, now: number, cost?: number): boolean | Promise<boolean>;
37
39
  };
38
40
  declare function createMemoryRateLimitStore(options?: {
39
41
  max?: number;
40
42
  windowMs?: number;
41
43
  }): RateLimitStore;
44
+ /**
45
+ * Returns unambiguous declared email fields in form order. A repeated name is
46
+ * eligible only when every declaration for that name is an email field.
47
+ */
48
+ declare function getAutoReplyEmailFields(config: FormConfig): string[];
49
+ /** Resolves the one declared field that may control submission email delivery. */
50
+ declare function resolveAutoReplyEmailField(config: FormConfig): string | null;
42
51
  type ProcessSubmissionArgs = {
43
52
  config: FormConfig;
44
53
  data: Record<string, unknown>;
@@ -58,4 +67,4 @@ type ProcessSubmissionResult = {
58
67
  };
59
68
  declare function processSubmission(args: ProcessSubmissionArgs): ProcessSubmissionResult;
60
69
 
61
- export { type FormConfig as F, HONEYPOT_FIELD_NAME as H, type ProcessSubmissionArgs as P, type RateLimitStore as R, type FormFieldConfig as a, type FormNotifyConfig as b, type ProcessSubmissionResult as c, createMemoryRateLimitStore as d, processSubmission as p };
70
+ export { type FormConfig as F, HONEYPOT_FIELD_NAME as H, type ProcessSubmissionArgs as P, type RateLimitStore as R, type FormFieldConfig as a, type FormNotifyConfig as b, type ProcessSubmissionResult as c, createMemoryRateLimitStore as d, getAutoReplyEmailFields as g, processSubmission as p, resolveAutoReplyEmailField as r };
@@ -18,6 +18,8 @@ type FormNotifyConfig = {
18
18
  subject?: string;
19
19
  /** Send the success message back to the submitter's email field. */
20
20
  autoReply?: boolean;
21
+ /** Declared email field used for staff reply-to and auto-replies. */
22
+ autoReplyEmailField?: string;
21
23
  };
22
24
  type FormConfig = {
23
25
  steps?: Array<{
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@orion-studios/cms",
3
- "version": "0.5.4",
3
+ "version": "0.5.6",
4
4
  "description": "Orion CMS v2 core engine \u2014 JSONB content model on Supabase primitives",
5
5
  "type": "module",
6
6
  "exports": {
@@ -56,6 +56,7 @@
56
56
  "dependencies": {
57
57
  "@supabase/ssr": "^0.7.0",
58
58
  "@supabase/supabase-js": "^2.58.0",
59
+ "sharp": "^0.35.3",
59
60
  "zod": "^3.25.0"
60
61
  },
61
62
  "peerDependencies": {
package/sql/bootstrap.sql CHANGED
@@ -244,7 +244,10 @@ create table if not exists cms_forms (
244
244
  -- public form renderer needs the field list), notify addresses are not.
245
245
  alter table cms_forms add column if not exists notify jsonb not null default '{}'::jsonb;
246
246
  update cms_forms
247
- set notify = coalesce(config->'notify', '{}'::jsonb),
247
+ set notify = case
248
+ when jsonb_typeof(notify) = 'object' and notify <> '{}'::jsonb then notify
249
+ else coalesce(config->'notify', '{}'::jsonb)
250
+ end,
248
251
  config = config - 'notify'
249
252
  where config ? 'notify';
250
253
 
@@ -301,8 +304,10 @@ create table if not exists cms_events (
301
304
  region text not null default '',
302
305
  city text not null default '',
303
306
  meta jsonb not null default '{}'::jsonb,
307
+ server_verified boolean not null default false,
304
308
  created_at timestamptz not null default now()
305
309
  );
310
+ alter table cms_events add column if not exists server_verified boolean not null default false;
306
311
  create index if not exists cms_events_day_idx on cms_events (created_at desc);
307
312
  create index if not exists cms_events_session_idx on cms_events (session_key, created_at);
308
313
  create index if not exists cms_events_visitor_idx on cms_events (visitor_key, created_at)
@@ -700,7 +705,8 @@ $$;
700
705
  create or replace function cms_rate_limit_consume(
701
706
  p_key text,
702
707
  p_max integer,
703
- p_window_seconds integer
708
+ p_window_seconds integer,
709
+ p_cost integer
704
710
  ) returns boolean
705
711
  language plpgsql
706
712
  security definer
@@ -709,13 +715,23 @@ as $$
709
715
  declare
710
716
  v_count integer;
711
717
  begin
718
+ if p_key is null or length(p_key) < 1 or length(p_key) > 256
719
+ or p_max < 1 or p_max > 1000000
720
+ or p_window_seconds < 1 or p_window_seconds > 604800
721
+ or p_cost < 1 or p_cost > p_max then
722
+ return false;
723
+ end if;
724
+
712
725
  insert into cms_rate_limits (key, window_start, count)
713
- values (p_key, now(), 1)
726
+ values (p_key, now(), p_cost)
714
727
  on conflict (key) do update
715
728
  set count = case
716
729
  when cms_rate_limits.window_start < now() - make_interval(secs => p_window_seconds)
717
- then 1
718
- else cms_rate_limits.count + 1
730
+ then p_cost
731
+ else least(
732
+ (p_max + 1)::bigint,
733
+ cms_rate_limits.count::bigint + p_cost::bigint
734
+ )::integer
719
735
  end,
720
736
  window_start = case
721
737
  when cms_rate_limits.window_start < now() - make_interval(secs => p_window_seconds)
@@ -728,6 +744,35 @@ begin
728
744
  end;
729
745
  $$;
730
746
 
747
+ create or replace function cms_rate_limit_consume(
748
+ p_key text,
749
+ p_max integer,
750
+ p_window_seconds integer
751
+ ) returns boolean
752
+ language sql
753
+ security definer
754
+ set search_path = public
755
+ as $$
756
+ select cms_rate_limit_consume(p_key, p_max, p_window_seconds, 1);
757
+ $$;
758
+
759
+ create or replace function cms_prune_rate_limits(
760
+ p_before timestamptz
761
+ ) returns bigint
762
+ language plpgsql
763
+ security definer
764
+ set search_path = public
765
+ as $$
766
+ declare
767
+ v_deleted bigint;
768
+ begin
769
+ delete from cms_rate_limits
770
+ where window_start < p_before;
771
+ get diagnostics v_deleted = row_count;
772
+ return v_deleted;
773
+ end;
774
+ $$;
775
+
731
776
  create or replace function cms_update_global(
732
777
  p_key text,
733
778
  p_data jsonb,
@@ -789,8 +834,6 @@ create policy cms_globals_public_read on cms_globals
789
834
  for select using (true);
790
835
 
791
836
  drop policy if exists cms_media_public_read on cms_media;
792
- create policy cms_media_public_read on cms_media
793
- for select using (true);
794
837
 
795
838
  drop policy if exists cms_forms_public_read on cms_forms;
796
839
  create policy cms_forms_public_read on cms_forms
@@ -830,6 +873,8 @@ declare
830
873
  'cms_sync_page(text, text, text, jsonb, jsonb)',
831
874
  'cms_publish_due_pages()',
832
875
  'cms_rate_limit_consume(text, integer, integer)',
876
+ 'cms_rate_limit_consume(text, integer, integer, integer)',
877
+ 'cms_prune_rate_limits(timestamptz)',
833
878
  'cms_update_global(text, jsonb, uuid)'
834
879
  ];
835
880
  begin
@@ -853,6 +898,9 @@ end $fn_lockdown$;
853
898
  -- site-specific role, the PostgREST roles get nothing — so grant explicitly.
854
899
  -- Wrapped per-role so bootstrap also works on plain Postgres (dev/tests).
855
900
  revoke update, delete on cms_page_versions from public;
901
+ revoke select on cms_forms from public;
902
+ revoke select (notify) on cms_forms from public;
903
+ revoke select on cms_media from public;
856
904
  do $grants$
857
905
  begin
858
906
  if exists (select 1 from pg_roles where rolname = 'service_role') then
@@ -868,22 +916,26 @@ begin
868
916
  -- cms_pages and cms_forms get column-level read grants. Anonymous readers
869
917
  -- see published page state only and never form notification addresses.
870
918
  if exists (select 1 from pg_roles where rolname = 'anon') then
871
- grant select on cms_globals, cms_media, cms_redirects to anon;
919
+ grant select on cms_globals, cms_redirects to anon;
920
+ revoke select on cms_media from anon;
872
921
  revoke select on cms_pages from anon;
873
922
  grant select (id, slug, path, published_title, published_seo, status, published_layout, published_at, created_at, updated_at)
874
923
  on cms_pages to anon;
875
924
  revoke all on cms_page_versions from anon;
876
925
  revoke select on cms_forms from anon;
926
+ revoke select (notify) on cms_forms from anon;
877
927
  grant select (id, slug, title, config, success_message, created_at, updated_at)
878
928
  on cms_forms to anon;
879
929
  end if;
880
930
  if exists (select 1 from pg_roles where rolname = 'authenticated') then
881
- grant select on cms_globals, cms_media, cms_profiles, cms_redirects to authenticated;
931
+ grant select on cms_globals, cms_profiles, cms_redirects to authenticated;
932
+ revoke select on cms_media from authenticated;
882
933
  revoke select on cms_pages from authenticated;
883
934
  grant select (id, slug, path, published_title, published_seo, status, published_layout, published_at, created_at, updated_at)
884
935
  on cms_pages to authenticated;
885
936
  revoke all on cms_page_versions from authenticated;
886
937
  revoke select on cms_forms from authenticated;
938
+ revoke select (notify) on cms_forms from authenticated;
887
939
  grant select (id, slug, title, config, success_message, created_at, updated_at)
888
940
  on cms_forms to authenticated;
889
941
  end if;
@@ -0,0 +1,41 @@
1
+ -- SEC-014: move legacy notification settings out of public form config.
2
+
3
+ alter table cms_forms
4
+ add column if not exists notify jsonb not null default '{}'::jsonb;
5
+
6
+ -- Current private settings remain authoritative when both representations
7
+ -- exist. Older rows without private settings inherit the legacy value.
8
+ update cms_forms
9
+ set notify = case
10
+ when jsonb_typeof(notify) = 'object' and notify <> '{}'::jsonb then notify
11
+ else coalesce(config->'notify', '{}'::jsonb)
12
+ end,
13
+ config = config - 'notify'
14
+ where config ? 'notify';
15
+
16
+ -- RLS limits rows, while column grants keep notification settings out of the
17
+ -- public form definition returned by the Supabase Data API.
18
+ revoke select on cms_forms from public;
19
+ revoke select (notify) on cms_forms from public;
20
+
21
+ do $form_grants$
22
+ begin
23
+ if exists (select 1 from pg_roles where rolname = 'anon') then
24
+ revoke select on cms_forms from anon;
25
+ revoke select (notify) on cms_forms from anon;
26
+ grant select (id, slug, title, config, success_message, created_at, updated_at)
27
+ on cms_forms to anon;
28
+ end if;
29
+
30
+ if exists (select 1 from pg_roles where rolname = 'authenticated') then
31
+ revoke select on cms_forms from authenticated;
32
+ revoke select (notify) on cms_forms from authenticated;
33
+ grant select (id, slug, title, config, success_message, created_at, updated_at)
34
+ on cms_forms to authenticated;
35
+ end if;
36
+
37
+ if exists (select 1 from pg_roles where rolname = 'service_role') then
38
+ grant select, insert, update, delete on cms_forms to service_role;
39
+ end if;
40
+ end
41
+ $form_grants$;
@@ -0,0 +1,101 @@
1
+ -- Bound public analytics storage by accepted event count and separate
2
+ -- server-verified conversions from forgeable browser observations.
3
+
4
+ alter table cms_events
5
+ add column if not exists server_verified boolean not null default false;
6
+
7
+ create or replace function cms_rate_limit_consume(
8
+ p_key text,
9
+ p_max integer,
10
+ p_window_seconds integer,
11
+ p_cost integer
12
+ ) returns boolean
13
+ language plpgsql
14
+ security definer
15
+ set search_path = public
16
+ as $$
17
+ declare
18
+ v_count integer;
19
+ begin
20
+ if p_key is null or length(p_key) < 1 or length(p_key) > 256
21
+ or p_max < 1 or p_max > 1000000
22
+ or p_window_seconds < 1 or p_window_seconds > 604800
23
+ or p_cost < 1 or p_cost > p_max then
24
+ return false;
25
+ end if;
26
+
27
+ insert into cms_rate_limits (key, window_start, count)
28
+ values (p_key, now(), p_cost)
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 p_cost
33
+ else least(
34
+ (p_max + 1)::bigint,
35
+ cms_rate_limits.count::bigint + p_cost::bigint
36
+ )::integer
37
+ end,
38
+ window_start = case
39
+ when cms_rate_limits.window_start < now() - make_interval(secs => p_window_seconds)
40
+ then now()
41
+ else cms_rate_limits.window_start
42
+ end
43
+ returning count into v_count;
44
+
45
+ return v_count <= p_max;
46
+ end;
47
+ $$;
48
+
49
+ -- Preserve the existing form-submission caller while new analytics callers
50
+ -- use the weighted overload.
51
+ create or replace function cms_rate_limit_consume(
52
+ p_key text,
53
+ p_max integer,
54
+ p_window_seconds integer
55
+ ) returns boolean
56
+ language sql
57
+ security definer
58
+ set search_path = public
59
+ as $$
60
+ select cms_rate_limit_consume(p_key, p_max, p_window_seconds, 1);
61
+ $$;
62
+
63
+ create or replace function cms_prune_rate_limits(
64
+ p_before timestamptz
65
+ ) returns bigint
66
+ language plpgsql
67
+ security definer
68
+ set search_path = public
69
+ as $$
70
+ declare
71
+ v_deleted bigint;
72
+ begin
73
+ delete from cms_rate_limits
74
+ where window_start < p_before;
75
+ get diagnostics v_deleted = row_count;
76
+ return v_deleted;
77
+ end;
78
+ $$;
79
+
80
+ revoke execute on function cms_rate_limit_consume(text, integer, integer) from public;
81
+ revoke execute on function cms_rate_limit_consume(text, integer, integer, integer) from public;
82
+ revoke execute on function cms_prune_rate_limits(timestamptz) from public;
83
+
84
+ do $lockdown$
85
+ begin
86
+ if exists (select 1 from pg_roles where rolname = 'anon') then
87
+ revoke execute on function cms_rate_limit_consume(text, integer, integer) from anon;
88
+ revoke execute on function cms_rate_limit_consume(text, integer, integer, integer) from anon;
89
+ revoke execute on function cms_prune_rate_limits(timestamptz) from anon;
90
+ end if;
91
+ if exists (select 1 from pg_roles where rolname = 'authenticated') then
92
+ revoke execute on function cms_rate_limit_consume(text, integer, integer) from authenticated;
93
+ revoke execute on function cms_rate_limit_consume(text, integer, integer, integer) from authenticated;
94
+ revoke execute on function cms_prune_rate_limits(timestamptz) from authenticated;
95
+ end if;
96
+ if exists (select 1 from pg_roles where rolname = 'service_role') then
97
+ grant execute on function cms_rate_limit_consume(text, integer, integer) to service_role;
98
+ grant execute on function cms_rate_limit_consume(text, integer, integer, integer) to service_role;
99
+ grant execute on function cms_prune_rate_limits(timestamptz) to service_role;
100
+ end if;
101
+ end $lockdown$;
@@ -0,0 +1,21 @@
1
+ -- SEC-016: keep unused and draft media metadata out of public enumeration.
2
+ -- Published layouts already carry the storage paths needed by public pages.
3
+
4
+ drop policy if exists cms_media_public_read on cms_media;
5
+ revoke select on cms_media from public;
6
+
7
+ do $media_grants$
8
+ begin
9
+ if exists (select 1 from pg_roles where rolname = 'anon') then
10
+ revoke select on cms_media from anon;
11
+ end if;
12
+
13
+ if exists (select 1 from pg_roles where rolname = 'authenticated') then
14
+ revoke select on cms_media from authenticated;
15
+ end if;
16
+
17
+ if exists (select 1 from pg_roles where rolname = 'service_role') then
18
+ grant select, insert, update, delete on cms_media to service_role;
19
+ end if;
20
+ end
21
+ $media_grants$;