@nextblock-cms/db 0.21.3 → 0.22.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.
@@ -7,6 +7,58 @@ const corsHeaders = {
7
7
  'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type',
8
8
  };
9
9
 
10
+ /**
11
+ * Dedicated bearer for this function. It runs with the service-role key (which bypasses RLS) and
12
+ * can wipe content, mint confirmed users and fetch arbitrary URLs, so it must NEVER be reachable
13
+ * with the public anon JWT (the platform `verify_jwt` default accepts the anon key, which ships in
14
+ * every site bundle). The operator sets MIGRATION_INGEST_BEARER to a high-entropy secret that is
15
+ * NOT the anon or service key; the function is fail-closed — with the var unset it rejects every
16
+ * request, because this is a maintainer migration tool, not a public endpoint.
17
+ */
18
+ function isAuthorizedIngestRequest(req: Request): boolean {
19
+ const expected = (Deno.env.get('MIGRATION_INGEST_BEARER') ?? '').trim();
20
+ if (!expected) return false;
21
+ const provided = (req.headers.get('Authorization') ?? '').replace(/^Bearer\s+/i, '').trim();
22
+ if (!provided || provided.length !== expected.length) return false;
23
+ // constant-time compare
24
+ let diff = 0;
25
+ for (let i = 0; i < expected.length; i += 1) diff |= provided.charCodeAt(i) ^ expected.charCodeAt(i);
26
+ return diff === 0;
27
+ }
28
+
29
+ /**
30
+ * SSRF guard for the outbound media fetch. Blocks obvious private, loopback, link-local and
31
+ * cloud-metadata destinations and requires http(s). Defence in depth behind the bearer: an
32
+ * authorized caller still should not be able to turn this into an internal port scanner or a
33
+ * cloud-credential exfiltrator. (DNS rebinding is further limited by redirect:'error' on the
34
+ * fetch; full rebind protection would require pinning the resolved IP, which the edge runtime
35
+ * does not expose.)
36
+ */
37
+ function isBlockedFetchTarget(rawUrl: string): boolean {
38
+ let u: URL;
39
+ try {
40
+ u = new URL(rawUrl);
41
+ } catch {
42
+ return true;
43
+ }
44
+ if (u.protocol !== 'http:' && u.protocol !== 'https:') return true;
45
+ const host = u.hostname.toLowerCase().replace(/^\[|\]$/g, '');
46
+ if (host === 'localhost' || host.endsWith('.localhost') || host.endsWith('.internal')) return true;
47
+ // IPv6 loopback / link-local / unique-local
48
+ if (host === '::1' || host.startsWith('fe80:') || host.startsWith('fc') || host.startsWith('fd')) return true;
49
+ // IPv4 literals in private / loopback / link-local / metadata ranges
50
+ const m = host.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/);
51
+ if (m) {
52
+ const [a, b] = [Number(m[1]), Number(m[2])];
53
+ if (a === 10 || a === 127 || a === 0) return true;
54
+ if (a === 169 && b === 254) return true; // link-local incl. 169.254.169.254 metadata
55
+ if (a === 172 && b >= 16 && b <= 31) return true;
56
+ if (a === 192 && b === 168) return true;
57
+ if (a === 100 && b >= 64 && b <= 127) return true; // CGNAT
58
+ }
59
+ return false;
60
+ }
61
+
10
62
  // Upload S3 / R2 Proxy Handler
