@nextblock-cms/db 0.15.10 → 0.16.1

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.
@@ -173,6 +173,36 @@ export declare const getServiceRoleSupabaseClient: () => import('@supabase/supab
173
173
  referencedColumns: ["id"];
174
174
  }];
175
175
  };
176
+ cms_redirects: {
177
+ Row: {
178
+ created_at: string;
179
+ destination_path: string;
180
+ id: string;
181
+ is_active: boolean;
182
+ source_path: string;
183
+ status_code: number;
184
+ updated_at: string;
185
+ };
186
+ Insert: {
187
+ created_at?: string;
188
+ destination_path: string;
189
+ id?: string;
190
+ is_active?: boolean;
191
+ source_path: string;
192
+ status_code?: number;
193
+ updated_at?: string;
194
+ };
195
+ Update: {
196
+ created_at?: string;
197
+ destination_path?: string;
198
+ id?: string;
199
+ is_active?: boolean;
200
+ source_path?: string;
201
+ status_code?: number;
202
+ updated_at?: string;
203
+ };
204
+ Relationships: [];
205
+ };
176
206
  content_drafts: {
177
207
  Row: {
178
208
  author_id: string | null;
@@ -2722,6 +2752,6 @@ export declare const getServiceRoleSupabaseClient: () => import('@supabase/supab
2722
2752
  };
2723
2753
  CompositeTypes: { [_ in never]: never; };
2724
2754
  }, {
2725
- PostgrestVersion: "14.17";
2755
+ PostgrestVersion: "14.15";
2726
2756
  }>;
2727
2757
  export {};
@@ -3,7 +3,7 @@ export type Json = string | number | boolean | null | {
3
3
  } | Json[];
4
4
  export type Database = {
5
5
  __InternalSupabase: {
6
- PostgrestVersion: "14.17";
6
+ PostgrestVersion: "14.15";
7
7
  };
8
8
  public: {
9
9
  Tables: {
@@ -179,6 +179,36 @@ export type Database = {
179
179
  }
180
180
  ];
181
181
  };
182
+ cms_redirects: {
183
+ Row: {
184
+ created_at: string;
185
+ destination_path: string;
186
+ id: string;
187
+ is_active: boolean;
188
+ source_path: string;
189
+ status_code: number;
190
+ updated_at: string;
191
+ };
192
+ Insert: {
193
+ created_at?: string;
194
+ destination_path: string;
195
+ id?: string;
196
+ is_active?: boolean;
197
+ source_path: string;
198
+ status_code?: number;
199
+ updated_at?: string;
200
+ };
201
+ Update: {
202
+ created_at?: string;
203
+ destination_path?: string;
204
+ id?: string;
205
+ is_active?: boolean;
206
+ source_path?: string;
207
+ status_code?: number;
208
+ updated_at?: string;
209
+ };
210
+ Relationships: [];
211
+ };
182
212
  content_drafts: {
183
213
  Row: {
184
214
  author_id: string | null;
@@ -2814,9 +2844,9 @@ type DatabaseWithoutInternals = Omit<Database, "__InternalSupabase">;
2814
2844
  type DefaultSchema = DatabaseWithoutInternals[Extract<keyof Database, "public">];
2815
2845
  export type Tables<DefaultSchemaTableNameOrOptions extends keyof (DefaultSchema["Tables"] & DefaultSchema["Views"]) | {
2816
2846
  schema: keyof DatabaseWithoutInternals;
2817
- }, TableName extends DefaultSchemaTableNameOrOptions extends {
2847
+ }, TableName extends (DefaultSchemaTableNameOrOptions extends {
2818
2848
  schema: keyof DatabaseWithoutInternals;
2819
- } ? keyof (DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Tables"] & DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Views"]) : never = never> = DefaultSchemaTableNameOrOptions extends {
2849
+ } ? keyof (DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Tables"] & DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Views"]) : never) = never> = DefaultSchemaTableNameOrOptions extends {
2820
2850
  schema: keyof DatabaseWithoutInternals;
2821
2851
  } ? (DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Tables"] & DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Views"])[TableName] extends {
2822
2852
  Row: infer R;
@@ -2825,9 +2855,9 @@ export type Tables<DefaultSchemaTableNameOrOptions extends keyof (DefaultSchema[
2825
2855
  } ? R : never : never;
2826
2856
  export type TablesInsert<DefaultSchemaTableNameOrOptions extends keyof DefaultSchema["Tables"] | {
2827
2857
  schema: keyof DatabaseWithoutInternals;
2828
- }, TableName extends DefaultSchemaTableNameOrOptions extends {
2858
+ }, TableName extends (DefaultSchemaTableNameOrOptions extends {
2829
2859
  schema: keyof DatabaseWithoutInternals;
2830
- } ? keyof DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Tables"] : never = never> = DefaultSchemaTableNameOrOptions extends {
2860
+ } ? keyof DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Tables"] : never) = never> = DefaultSchemaTableNameOrOptions extends {
2831
2861
  schema: keyof DatabaseWithoutInternals;
2832
2862
  } ? DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Tables"][TableName] extends {
2833
2863
  Insert: infer I;
@@ -2836,9 +2866,9 @@ export type TablesInsert<DefaultSchemaTableNameOrOptions extends keyof DefaultSc
2836
2866
  } ? I : never : never;
2837
2867
  export type TablesUpdate<DefaultSchemaTableNameOrOptions extends keyof DefaultSchema["Tables"] | {
2838
2868
  schema: keyof DatabaseWithoutInternals;
2839
- }, TableName extends DefaultSchemaTableNameOrOptions extends {
2869
+ }, TableName extends (DefaultSchemaTableNameOrOptions extends {
2840
2870
  schema: keyof DatabaseWithoutInternals;
2841
- } ? keyof DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Tables"] : never = never> = DefaultSchemaTableNameOrOptions extends {
2871
+ } ? keyof DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Tables"] : never) = never> = DefaultSchemaTableNameOrOptions extends {
2842
2872
  schema: keyof DatabaseWithoutInternals;
2843
2873
  } ? DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Tables"][TableName] extends {
2844
2874
  Update: infer U;
@@ -2847,16 +2877,16 @@ export type TablesUpdate<DefaultSchemaTableNameOrOptions extends keyof DefaultSc
2847
2877
  } ? U : never : never;
2848
2878
  export type Enums<DefaultSchemaEnumNameOrOptions extends keyof DefaultSchema["Enums"] | {
2849
2879
  schema: keyof DatabaseWithoutInternals;
2850
- }, EnumName extends DefaultSchemaEnumNameOrOptions extends {
2880
+ }, EnumName extends (DefaultSchemaEnumNameOrOptions extends {
2851
2881
  schema: keyof DatabaseWithoutInternals;
2852
- } ? keyof DatabaseWithoutInternals[DefaultSchemaEnumNameOrOptions["schema"]]["Enums"] : never = never> = DefaultSchemaEnumNameOrOptions extends {
2882
+ } ? keyof DatabaseWithoutInternals[DefaultSchemaEnumNameOrOptions["schema"]]["Enums"] : never) = never> = DefaultSchemaEnumNameOrOptions extends {
2853
2883
  schema: keyof DatabaseWithoutInternals;
2854
2884
  } ? DatabaseWithoutInternals[DefaultSchemaEnumNameOrOptions["schema"]]["Enums"][EnumName] : DefaultSchemaEnumNameOrOptions extends keyof DefaultSchema["Enums"] ? DefaultSchema["Enums"][DefaultSchemaEnumNameOrOptions] : never;
2855
2885
  export type CompositeTypes<PublicCompositeTypeNameOrOptions extends keyof DefaultSchema["CompositeTypes"] | {
2856
2886
  schema: keyof DatabaseWithoutInternals;
2857
- }, CompositeTypeName extends PublicCompositeTypeNameOrOptions extends {
2887
+ }, CompositeTypeName extends (PublicCompositeTypeNameOrOptions extends {
2858
2888
  schema: keyof DatabaseWithoutInternals;
2859
- } ? keyof DatabaseWithoutInternals[PublicCompositeTypeNameOrOptions["schema"]]["CompositeTypes"] : never = never> = PublicCompositeTypeNameOrOptions extends {
2889
+ } ? keyof DatabaseWithoutInternals[PublicCompositeTypeNameOrOptions["schema"]]["CompositeTypes"] : never) = never> = PublicCompositeTypeNameOrOptions extends {
2860
2890
  schema: keyof DatabaseWithoutInternals;
2861
2891
  } ? DatabaseWithoutInternals[PublicCompositeTypeNameOrOptions["schema"]]["CompositeTypes"][CompositeTypeName] : PublicCompositeTypeNameOrOptions extends keyof DefaultSchema["CompositeTypes"] ? DefaultSchema["CompositeTypes"][PublicCompositeTypeNameOrOptions] : never;