11
63
  async function processMediaUpload(url: string, r2Config: any, warnings: string[]): Promise<{ publicUrl: string; objectKey: string; contentType: string } | null> {
12
64
  if (!r2Config || !r2Config.account_id) {
@@ -37,11 +89,15 @@ async function processMediaUpload(url: string, r2Config: any, warnings: string[]
37
89
  const extension = contentType.split('/')[1] || 'jpg';
38
90
  filename = `upload_${crypto.randomUUID()}.${extension}`;
39
91
  } else {
92
+ if (isBlockedFetchTarget(url)) {
93
+ warnings.push(`Refused to fetch blocked/internal URL: ${url}`);
94
+ return null;
95
+ }
40
96
  const controller = new AbortController();
41
97
  const timeoutId = setTimeout(() => controller.abort(), 6000); // 6-second timeout
42
98
  let fileResponse;
43
99
  try {
44
- fileResponse = await fetch(url, { signal: controller.signal });
100
+ fileResponse = await fetch(url, { signal: controller.signal, redirect: 'error' });
45
101
  clearTimeout(timeoutId);
46
102
  } catch (err: any) {
47
103
  clearTimeout(timeoutId);
@@ -91,6 +147,13 @@ Deno.serve(async (req: Request) => {
91
147
  return new Response('ok', { headers: corsHeaders });
92
148
  }
93
149
 
150
+ if (!isAuthorizedIngestRequest(req)) {
151
+ return new Response(JSON.stringify({ error: 'Unauthorized' }), {
152
+ headers: { ...corsHeaders, 'Content-Type': 'application/json', 'WWW-Authenticate': 'Bearer' },
153
+ status: 401,
154
+ });
155
+ }
156
+
94
157
  try {
95
158
  const supabaseClient = createClient(
96
159
  Deno.env.get('SUPABASE_URL') ?? '',
@@ -461,17 +524,17 @@ Deno.serve(async (req: Request) => {
461
524
  status: item.status,
462
525
  author_id: author_id,
463
526
  meta_title: item.seo?.meta_title || null,
464
- meta_description: item.seo?.meta_description || null,
465
- feature_image_id,
466
- created_at: item.created_at,
467
- updated_at: item.updated_at,
468
- };
527
+ meta_description: item.seo?.meta_description || null,
528
+ feature_image_id,
529
+ created_at: item.created_at,
530
+ updated_at: item.updated_at,
531
+ };
469
532
 
470
533
  // Posts specific fields
471
534
  if (entity_type === 'post') {
472
- baseData.excerpt = item.excerpt;
473
- baseData.published_at = item.published_at;
474
- }
535
+ baseData.excerpt = item.excerpt;
536
+ baseData.published_at = item.published_at;
537
+ }
475
538
 
476
539
  processedEntities.push(baseData);
477
540
 
@@ -0,0 +1,362 @@
1
+ -- 02021: profiles are readable by their owner and staff only, `role` is admin-only, and the
2
+ -- revision tables are staff-only.
3
+ --
4
+ -- Reported 2026-10-17 and reproduced 2026-10-19 on a local replay of this folder. The baseline
5
+ -- shipped three holes:
6
+ --
7
+ -- * profiles_read_policy was FOR SELECT USING (true) with no TO clause, so it applied to every
8
+ -- role. The anon key that ships in the site bundle could list every account's full_name,
9
+ -- phone, website, avatar_url and role (no e-mail: that stays in auth.users).
10
+ -- * profiles_update_policy let any signed-in account update its own row with no column
11
+ -- restriction, and nothing guarded `role`. One PATCH from a self-registered session set
12
+ -- role = 'ADMIN'; get_current_user_role(), proxy.ts and app/cms/layout.tsx all trust that
13
+ -- column, so the account became a full CMS admin.
14
+ -- * page_revisions, post_revisions and product_revisions were FOR SELECT TO authenticated
15
+ -- USING (true): any customer account could read every historical version of every page, post
16
+ -- and product, published or not.
17
+ --
18
+ -- The fix:
19
+ --
20
+ -- 1. The role lookup moves to private.current_user_role(), a SECURITY DEFINER function in a
21
+ -- schema PostgREST does not expose. Running as the table owner makes it independent of the
22
+ -- profiles policies below, which now call it themselves (as SECURITY INVOKER it would only
23
+ -- work because the OR in the new SELECT policy short-circuits on the caller's own row and a
24
+ -- function with a SET clause is never inlined). public.get_current_user_role() and
25
+ -- public.is_admin() keep their names and signatures as SECURITY INVOKER wrappers, so the
26
+ -- 150 policies that call them are untouched, and the Supabase advisor lints
27
+ -- 0028/0029 (SECURITY DEFINER function executable by anon/authenticated in an exposed
28
+ -- schema) stay quiet: policies run with the querying role's rights, so anon, authenticated
29
+ -- and service_role get USAGE on private and EXECUTE on the helper, but /rest/v1/rpc cannot
30
+ -- reach it. Both helpers pin search_path.
31
+ -- 2. profiles SELECT is TO authenticated: own row, or every row for ADMIN and WRITER (the users
32
+ -- screen, author pickers, revision history, dashboard counts). anon reads nothing. The
33
+ -- UPDATE policy keeps "own row or ADMIN", and its WITH CHECK pins `role` to its current
34
+ -- value unless the caller is ADMIN.
35
+ -- 3. A BEFORE UPDATE trigger rejects a role change made by a request that carries a user
36
+ -- session (auth.uid() is set) unless that user is an ADMIN. It keys off the session, not
37
+ -- the SQL role, so a future SECURITY DEFINER helper called from a USER session cannot
38
+ -- slip past it. service_role and postgres carry no session: the users screen, /setup, the
39
+ -- MCP route, the sandbox reset and migrations all write through them.
40
+ -- 4. The three revision tables read for ADMIN and WRITER only. Their writers were already
41
+ -- staff-only; no USER-role or anonymous code path reads them.
42
+ -- 5. The public site still shows who wrote an approved review, comment or staff reply.
43
+ -- ProductReviewsSection, PostCommentsSection and StaffReplies embed the name and avatar
44
+ -- from the browser client, i.e. as anon, so that read moves off profiles onto author(): a
45
+ -- PostgREST computed relationship on cms_interactions (`select=*,author(full_name,avatar_url)`)
46
+ -- that returns id, full_name and avatar_url, and only for an interaction that is approved.
47
+ -- Its row type is the profile_cards view, which is security_invoker: querying the view
48
+ -- directly yields exactly what profiles allows the caller, nothing more.
49
+ -- 6. user_security_settings loses its owner INSERT/UPDATE policies. evaluateTwoFactor()
50
+ -- trusts mfa_enabled / mfa_type to decide whether /cms asks for a second factor, and the
51
+ -- owner could rewrite both over REST with a first-factor-only session. The security
52
+ -- actions verify the code or TOTP first and write through the service role.
53
+ -- 7. Scheduled content stays private until its time. posts already checked published_at;
54
+ -- pages and products now do too, and products no longer list draft or archived rows (nor
55
+ -- their variants) to anyone but staff. The app already filtered these itself; the Data API
56
+ -- did not.
57
+ -- 8. generate_order_invoice_number() and assign_order_invoice_metadata() become service-role
58
+ -- only (any visitor, or any account, could burn a number from order_invoice_number_seq per
59
+ -- call; every real caller already goes through the service role), and anon loses EXECUTE
60
+ -- on upsert_product_with_variants(), whose is_admin() guard is NULL without a session and
61
+ -- relies on RLS. /setup's post-apply grant pass (lib/setup/schema-apply.ts) re-applies
62
+ -- these revokes after its blanket grants.
63
+ -- 9. TRUNCATE is revoked from anon and authenticated on every table: row-level security does
64
+ -- not govern it, and no API role has a reason to hold it.
65
+ -- 10. site_settings reads become an allow-list. The baseline denied seven secret keys and
66
+ -- published everything else to the public key: staff notification addresses, the business
67
+ -- invoice identity, SMTP host and sender, the 2FA posture, onboarding state, payment
68
+ -- public config. Now anon reads the keys the public site renders or routes on
69
+ -- (`is_admin_created` drives the /setup redirect in proxy.ts, `enabled_payment_providers`
70
+ -- every Buy button); a signed-in customer additionally reads `invoice_settings` for their
71
+ -- own invoices; ADMIN and WRITER read the keys the CMS shell loads; ADMIN reads
72
+ -- everything, so a key a future feature adds is ADMIN-only until this policy lists it.
73
+ -- Writes are unchanged.
74
+ --
75
+ -- Idempotent, and safe on a database that recorded the baseline without running it (docs/04 →
76
+ -- "How a squash crosses live databases"): every SELECT policy on profiles is dropped by name
77
+ -- from pg_policies before the replacement is created, so a generation-1 policy under another
78
+ -- name cannot keep the table open.
79
+
80
+ -- ---------------------------------------------------------------------------
81
+ -- 1. Role helpers: owner-privileged lookup in a non-exposed schema, invoker wrappers in public
82
+ -- ---------------------------------------------------------------------------
83
+ CREATE SCHEMA IF NOT EXISTS private;
84
+ REVOKE ALL ON SCHEMA private FROM PUBLIC;
85
+ -- Policies evaluate with the querying role's rights, and policies without a TO clause run
86
+ -- for anon too, so every API role can call the helper from inside a policy.
87
+ GRANT USAGE ON SCHEMA private TO anon, authenticated, service_role;
88
+
89
+ CREATE OR REPLACE FUNCTION private.current_user_role() RETURNS public.user_role
90
+ LANGUAGE sql STABLE SECURITY DEFINER
91
+ SET search_path TO ''
92
+ AS $$
93
+ SELECT role FROM public.profiles WHERE id = auth.uid();
94
+ $$;
95
+
96
+ REVOKE ALL ON FUNCTION private.current_user_role() FROM PUBLIC;
97
+ GRANT EXECUTE ON FUNCTION private.current_user_role() TO anon, authenticated, service_role;
98
+
99
+ -- Same names, same signatures, same NULL-without-a-session semantics as the baseline.
100
+ CREATE OR REPLACE FUNCTION public.get_current_user_role() RETURNS public.user_role
101
+ LANGUAGE sql STABLE SECURITY INVOKER
102
+ SET search_path TO 'public'
103
+ AS $$
104
+ SELECT private.current_user_role();
105
+ $$;
106
+
107
+ CREATE OR REPLACE FUNCTION public.is_admin() RETURNS boolean
108
+ LANGUAGE sql STABLE SECURITY INVOKER
109
+ SET search_path TO 'public'
110
+ AS $$
111
+ SELECT private.current_user_role() = 'ADMIN'::public.user_role;
112
+ $$;
113
+
114
+ REVOKE ALL ON FUNCTION public.get_current_user_role() FROM PUBLIC;
115
+ GRANT EXECUTE ON FUNCTION public.get_current_user_role() TO anon, authenticated, service_role;
116
+ REVOKE ALL ON FUNCTION public.is_admin() FROM PUBLIC;
117
+ GRANT EXECUTE ON FUNCTION public.is_admin() TO anon, authenticated, service_role;
118
+
119
+ -- ---------------------------------------------------------------------------
120
+ -- 2. profiles: own row or staff reads, no anon, role pinned for non-admins
121
+ -- ---------------------------------------------------------------------------
122
+ DO $$
123
+ DECLARE p record;
124
+ BEGIN
125
+ FOR p IN
126
+ SELECT policyname FROM pg_policies
127
+ WHERE schemaname = 'public' AND tablename = 'profiles' AND cmd = 'SELECT'
128
+ LOOP
129
+ EXECUTE format('DROP POLICY IF EXISTS %I ON public.profiles', p.policyname);
130
+ END LOOP;
131
+ END $$;
132
+
133
+ CREATE POLICY profiles_read_policy ON public.profiles
134
+ FOR SELECT TO authenticated
135
+ USING (
136
+ id = (SELECT auth.uid())
137
+ OR (SELECT public.get_current_user_role()) = ANY (ARRAY['ADMIN'::public.user_role, 'WRITER'::public.user_role])
138
+ );
139
+
140
+ DROP POLICY IF EXISTS profiles_update_policy ON public.profiles;
141
+ CREATE POLICY profiles_update_policy ON public.profiles
142
+ FOR UPDATE TO authenticated
143
+ USING (
144
+ id = (SELECT auth.uid())
145
+ OR (SELECT public.get_current_user_role()) = 'ADMIN'::public.user_role
146
+ )
147
+ WITH CHECK (
148
+ (SELECT public.get_current_user_role()) = 'ADMIN'::public.user_role
149
+ OR (id = (SELECT auth.uid()) AND role = (SELECT public.get_current_user_role()))
150
+ );
151
+
152
+ -- ---------------------------------------------------------------------------
153
+ -- 3. role changes from a user session are admin-only (backstop for the policy above)
154
+ -- ---------------------------------------------------------------------------
155
+ CREATE OR REPLACE FUNCTION public.protect_profile_role() RETURNS trigger
156
+ LANGUAGE plpgsql
157
+ SET search_path TO 'public'
158
+ AS $$
159
+ BEGIN
160
+ IF NEW.role IS DISTINCT FROM OLD.role
161
+ AND auth.uid() IS NOT NULL
162
+ AND (SELECT public.get_current_user_role()) IS DISTINCT FROM 'ADMIN'::public.user_role
163
+ THEN
164
+ RAISE EXCEPTION 'Only an administrator can change a user''s role'
165
+ USING ERRCODE = 'insufficient_privilege';
166
+ END IF;
167
+ RETURN NEW;
168
+ END;
169
+ $$;
170
+
171
+ -- Trigger functions are never called by API roles (02013).
172
+ REVOKE ALL ON FUNCTION public.protect_profile_role() FROM PUBLIC, anon, authenticated;
173
+ GRANT EXECUTE ON FUNCTION public.protect_profile_role() TO service_role;
174
+
175
+ DROP TRIGGER IF EXISTS protect_profile_role ON public.profiles;
176
+ CREATE TRIGGER protect_profile_role
177
+ BEFORE UPDATE ON public.profiles
178
+ FOR EACH ROW
179
+ WHEN (OLD.role IS DISTINCT FROM NEW.role)
180
+ EXECUTE FUNCTION public.protect_profile_role();
181
+
182
+ -- ---------------------------------------------------------------------------
183
+ -- 4. Revision tables: staff read only
184
+ -- ---------------------------------------------------------------------------
185
+ DROP POLICY IF EXISTS page_revisions_read_policy ON public.page_revisions;
186
+ CREATE POLICY page_revisions_read_policy ON public.page_revisions
187
+ FOR SELECT TO authenticated
188
+ USING (
189
+ (SELECT public.get_current_user_role()) = ANY (ARRAY['ADMIN'::public.user_role, 'WRITER'::public.user_role])
190
+ );
191
+
192
+ DROP POLICY IF EXISTS post_revisions_read_policy ON public.post_revisions;
193
+ CREATE POLICY post_revisions_read_policy ON public.post_revisions
194
+ FOR SELECT TO authenticated
195
+ USING (
196
+ (SELECT public.get_current_user_role()) = ANY (ARRAY['ADMIN'::public.user_role, 'WRITER'::public.user_role])
197
+ );
198
+
199
+ DROP POLICY IF EXISTS product_revisions_read_policy ON public.product_revisions;
200
+ CREATE POLICY product_revisions_read_policy ON public.product_revisions
201
+ FOR SELECT TO authenticated
202
+ USING (
203
+ (SELECT public.get_current_user_role()) = ANY (ARRAY['ADMIN'::public.user_role, 'WRITER'::public.user_role])
204
+ );
205
+
206
+ -- ---------------------------------------------------------------------------
207
+ -- 5. Public author card: cms_interactions?select=*,author(full_name,avatar_url)
208
+ -- ---------------------------------------------------------------------------
209
+ CREATE OR REPLACE VIEW public.profile_cards
210
+ WITH (security_invoker = true) AS
211
+ SELECT id, full_name, avatar_url FROM public.profiles;
212
+
213
+ GRANT SELECT ON public.profile_cards TO anon, authenticated, service_role;
214
+
215
+ -- One card per interaction row the caller can already see. The EXISTS re-reads the row so a
216
+ -- hand-built argument cannot turn the function into a name lookup for arbitrary ids. The
217
+ -- owner-privileged read lives in private (advisor lints 0028/0029, as in section 1); the
218
+ -- public function is the invoker wrapper PostgREST embeds as a computed relationship.
219
+ CREATE OR REPLACE FUNCTION private.author_card(p_interaction_id uuid, p_user_id uuid)
220
+ RETURNS SETOF public.profile_cards ROWS 1
221
+ LANGUAGE sql STABLE SECURITY DEFINER
222
+ SET search_path TO ''
223
+ AS $$
224
+ SELECT p.id, p.full_name, p.avatar_url
225
+ FROM public.profiles p
226
+ WHERE p.id = p_user_id
227
+ AND EXISTS (
228
+ SELECT 1 FROM public.cms_interactions ci
229
+ WHERE ci.id = p_interaction_id
230
+ AND ci.user_id = p_user_id
231
+ AND ci.status = 'approved'::public.approval_status
232
+ );
233
+ $$;
234
+
235
+ REVOKE ALL ON FUNCTION private.author_card(uuid, uuid) FROM PUBLIC;
236
+ GRANT EXECUTE ON FUNCTION private.author_card(uuid, uuid) TO anon, authenticated, service_role;
237
+
238
+ CREATE OR REPLACE FUNCTION public.author(public.cms_interactions)
239
+ RETURNS SETOF public.profile_cards ROWS 1
240
+ LANGUAGE sql STABLE SECURITY INVOKER
241
+ SET search_path TO 'public'
242
+ AS $$
243
+ SELECT * FROM private.author_card($1.id, $1.user_id);
244
+ $$;
245
+
246
+ REVOKE ALL ON FUNCTION public.author(public.cms_interactions) FROM PUBLIC;
247
+ GRANT EXECUTE ON FUNCTION public.author(public.cms_interactions) TO anon, authenticated, service_role;
248
+
249
+ -- ---------------------------------------------------------------------------
250
+ -- 6. user_security_settings: written by the server only, read by its owner
251
+ -- ---------------------------------------------------------------------------
252
+ DROP POLICY IF EXISTS user_security_settings_insert_own_policy ON public.user_security_settings;
253
+ DROP POLICY IF EXISTS user_security_settings_update_own_policy ON public.user_security_settings;
254
+
255
+ -- ---------------------------------------------------------------------------
256
+ -- 7. Scheduled, draft and archived content is staff-only until it is live
257
+ -- ---------------------------------------------------------------------------
258
+ DROP POLICY IF EXISTS pages_anon_read_policy ON public.pages;
259
+ CREATE POLICY pages_anon_read_policy ON public.pages
260
+ FOR SELECT TO anon
261
+ USING (
262
+ status = 'published'::public.page_status
263
+ AND (published_at IS NULL OR published_at <= now())
264
+ );
265
+
266
+ DROP POLICY IF EXISTS pages_read_policy ON public.pages;
267
+ CREATE POLICY pages_read_policy ON public.pages
268
+ FOR SELECT TO authenticated
269
+ USING (
270
+ (status = 'published'::public.page_status AND (published_at IS NULL OR published_at <= now()))
271
+ OR (author_id = (SELECT auth.uid()) AND status <> 'published'::public.page_status)
272
+ OR (SELECT public.get_current_user_role()) = ANY (ARRAY['ADMIN'::public.user_role, 'WRITER'::public.user_role])
273
+ );
274
+
275
+ DROP POLICY IF EXISTS "Public can view products" ON public.products;
276
+ CREATE POLICY "Public can view products" ON public.products
277
+ FOR SELECT TO anon, authenticated
278
+ USING (
279
+ (status = 'active' AND (published_at IS NULL OR published_at <= now()))
280
+ OR (SELECT public.get_current_user_role()) = ANY (ARRAY['ADMIN'::public.user_role, 'WRITER'::public.user_role])
281
+ );
282
+
283
+ DROP POLICY IF EXISTS "Public read product_variants" ON public.product_variants;
284
+ CREATE POLICY "Public read product_variants" ON public.product_variants
285
+ FOR SELECT TO anon, authenticated
286
+ USING (
287
+ EXISTS (
288
+ SELECT 1 FROM public.products p
289
+ WHERE p.id = product_variants.product_id
290
+ AND p.status = 'active'
291
+ AND (p.published_at IS NULL OR p.published_at <= now())
292
+ )
293
+ OR (SELECT public.get_current_user_role()) = ANY (ARRAY['ADMIN'::public.user_role, 'WRITER'::public.user_role])
294
+ );
295
+
296
+ -- ---------------------------------------------------------------------------
297
+ -- 8. Invoice numbering and the product upsert are not public RPCs
298
+ -- ---------------------------------------------------------------------------
299
+ REVOKE EXECUTE ON FUNCTION public.generate_order_invoice_number() FROM PUBLIC, anon, authenticated;
300
+ REVOKE EXECUTE ON FUNCTION public.assign_order_invoice_metadata(uuid, timestamp with time zone) FROM PUBLIC, anon, authenticated;
301
+ REVOKE ALL ON SEQUENCE public.order_invoice_number_seq FROM anon, authenticated;
302
+ REVOKE EXECUTE ON FUNCTION public.upsert_product_with_variants(jsonb) FROM PUBLIC, anon;
303
+
304
+ -- ---------------------------------------------------------------------------
305
+ -- 9. API roles never truncate
306
+ -- ---------------------------------------------------------------------------
307
+ REVOKE TRUNCATE ON ALL TABLES IN SCHEMA public FROM anon, authenticated;
308
+
309
+ -- ---------------------------------------------------------------------------
310
+ -- 10. site_settings: read tiers instead of a deny-list
311
+ -- ---------------------------------------------------------------------------
312
+ -- Every SELECT policy goes, by name, so a generation-1 policy under another name cannot keep
313
+ -- the table open (same reasoning as profiles above).
314
+ DO $$
315
+ DECLARE p record;
316
+ BEGIN
317
+ FOR p IN
318
+ SELECT policyname FROM pg_policies
319
+ WHERE schemaname = 'public' AND tablename = 'site_settings' AND cmd = 'SELECT'
320
+ LOOP
321
+ EXECUTE format('DROP POLICY IF EXISTS %I ON public.site_settings', p.policyname);
322
+ END LOOP;
323
+ END $$;
324
+
325
+ -- Keys the public site renders or routes on. Each one has an anonymous reader:
326
+ -- app/lib/site-settings.ts, app/layout.tsx, proxy.ts (language detection, is_admin_created),
327
+ -- app/robots.txt, app/llms.txt, components/BlockRenderer.tsx (bot protection), the logo
328
+ -- loader, libs/ecommerce payments/queries.ts and tax-calculation.ts, and the history-repair
329
+ -- tool's anon fallback for migration_baseline_generation.
330
+ CREATE POLICY site_settings_anon_read_policy ON public.site_settings
331
+ FOR SELECT TO anon
332
+ USING (
333
+ key = ANY (ARRAY[
334
+ 'site_title', 'site_description', 'site_keywords', 'site_social_image',
335
+ 'footer_copyright', 'footer_show_attribution', 'global_css',
336
+ 'language_detection_settings', 'seo_robots_settings', 'bot_protection_public',
337
+ 'active_logo_id', 'is_admin_created', 'privacy_settings',
338
+ 'enabled_payment_providers', 'ecommerce_inventory_settings',
339
+ 'migration_baseline_generation'
340
+ ])
341
+ );
342
+
343
+ CREATE POLICY site_settings_read_policy ON public.site_settings
344
+ FOR SELECT TO authenticated
345
+ USING (
346
+ key = ANY (ARRAY[
347
+ 'site_title', 'site_description', 'site_keywords', 'site_social_image',
348
+ 'footer_copyright', 'footer_show_attribution', 'global_css',
349
+ 'language_detection_settings', 'seo_robots_settings', 'bot_protection_public',
350
+ 'active_logo_id', 'is_admin_created', 'privacy_settings',
351
+ 'enabled_payment_providers', 'ecommerce_inventory_settings',
352
+ 'migration_baseline_generation',
353
+ -- a customer's own invoice carries the seller block (libs/ecommerce invoice-server.ts)
354
+ 'invoice_settings'
355
+ ])
356
+ OR (
357
+ -- loaded by the CMS shell and dashboard for every staff role
358
+ key = ANY (ARRAY['security_settings', 'onboarding_state', 'email_public', 'cortex_ai_site_brief'])
359
+ AND (SELECT public.get_current_user_role()) = ANY (ARRAY['ADMIN'::public.user_role, 'WRITER'::public.user_role])
360
+ )
361
+ OR (SELECT public.get_current_user_role()) = 'ADMIN'::public.user_role
362
+ );