2862
2892
  export declare const Constants: {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nextblock-cms/db",
3
- "version": "0.15.10",
3
+ "version": "0.16.1",
4
4
  "main": "index.cjs.js",
5
5
  "module": "index.es.js",
6
6
  "types": "index.d.ts",
@@ -0,0 +1,149 @@
1
+ -- SEO engine: managed 301/302 redirects and operator-configurable robots directives.
2
+ --
3
+ -- Two unrelated-looking things ship in one migration because they are the same
4
+ -- feature from an operator's point of view: the `/cms/settings/seo` screen is where
5
+ -- someone goes to say "this URL moved" and "do not crawl that". Splitting them
6
+ -- across two migrations would only mean two files that must always be applied
7
+ -- together.
8
+ --
9
+ -- WHY A TABLE AND NOT next.config.js redirects(). Redirects are content, not
10
+ -- configuration. An editor who renames a page's slug needs the old URL to keep
11
+ -- working immediately, without a redeploy and without touching source control.
12
+ -- That rules out the build-time array; it has to be data.
13
+ --
14
+ -- WHY THE PROXY READS THIS WITH THE ANON KEY. apps/nextblock/proxy.ts (Next 16's
15
+ -- renamed middleware) resolves redirects before rendering, and it holds a Supabase
16
+ -- client built from the anon key. So the public SELECT policy below is load-bearing:
17
+ -- without an explicit `TO authenticated, anon` grant the proxy's lookup would return
18
+ -- zero rows for every anonymous visitor -- silently, with no error -- and no redirect
19
+ -- would ever fire. Only is_active rows are exposed, so a half-written rule is never
20
+ -- live.
21
+ --
22
+ -- WHY status_code IS AN integer AND NOT AN ENUM. The same reasoning migration 27
23
+ -- recorded for `source`: a Postgres enum cannot be extended and used inside the same
24
+ -- transaction, which is exactly the scope of one migration file. A CHECK constraint
25
+ -- is replaceable in a single statement. 301 and 302 are the only two values the
26
+ -- admin UI offers; 307/308 are deliberately not exposed, because their
27
+ -- method-preserving semantics surprise operators who just want "this page moved".
28
+ --
29
+ -- LOOP SAFETY is enforced in application code (wouldCreateLoop in
30
+ -- @nextblock-cms/utils, called by the admin server actions) rather than by a
31
+ -- constraint, because detecting a cycle requires walking the whole table and a CHECK
32
+ -- constraint can only see one row. The self-redirect case IS cheap to check per row,
33
+ -- so that one is a constraint -- it is the cycle operators actually hit.
34
+ --
35
+ -- SECURITY POSTURE. A redirect can send every visitor of a path to an arbitrary
36
+ -- external origin, which makes this table an open-redirect surface and a phishing
37
+ -- lever. Writes are therefore ADMIN-only -- WRITER is deliberately excluded, matching
38
+ -- site_scripts rather than the content tables. Reads are public but limited to active
39
+ -- rows. The robots settings live in site_settings, whose existing read policy is
40
+ -- already public for non-secret keys; nothing here is secret.
41
+ --
42
+ -- Forward-only and idempotent.
43
+
44
+ CREATE TABLE IF NOT EXISTS public.cms_redirects (
45
+ id uuid DEFAULT gen_random_uuid() NOT NULL,
46
+ -- The incoming site-relative path to match, normalized by the application to a
47
+ -- leading slash with no trailing slash (except root). Matching is exact: prefix
48
+ -- and wildcard rules are deliberately not supported, because they are the usual
49
+ -- way an operator builds an accidental loop.
50
+ source_path text NOT NULL,
51
+ -- Where to send the visitor. Either another site-relative path or a fully
52
+ -- qualified https URL for an off-site move.
53
+ destination_path text NOT NULL,
54
+ -- 301 permanent (the SEO-meaningful one: search engines transfer ranking signals
55
+ -- and browsers cache it aggressively) or 302 temporary.
56
+ status_code integer DEFAULT 301 NOT NULL,
57
+ is_active boolean DEFAULT true NOT NULL,
58
+ created_at timestamp with time zone DEFAULT now() NOT NULL,
59
+ updated_at timestamp with time zone DEFAULT now() NOT NULL,
60
+ CONSTRAINT cms_redirects_pkey PRIMARY KEY (id),
61
+ -- One rule per source. This UNIQUE constraint also provides the index the proxy
62
+ -- lookup relies on, so no separate plain index on source_path is needed.
63
+ CONSTRAINT cms_redirects_source_path_key UNIQUE (source_path),
64
+ CONSTRAINT cms_redirects_status_code_check
65
+ CHECK ((status_code = ANY (ARRAY[301, 302]))),
66
+ -- A source is always a path on this site; accepting an absolute URL here would
67
+ -- silently never match, since the proxy only ever compares pathnames.
68
+ CONSTRAINT cms_redirects_source_path_check
69
+ CHECK ((source_path ~ '^/')),
70
+ -- A destination is either a site-relative path or an https URL. Plain http is
71
+ -- refused so a redirect can never downgrade a visitor to cleartext.
72
+ CONSTRAINT cms_redirects_destination_path_check
73
+ CHECK (((destination_path ~ '^/') OR (destination_path ~ '^https://'))),
74
+ -- The one cycle a single row can express, and the one operators actually create.
75
+ CONSTRAINT cms_redirects_no_self_redirect_check
76
+ CHECK ((source_path <> destination_path))
77
+ );
78
+
79
+ COMMENT ON TABLE public.cms_redirects IS
80
+ 'Operator-managed 301/302 redirects resolved by apps/nextblock/proxy.ts before rendering. Only is_active rows are publicly readable; only ADMIN may write, because a redirect rule is an open-redirect surface.';
81
+ COMMENT ON COLUMN public.cms_redirects.source_path IS
82
+ 'Exact site-relative path to match, normalized to a leading slash and no trailing slash (except root). No wildcards, by design.';
83
+ COMMENT ON COLUMN public.cms_redirects.destination_path IS
84
+ 'Site-relative path or absolute https URL to send the visitor to.';
85
+ COMMENT ON COLUMN public.cms_redirects.status_code IS
86
+ '301 permanent or 302 temporary. 307/308 are not offered by the admin UI.';
87
+ COMMENT ON COLUMN public.cms_redirects.is_active IS
88
+ 'Only active rows are readable by anon, and only active rows are matched by the proxy.';
89
+
90
+ -- The proxy loads the whole active set once per cache window rather than querying per
91
+ -- request, so the hot query is "all active rows" and this partial index is what serves
92
+ -- it. Carrying source_path in the index keeps that read index-only.
93
+ CREATE INDEX IF NOT EXISTS cms_redirects_active_source_idx
94
+ ON public.cms_redirects USING btree (source_path) WHERE (is_active);
95
+
96
+ DROP TRIGGER IF EXISTS set_cms_redirects_updated_at ON public.cms_redirects;
97
+ CREATE TRIGGER set_cms_redirects_updated_at
98
+ BEFORE UPDATE ON public.cms_redirects
99
+ FOR EACH ROW EXECUTE FUNCTION public.set_current_timestamp_updated_at();
100
+
101
+ ALTER TABLE public.cms_redirects ENABLE ROW LEVEL SECURITY;
102
+
103
+ GRANT ALL ON TABLE public.cms_redirects TO anon;
104
+ GRANT ALL ON TABLE public.cms_redirects TO authenticated;
105
+ GRANT ALL ON TABLE public.cms_redirects TO service_role;
106
+
107
+ -- The proxy runs as anon for a logged-out visitor, which is the overwhelming majority
108
+ -- of traffic and the only traffic redirects really matter for. Without this policy the
109
+ -- lookup returns zero rows and the feature is silently dead.
110
+ DROP POLICY IF EXISTS "Public read active redirects" ON public.cms_redirects;
111
+ CREATE POLICY "Public read active redirects" ON public.cms_redirects
112
+ FOR SELECT TO authenticated, anon USING (is_active);
113
+
114
+ DROP POLICY IF EXISTS "Admins read all redirects" ON public.cms_redirects;
115
+ CREATE POLICY "Admins read all redirects" ON public.cms_redirects
116
+ FOR SELECT TO authenticated
117
+ USING (((SELECT public.get_current_user_role()) = 'ADMIN'::public.user_role));
118
+
119
+ DROP POLICY IF EXISTS "Admins insert redirects" ON public.cms_redirects;
120
+ CREATE POLICY "Admins insert redirects" ON public.cms_redirects
121
+ FOR INSERT TO authenticated
122
+ WITH CHECK (((SELECT public.get_current_user_role()) = 'ADMIN'::public.user_role));
123
+
124
+ DROP POLICY IF EXISTS "Admins update redirects" ON public.cms_redirects;
125
+ CREATE POLICY "Admins update redirects" ON public.cms_redirects
126
+ FOR UPDATE TO authenticated
127
+ USING (((SELECT public.get_current_user_role()) = 'ADMIN'::public.user_role))
128
+ WITH CHECK (((SELECT public.get_current_user_role()) = 'ADMIN'::public.user_role));
129
+
130
+ DROP POLICY IF EXISTS "Admins delete redirects" ON public.cms_redirects;
131
+ CREATE POLICY "Admins delete redirects" ON public.cms_redirects
132
+ FOR DELETE TO authenticated
133
+ USING (((SELECT public.get_current_user_role()) = 'ADMIN'::public.user_role));
134
+
135
+ -- Robots directives live in site_settings rather than a table of their own: there is
136
+ -- exactly one robots.txt per install, so a dedicated table would only ever hold one
137
+ -- row, and site_settings already carries the public-read / staff-write policy this
138
+ -- needs. The stored shape matches `RobotsSettings` in @nextblock-cms/utils, and
139
+ -- `normalizeRobotsSettings` tolerates a missing or malformed value -- so this seed is
140
+ -- a convenience for the settings screen, not a correctness requirement for rendering.
141
+ --
142
+ -- ON CONFLICT DO NOTHING because an install that has already configured robots must
143
+ -- not have its rules reset by a replay of this migration.
144
+ INSERT INTO public.site_settings (key, value)
145
+ VALUES (
146
+ 'seo_robots_settings',
147
+ '{"customRules": "", "isIndexingEnabled": true, "sitemapEnabled": true, "userAgentRules": [{"allow": ["/"], "disallow": [], "userAgent": "*"}]}'::jsonb
148
+ )
149
+ ON CONFLICT (key) DO NOTHING;
@@ -0,0 +1,35 @@
1
+ -- Restrict writes to the robots.txt settings row to ADMIN only.
2
+ --
3
+ -- `site_settings.seo_robots_settings` is the row that decides whether the whole site
4
+ -- is crawlable: app/robots.ts reads it on every /robots.txt hit and serves a blanket
5
+ -- `Disallow: /` when `isIndexingEnabled` is false. The CMS only offers that switch
6
+ -- under /cms/settings/seo, and `saveRobotsSettings` re-checks for ADMIN before it
7
+ -- writes — but RLS is the independent boundary, and it did not agree. The baseline
8
+ -- write policies let ADMIN *or* WRITER write any key outside the sensitive array, so
9
+ -- a WRITER holding a normal session could PATCH this row through PostgREST and take
10
+ -- the entire site out of Google. That failure is quiet (nothing in the CMS shows it),
11
+ -- slow to notice (search traffic decays over weeks) and hard to attribute after the
12
+ -- fact, which is why the database has to refuse it rather than trusting the one
13
+ -- server action that happens to guard it today.
14
+ --
15
+ -- The SELECT policy is deliberately NOT touched. This key must stay anon-READABLE:
16
+ -- app/robots.ts reads it with the anon (SSG) client on every crawl, and adding the key
17
+ -- to the read policy's sensitive array would make robots.txt fall back to its
18
+ -- permissive defaults for every crawler. Only INSERT/UPDATE/DELETE move to ADMIN-only,
19
+ -- matching the UI boundary — exactly the shape migration 00000000000011 used for
20
+ -- language_detection_settings, which is anon-readable for the same reason.
21
+ --
22
+ -- Every key already present in each policy's array is preserved (dropping one would
23
+ -- silently widen write access back to WRITER for that key); `seo_robots_settings` is
24
+ -- appended to the three write policies only.
25
+
26
+ DROP POLICY IF EXISTS site_settings_insert_policy ON public.site_settings;
27
+ CREATE POLICY site_settings_insert_policy ON public.site_settings FOR INSERT TO authenticated WITH CHECK ((((key <> ALL (ARRAY['cortex_ai_openrouter_api_key'::text, 'bot_protection_secret'::text, 'email_secret'::text, 'payment_secret'::text, 'language_detection_settings'::text, 'cortex_ai_pexels_api_key'::text, 'cortex_ai_unsplash_access_key'::text, 'cortex_ai_mcp_settings'::text, 'seo_robots_settings'::text])) AND (( SELECT public.get_current_user_role() AS get_current_user_role) = ANY (ARRAY['ADMIN'::public.user_role, 'WRITER'::public.user_role]))) OR ((key = ANY (ARRAY['cortex_ai_openrouter_api_key'::text, 'bot_protection_secret'::text, 'email_secret'::text, 'payment_secret'::text, 'language_detection_settings'::text, 'cortex_ai_pexels_api_key'::text, 'cortex_ai_unsplash_access_key'::text, 'cortex_ai_mcp_settings'::text, 'seo_robots_settings'::text])) AND (( SELECT public.get_current_user_role() AS get_current_user_role) = 'ADMIN'::public.user_role))));
28
+
29
+ DROP POLICY IF EXISTS site_settings_update_policy ON public.site_settings;
30
+ CREATE POLICY site_settings_update_policy ON public.site_settings FOR UPDATE TO authenticated USING ((((key <> ALL (ARRAY['cortex_ai_openrouter_api_key'::text, 'bot_protection_secret'::text, 'email_secret'::text, 'payment_secret'::text, 'language_detection_settings'::text, 'cortex_ai_pexels_api_key'::text, 'cortex_ai_unsplash_access_key'::text, 'cortex_ai_mcp_settings'::text, 'seo_robots_settings'::text])) AND (( SELECT public.get_current_user_role() AS get_current_user_role) = ANY (ARRAY['ADMIN'::public.user_role, 'WRITER'::public.user_role]))) OR ((key = ANY (ARRAY['cortex_ai_openrouter_api_key'::text, 'bot_protection_secret'::text, 'email_secret'::text, 'payment_secret'::text, 'language_detection_settings'::text, 'cortex_ai_pexels_api_key'::text, 'cortex_ai_unsplash_access_key'::text, 'cortex_ai_mcp_settings'::text, 'seo_robots_settings'::text])) AND (( SELECT public.get_current_user_role() AS get_current_user_role) = 'ADMIN'::public.user_role)))) WITH CHECK ((((key <> ALL (ARRAY['cortex_ai_openrouter_api_key'::text, 'bot_protection_secret'::text, 'email_secret'::text, 'payment_secret'::text, 'language_detection_settings'::text, 'cortex_ai_pexels_api_key'::text, 'cortex_ai_unsplash_access_key'::text, 'cortex_ai_mcp_settings'::text, 'seo_robots_settings'::text])) AND (( SELECT public.get_current_user_role() AS get_current_user_role) = ANY (ARRAY['ADMIN'::public.user_role, 'WRITER'::public.user_role]))) OR ((key = ANY (ARRAY['cortex_ai_openrouter_api_key'::text, 'bot_protection_secret'::text, 'email_secret'::text, 'payment_secret'::text, 'language_detection_settings'::text, 'cortex_ai_pexels_api_key'::text, 'cortex_ai_unsplash_access_key'::text, 'cortex_ai_mcp_settings'::text, 'seo_robots_settings'::text])) AND (( SELECT public.get_current_user_role() AS get_current_user_role) = 'ADMIN'::public.user_role))));
31
+
32
+ DROP POLICY IF EXISTS site_settings_delete_policy ON public.site_settings;
33
+ CREATE POLICY site_settings_delete_policy ON public.site_settings FOR DELETE TO authenticated USING ((((key <> ALL (ARRAY['cortex_ai_openrouter_api_key'::text, 'bot_protection_secret'::text, 'email_secret'::text, 'payment_secret'::text, 'language_detection_settings'::text, 'cortex_ai_pexels_api_key'::text, 'cortex_ai_unsplash_access_key'::text, 'cortex_ai_mcp_settings'::text, 'seo_robots_settings'::text])) AND (( SELECT public.get_current_user_role() AS get_current_user_role) = ANY (ARRAY['ADMIN'::public.user_role, 'WRITER'::public.user_role]))) OR ((key = ANY (ARRAY['cortex_ai_openrouter_api_key'::text, 'bot_protection_secret'::text, 'email_secret'::text, 'payment_secret'::text, 'language_detection_settings'::text, 'cortex_ai_pexels_api_key'::text, 'cortex_ai_unsplash_access_key'::text, 'cortex_ai_mcp_settings'::text, 'seo_robots_settings'::text])) AND (( SELECT public.get_current_user_role() AS get_current_user_role) = 'ADMIN'::public.user_role))));
34
+
35
+ -- Forward-only and idempotent.