@nextblock-cms/db 0.22.0 → 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.
Files changed (26) hide show
  1. package/README.md +47 -47
  2. package/lib/supabase/server.d.ts +151 -2
  3. package/lib/supabase/types.d.ts +160 -2
  4. package/package.json +1 -1
  5. package/supabase/functions/nextblock-migration-ingest/index.ts +64 -1
  6. package/supabase/migrations/02000_catchup_gen1.sql +5880 -5880
  7. package/supabase/migrations/02001_baseline_schema.sql +2734 -2734
  8. package/supabase/migrations/02002_baseline_constraints_and_indexes.sql +1262 -1262
  9. package/supabase/migrations/02003_baseline_security_and_grants.sql +1577 -1577
  10. package/supabase/migrations/02004_baseline_seed.sql +565 -565
  11. package/supabase/migrations/02005_home_live_demo_promo_cadence_15min.sql +53 -53
  12. package/supabase/migrations/02006_home_marketing_contrast.sql +56 -56
  13. package/supabase/migrations/02007_home_pillar_caption_contrast.sql +44 -44
  14. package/supabase/migrations/02008_home_fr_parity.sql +89 -89
  15. package/supabase/migrations/02009_fr_cortex_posts.sql +67 -67
  16. package/supabase/migrations/02010_shop_hero_redesign.sql +72 -72
  17. package/supabase/migrations/02011_drop_backdrop_blur_on_pages.sql +26 -26
  18. package/supabase/migrations/02012_drop_variant_backdrop_blur.sql +17 -17
  19. package/supabase/migrations/02013_advisor_rls_and_trigger_grants.sql +102 -102
  20. package/supabase/migrations/02014_package_activations_staff_read.sql +18 -18
  21. package/supabase/migrations/02018_install_paths_and_seed_copy_accuracy.sql +1820 -1820
  22. package/supabase/migrations/02019_open_handed_licensing_and_mcp_clients.sql +448 -448
  23. package/supabase/migrations/02020_commerce_product_accuracy.sql +93 -93
  24. package/supabase/migrations/02021_profiles_staff_read_and_role_guard.sql +362 -362
  25. package/supabase/migrations/02022_security_hardening_writer_boundary_and_aal.sql +504 -0
  26. package/supabase/migrations/02023_aal2_for_orders_profiles_site_settings.sql +171 -0
@@ -0,0 +1,504 @@
1
+ -- 02022: close the privilege and disclosure holes the 2026-10-19 red-team confirmed on a local
2
+ -- replay of this folder. Append-only, idempotent, forward-only (02021 is the prior fix; see its
3
+ -- header and docs/04 "Row Level Security Patterns"). Every policy is dropped by name before it is
4
+ -- recreated so a generation-1 policy under a different name cannot keep the old behaviour.
5
+ --
6
+ -- What this migration fixes (DB layer only; the matching app-code changes ship in the same PR):
7
+ --
8
+ -- 1. site_settings write tiers. 02021 tiered READS by key but WRITES still let any WRITER write
9
+ -- every non-"secret" key. Four of those keys are security-sensitive sinks a WRITER could abuse
10
+ -- over PostgREST with only a first-factor session:
11
+ -- * privacy_settings.custom_scripts -> site-wide stored XSS (executes for visitors and in /cms)
12
+ -- * global_css -> site-wide raw <style> injection (incl. /cms)
13
+ -- * is_admin_created -> reset it to re-open the first-admin bootstrap
14
+ -- (WRITER -> ADMIN) AND to funnel all traffic to /setup
15
+ -- (full-site DoS; proxy.ts reads it)
16
+ -- * security_settings -> flip enforce_staff_2fa / stretch trusted_device_days
17
+ -- They move into the ADMIN-only branch of the three write policies. Reads are unchanged
18
+ -- (02021 still lets anon read privacy_settings/global_css/is_admin_created for the public site
19
+ -- and the /setup redirect; security_settings stays ADMIN/WRITER-read).
20
+ --
21
+ -- 2. handle_new_user() becomes a true one-shot. The baseline granted ADMIN to a new sign-up
22
+ -- whenever site_settings.is_admin_created was false, trusting a mutable flag. Now it also
23
+ -- requires that NO admin profile exists yet, so resetting the flag (section 1 already blocks
24
+ -- that for a WRITER, this is defense in depth) or a concurrent first-boot race can never mint a
25
+ -- second ADMIN. Self-heals the flag either way. service_role/postgres writes are unaffected
26
+ -- (the trigger runs as the table owner regardless of caller).
27
+ --
28
+ -- 3. package_activations holds PLAINTEXT license keys. 02014 let ADMIN *and* WRITER read them;
29
+ -- a content-role account does not need vendor license keys. Read drops to ADMIN, and is gated
30
+ -- by a satisfied second factor (section 5).
31
+ --
32
+ -- 4. Scheduled/draft/archived product rows still leaked their media links and category links to
33
+ -- anon (02021 re-cut products + product_variants but not these), and inventory_items published
34
+ -- every SKU's stock. Each anon read is scoped to a product that is actually live, matching the
35
+ -- product_variants policy 02021 already ships. Staff still read everything.
36
+ --
37
+ -- 5. aal2-in-RLS for the sharpest ADMIN-only tables. The app enforces 2FA only in proxy.ts /
38
+ -- app/cms/layout.tsx, so an ADMIN session JWT used directly against PostgREST reaches every
39
+ -- ADMIN-gated table with a first factor alone. private.meets_aal_requirement() + the
40
+ -- public.is_admin_mfa() invoker wrapper (same private/public split as 02021's role helpers, to
41
+ -- keep advisor lints 0028/0029 quiet) require aal2 ONLY for a user who actually enrolled a
42
+ -- factor, so a no-MFA admin is never locked out. Applied here to mcp_access_tokens (self-minting
43
+ -- a usable bearer is a persistence primitive) and package_activations (plaintext keys). Widening
44
+ -- is_admin_mfa() to the other ADMIN policies is a larger, separately-reviewed change (docs/04).
45
+ --
46
+ -- 6. email_2fa_challenges.attempts: a failure counter so lib/auth/twoFactor.ts can lock a code
47
+ -- pool after a few wrong guesses (the email factor had no attempt limit).
48
+ --
49
+ -- 7. ucp_cart_sessions.access_token_hash: sha256 of a per-cart capability token the UCP routes
50
+ -- now require, so a cart UUID alone no longer reads/overwrites/cancels a cart (buyer PII).
51
+ --
52
+ -- 8. redeem_coupon_atomically(uuid): one locked UPDATE that re-checks redemption_limit and
53
+ -- increments redemptions_count in SQL (the app's read-then-write lost updates past the cap).
54
+ -- service_role-only.
55
+ --
56
+ -- 9. media update/delete bound to the uploader: a WRITER touches only rows they uploaded (and
57
+ -- cannot re-assign uploader_id); ADMIN keeps every row.
58
+ --
59
+ -- 10. cortex_request_log: service-role-only per-user accounting the /api/ai/* routes use to cap
60
+ -- a WRITER's OpenRouter spend (no rate or budget cap existed).
61
+
62
+ -- ---------------------------------------------------------------------------
63
+ -- 1. site_settings: move four sensitive keys into the ADMIN-only write tier
64
+ -- ---------------------------------------------------------------------------
65
+ DROP POLICY IF EXISTS site_settings_insert_policy ON public.site_settings;
66
+ CREATE POLICY site_settings_insert_policy ON public.site_settings
67
+ FOR INSERT TO authenticated
68
+ WITH CHECK (
69
+ (key <> ALL (ARRAY['cortex_ai_openrouter_api_key','bot_protection_secret','email_secret','payment_secret','language_detection_settings','cortex_ai_pexels_api_key','cortex_ai_unsplash_access_key','cortex_ai_mcp_settings','seo_robots_settings','privacy_settings','is_admin_created','global_css','security_settings']::text[])
70
+ AND (SELECT public.get_current_user_role()) = ANY (ARRAY['ADMIN'::public.user_role,'WRITER'::public.user_role]))
71
+ OR (key = ANY (ARRAY['cortex_ai_openrouter_api_key','bot_protection_secret','email_secret','payment_secret','language_detection_settings','cortex_ai_pexels_api_key','cortex_ai_unsplash_access_key','cortex_ai_mcp_settings','seo_robots_settings','privacy_settings','is_admin_created','global_css','security_settings']::text[])
72
+ AND (SELECT public.get_current_user_role()) = 'ADMIN'::public.user_role)
73
+ );
74
+
75
+ DROP POLICY IF EXISTS site_settings_update_policy ON public.site_settings;
76
+ CREATE POLICY site_settings_update_policy ON public.site_settings
77
+ FOR UPDATE TO authenticated
78
+ USING (
79
+ (key <> ALL (ARRAY['cortex_ai_openrouter_api_key','bot_protection_secret','email_secret','payment_secret','language_detection_settings','cortex_ai_pexels_api_key','cortex_ai_unsplash_access_key','cortex_ai_mcp_settings','seo_robots_settings','privacy_settings','is_admin_created','global_css','security_settings']::text[])
80
+ AND (SELECT public.get_current_user_role()) = ANY (ARRAY['ADMIN'::public.user_role,'WRITER'::public.user_role]))
81
+ OR (key = ANY (ARRAY['cortex_ai_openrouter_api_key','bot_protection_secret','email_secret','payment_secret','language_detection_settings','cortex_ai_pexels_api_key','cortex_ai_unsplash_access_key','cortex_ai_mcp_settings','seo_robots_settings','privacy_settings','is_admin_created','global_css','security_settings']::text[])
82
+ AND (SELECT public.get_current_user_role()) = 'ADMIN'::public.user_role)
83
+ )
84
+ WITH CHECK (
85
+ (key <> ALL (ARRAY['cortex_ai_openrouter_api_key','bot_protection_secret','email_secret','payment_secret','language_detection_settings','cortex_ai_pexels_api_key','cortex_ai_unsplash_access_key','cortex_ai_mcp_settings','seo_robots_settings','privacy_settings','is_admin_created','global_css','security_settings']::text[])
86
+ AND (SELECT public.get_current_user_role()) = ANY (ARRAY['ADMIN'::public.user_role,'WRITER'::public.user_role]))
87
+ OR (key = ANY (ARRAY['cortex_ai_openrouter_api_key','bot_protection_secret','email_secret','payment_secret','language_detection_settings','cortex_ai_pexels_api_key','cortex_ai_unsplash_access_key','cortex_ai_mcp_settings','seo_robots_settings','privacy_settings','is_admin_created','global_css','security_settings']::text[])
88
+ AND (SELECT public.get_current_user_role()) = 'ADMIN'::public.user_role)
89
+ );
90
+
91
+ DROP POLICY IF EXISTS site_settings_delete_policy ON public.site_settings;
92
+ CREATE POLICY site_settings_delete_policy ON public.site_settings
93
+ FOR DELETE TO authenticated
94
+ USING (
95
+ (key <> ALL (ARRAY['cortex_ai_openrouter_api_key','bot_protection_secret','email_secret','payment_secret','language_detection_settings','cortex_ai_pexels_api_key','cortex_ai_unsplash_access_key','cortex_ai_mcp_settings','seo_robots_settings','privacy_settings','is_admin_created','global_css','security_settings']::text[])
96
+ AND (SELECT public.get_current_user_role()) = ANY (ARRAY['ADMIN'::public.user_role,'WRITER'::public.user_role]))
97
+ OR (key = ANY (ARRAY['cortex_ai_openrouter_api_key','bot_protection_secret','email_secret','payment_secret','language_detection_settings','cortex_ai_pexels_api_key','cortex_ai_unsplash_access_key','cortex_ai_mcp_settings','seo_robots_settings','privacy_settings','is_admin_created','global_css','security_settings']::text[])
98
+ AND (SELECT public.get_current_user_role()) = 'ADMIN'::public.user_role)
99
+ );
100
+
101
+ -- ---------------------------------------------------------------------------
102
+ -- 2. handle_new_user(): ADMIN only for the genuine first account, never again
103
+ -- ---------------------------------------------------------------------------
104
+ CREATE OR REPLACE FUNCTION public.handle_new_user()
105
+ RETURNS trigger
106
+ LANGUAGE plpgsql
107
+ SECURITY DEFINER
108
+ SET search_path TO 'public'
109
+ AS $function$
110
+ DECLARE
111
+ admin_flag_set boolean := false;
112
+ admin_exists boolean := false;
113
+ user_role public.user_role;
114
+ v_full_name text;
115
+ v_avatar_url text;
116
+ BEGIN
117
+ INSERT INTO public.site_settings (key, value)
118
+ VALUES ('is_admin_created', 'false'::jsonb)
119
+ ON CONFLICT (key) DO NOTHING;
120
+
121
+ SELECT COALESCE(value::jsonb::boolean, false)
122
+ INTO admin_flag_set
123
+ FROM public.site_settings
124
+ WHERE key = 'is_admin_created'
125
+ FOR UPDATE;
126
+
127
+ -- The flag alone is not trusted: a reset (now ADMIN-only to write, but defense in depth) or a
128
+ -- concurrent first-boot must never mint a second ADMIN. Promotion requires BOTH that the flag is
129
+ -- unset AND that no admin profile exists yet. The FOR UPDATE above serialises concurrent sign-ups.
130
+ SELECT EXISTS (SELECT 1 FROM public.profiles WHERE role = 'ADMIN'::public.user_role)
131
+ INTO admin_exists;
132
+
133
+ IF admin_flag_set = false AND admin_exists = false THEN
134
+ user_role := 'ADMIN'::public.user_role;
135
+ ELSE
136
+ user_role := 'USER'::public.user_role;
137
+ END IF;
138
+
139
+ -- Always mark bootstrap complete once any account is created, so a drifted/reset flag self-heals.
140
+ UPDATE public.site_settings
141
+ SET value = 'true'::jsonb
142
+ WHERE key = 'is_admin_created' AND value IS DISTINCT FROM 'true'::jsonb;
143
+
144
+ v_full_name := NEW.raw_user_meta_data->>'full_name';
145
+ v_avatar_url := NEW.raw_user_meta_data->>'avatar_url';
146
+
147
+ INSERT INTO public.profiles (id, role, full_name, avatar_url)
148
+ VALUES (NEW.id, user_role, v_full_name, v_avatar_url)
149
+ ON CONFLICT (id) DO UPDATE SET
150
+ full_name = EXCLUDED.full_name,
151
+ avatar_url = EXCLUDED.avatar_url;
152
+
153
+ RETURN NEW;
154
+ END;
155
+ $function$;
156
+
157
+ -- ---------------------------------------------------------------------------
158
+ -- 5a. aal2-in-RLS helpers (defined before section 3/5b use them)
159
+ -- ---------------------------------------------------------------------------
160
+ -- True unless the caller has a verified MFA factor but the current session is not aal2. A user
161
+ -- who never enrolled a factor always passes, so no admin is ever locked out by enabling this.
162
+ CREATE OR REPLACE FUNCTION private.meets_aal_requirement()
163
+ RETURNS boolean
164
+ LANGUAGE sql STABLE SECURITY DEFINER
165
+ SET search_path TO ''
166
+ AS $$
167
+ SELECT COALESCE((auth.jwt() ->> 'aal'), 'aal1') = 'aal2'
168
+ OR NOT EXISTS (
169
+ SELECT 1 FROM auth.mfa_factors f
170
+ WHERE f.user_id = auth.uid() AND f.status = 'verified'
171
+ );
172
+ $$;
173
+ REVOKE ALL ON FUNCTION private.meets_aal_requirement() FROM PUBLIC;
174
+ GRANT EXECUTE ON FUNCTION private.meets_aal_requirement() TO anon, authenticated, service_role;
175
+
176
+ -- SECURITY INVOKER public wrapper (never a SECURITY DEFINER function in public — advisor 0028/0029).
177
+ CREATE OR REPLACE FUNCTION public.is_admin_mfa()
178
+ RETURNS boolean
179
+ LANGUAGE sql STABLE SECURITY INVOKER
180
+ SET search_path TO 'public'
181
+ AS $$
182
+ SELECT public.is_admin() AND private.meets_aal_requirement();
183
+ $$;
184
+ REVOKE ALL ON FUNCTION public.is_admin_mfa() FROM PUBLIC;
185
+ GRANT EXECUTE ON FUNCTION public.is_admin_mfa() TO anon, authenticated, service_role;
186
+
187
+ -- ---------------------------------------------------------------------------
188
+ -- 3. package_activations: ADMIN read only, second factor required
189
+ -- ---------------------------------------------------------------------------
190
+ DROP POLICY IF EXISTS "Staff read package activations" ON public.package_activations;
191
+ CREATE POLICY "Staff read package activations" ON public.package_activations
192
+ FOR SELECT TO authenticated
193
+ USING ((SELECT public.is_admin_mfa()));
194
+
195
+ -- ---------------------------------------------------------------------------
196
+ -- 4. Draft/archived/scheduled products: media, categories and stock stay staff-only
197
+ -- ---------------------------------------------------------------------------
198
+ DROP POLICY IF EXISTS "Public can view product media" ON public.product_media;
199
+ CREATE POLICY "Public can view product media" ON public.product_media
200
+ FOR SELECT TO anon, authenticated
201
+ USING (
202
+ EXISTS (
203
+ SELECT 1 FROM public.products p
204
+ WHERE p.id = product_media.product_id
205
+ AND p.status = 'active'
206
+ AND (p.published_at IS NULL OR p.published_at <= now())
207
+ )
208
+ OR (SELECT public.get_current_user_role()) = ANY (ARRAY['ADMIN'::public.user_role,'WRITER'::public.user_role])
209
+ );
210
+
211
+ DROP POLICY IF EXISTS "Public can view product_categories" ON public.product_categories;
212
+ CREATE POLICY "Public can view product_categories" ON public.product_categories
213
+ FOR SELECT TO anon, authenticated
214
+ USING (
215
+ EXISTS (
216
+ SELECT 1 FROM public.products p
217
+ WHERE p.id = product_categories.product_id
218
+ AND p.status = 'active'
219
+ AND (p.published_at IS NULL OR p.published_at <= now())
220
+ )
221
+ OR (SELECT public.get_current_user_role()) = ANY (ARRAY['ADMIN'::public.user_role,'WRITER'::public.user_role])
222
+ );
223
+
224
+ DROP POLICY IF EXISTS "Public can view inventory items" ON public.inventory_items;
225
+ CREATE POLICY "Public can view inventory items" ON public.inventory_items
226
+ FOR SELECT TO anon, authenticated
227
+ USING (
228
+ EXISTS (
229
+ SELECT 1 FROM public.products p
230
+ WHERE p.sku = inventory_items.sku
231
+ AND p.status = 'active'
232
+ AND (p.published_at IS NULL OR p.published_at <= now())
233
+ )
234
+ OR EXISTS (
235
+ SELECT 1 FROM public.product_variants v
236
+ JOIN public.products p ON p.id = v.product_id
237
+ WHERE v.sku = inventory_items.sku
238
+ AND p.status = 'active'
239
+ AND (p.published_at IS NULL OR p.published_at <= now())
240
+ )
241
+ OR (SELECT public.get_current_user_role()) = ANY (ARRAY['ADMIN'::public.user_role,'WRITER'::public.user_role])
242
+ );
243
+
244
+ -- ---------------------------------------------------------------------------
245
+ -- 5b. aal2 for mcp_access_tokens (token self-mint is a persistence primitive)
246
+ -- ---------------------------------------------------------------------------
247
+ DROP POLICY IF EXISTS mcp_access_tokens_admin_select ON public.mcp_access_tokens;
248
+ CREATE POLICY mcp_access_tokens_admin_select ON public.mcp_access_tokens
249
+ FOR SELECT TO authenticated USING ((SELECT public.is_admin_mfa()));
250
+
251
+ DROP POLICY IF EXISTS mcp_access_tokens_admin_insert ON public.mcp_access_tokens;
252
+ CREATE POLICY mcp_access_tokens_admin_insert ON public.mcp_access_tokens
253
+ FOR INSERT TO authenticated WITH CHECK ((SELECT public.is_admin_mfa()));
254
+
255
+ DROP POLICY IF EXISTS mcp_access_tokens_admin_update ON public.mcp_access_tokens;
256
+ CREATE POLICY mcp_access_tokens_admin_update ON public.mcp_access_tokens
257
+ FOR UPDATE TO authenticated USING ((SELECT public.is_admin_mfa())) WITH CHECK ((SELECT public.is_admin_mfa()));
258
+
259
+ DROP POLICY IF EXISTS mcp_access_tokens_admin_delete ON public.mcp_access_tokens;
260
+ CREATE POLICY mcp_access_tokens_admin_delete ON public.mcp_access_tokens
261
+ FOR DELETE TO authenticated USING ((SELECT public.is_admin_mfa()));
262
+
263
+ -- ---------------------------------------------------------------------------
264
+ -- 6. Email-2FA brute force: give each challenge a failure counter
265
+ -- ---------------------------------------------------------------------------
266
+ -- The email second-factor had no attempt limit: a party holding a victim's password (first
267
+ -- factor) could iterate all 10^6 codes against the unthrottled verify path. The counter lets
268
+ -- lib/auth/twoFactor.ts lock the live code pool after a few wrong guesses. Column only; no grant
269
+ -- needed (table already granted), and the table is service-role/owner-written.
270
+ ALTER TABLE public.email_2fa_challenges ADD COLUMN IF NOT EXISTS attempts integer NOT NULL DEFAULT 0;
271
+
272
+ -- Atomic failure-counter bump so concurrent wrong guesses cannot each read the same count and
273
+ -- overwrite one another (a read-then-write in app code is racy). Increments every live code for
274
+ -- the user under the row locks and returns the new maximum; the server burns the pool when that
275
+ -- reaches the limit. SECURITY DEFINER in `private` (never callable over /rpc) and service_role-only.
276
+ CREATE OR REPLACE FUNCTION private.bump_email_2fa_attempts(p_user_id uuid)
277
+ RETURNS integer
278
+ LANGUAGE sql VOLATILE SECURITY DEFINER
279
+ SET search_path TO ''
280
+ AS $$
281
+ WITH bumped AS (
282
+ UPDATE public.email_2fa_challenges
283
+ SET attempts = attempts + 1
284
+ WHERE user_id = p_user_id
285
+ AND consumed_at IS NULL
286
+ AND expires_at > now()
287
+ RETURNING attempts
288
+ )
289
+ SELECT COALESCE(MAX(attempts), 0)::integer FROM bumped;
290
+ $$;
291
+ REVOKE ALL ON FUNCTION private.bump_email_2fa_attempts(uuid) FROM PUBLIC, anon, authenticated;
292
+ GRANT EXECUTE ON FUNCTION private.bump_email_2fa_attempts(uuid) TO service_role;
293
+
294
+ -- SECURITY INVOKER public wrapper so the server can call it through PostgREST rpc with the
295
+ -- service-role client (the private function is not itself API-reachable). Not granted to anon.
296
+ CREATE OR REPLACE FUNCTION public.bump_email_2fa_attempts(p_user_id uuid)
297
+ RETURNS integer
298
+ LANGUAGE sql VOLATILE SECURITY INVOKER
299
+ SET search_path TO 'public'
300
+ AS $$
301
+ SELECT private.bump_email_2fa_attempts(p_user_id);
302
+ $$;
303
+ REVOKE ALL ON FUNCTION public.bump_email_2fa_attempts(uuid) FROM PUBLIC, anon, authenticated;
304
+ GRANT EXECUTE ON FUNCTION public.bump_email_2fa_attempts(uuid) TO service_role;
305
+
306
+ -- ---------------------------------------------------------------------------
307
+ -- 7. UCP carts: a per-cart capability token (ucp-cart-idor)
308
+ -- ---------------------------------------------------------------------------
309
+ -- /ucp/v1/carts/{id} authorised on the cart UUID alone through the service-role client, so anyone
310
+ -- who learned an id could read the buyer PII in buyer_identity, overwrite or cancel the cart. The
311
+ -- app now mints a random capability token when it creates a cart, returns it once, and stores
312
+ -- only its sha256 hex here; every later read/write must present it. Nullable so carts created
313
+ -- before this column stay readable by the app's legacy path until they expire (7 days). Column
314
+ -- only; the table is already granted and its sole policy is service_role-only (anon /
315
+ -- authenticated hold table grants but no policy, so RLS denies them every row).
316
+ ALTER TABLE public.ucp_cart_sessions ADD COLUMN IF NOT EXISTS access_token_hash text;
317
+
318
+ -- ---------------------------------------------------------------------------
319
+ -- 8. Coupons: atomic, limit-aware redemption counter (coupon-redemption-race)
320
+ -- ---------------------------------------------------------------------------
321
+ -- libs/ecommerce coupon-server.ts incremented redemptions_count with a SELECT then an UPDATE that
322
+ -- wrote an app-computed literal, and checked the limit against the lagging read: concurrent
323
+ -- checkouts each passed the gate and overwrote each other's increment, so a redemption_limit=N
324
+ -- coupon could be redeemed well past N. This single UPDATE takes the row lock, re-checks the
325
+ -- limit under it and increments in SQL, so exactly N calls return true. It returns false (never
326
+ -- NULL) when the coupon is missing, inactive or exhausted. SECURITY INVOKER and service_role-only:
327
+ -- the checkout server calls it with the service client (RLS bypass is intended there); no browser
328
+ -- or PostgREST caller may bump a counter directly.
329
+ CREATE OR REPLACE FUNCTION public.redeem_coupon_atomically(p_coupon_id uuid)
330
+ RETURNS boolean
331
+ LANGUAGE sql
332
+ SECURITY INVOKER
333
+ SET search_path TO 'public'
334
+ AS $$
335
+ WITH redeemed AS (
336
+ UPDATE public.coupons
337
+ SET redemptions_count = COALESCE(redemptions_count, 0) + 1,
338
+ updated_at = now()
339
+ WHERE id = p_coupon_id
340
+ AND is_active
341
+ AND (redemption_limit IS NULL OR COALESCE(redemptions_count, 0) < redemption_limit)
342
+ RETURNING 1
343
+ )
344
+ SELECT EXISTS (SELECT 1 FROM redeemed);
345
+ $$;
346
+ REVOKE ALL ON FUNCTION public.redeem_coupon_atomically(uuid) FROM PUBLIC, anon, authenticated;
347
+ GRANT EXECUTE ON FUNCTION public.redeem_coupon_atomically(uuid) TO service_role;
348
+
349
+ -- ---------------------------------------------------------------------------
350
+ -- 9. media: a WRITER edits and deletes only their own uploads (media-no-ownership-binding)
351
+ -- ---------------------------------------------------------------------------
352
+ -- media_update_policy / media_delete_policy let any WRITER overwrite or delete ANY media row
353
+ -- (nothing bound the row to its uploader), and the UPDATE check let a WRITER re-assign
354
+ -- uploader_id. ADMIN keeps every row. A WRITER keeps rows where uploader_id is their own id, and
355
+ -- WITH CHECK keeps uploader_id pinned to them on update. The /cms/media actions use the cookie
356
+ -- client, so this is enforced for the UI as well as for raw PostgREST. media_insert_policy and
357
+ -- media_read_policy are unchanged (the app sets uploader_id = the session user on insert).
358
+ DROP POLICY IF EXISTS media_update_policy ON public.media;
359
+ CREATE POLICY media_update_policy ON public.media
360
+ FOR UPDATE TO authenticated
361
+ USING (
362
+ (SELECT public.is_admin()) IS TRUE
363
+ OR (uploader_id = (SELECT auth.uid())
364
+ AND (SELECT public.get_current_user_role()) = ANY (ARRAY['ADMIN'::public.user_role,'WRITER'::public.user_role]))
365
+ )
366
+ WITH CHECK (
367
+ (SELECT public.is_admin()) IS TRUE
368
+ OR (uploader_id = (SELECT auth.uid())
369
+ AND (SELECT public.get_current_user_role()) = ANY (ARRAY['ADMIN'::public.user_role,'WRITER'::public.user_role]))
370
+ );
371
+
372
+ DROP POLICY IF EXISTS media_delete_policy ON public.media;
373
+ CREATE POLICY media_delete_policy ON public.media
374
+ FOR DELETE TO authenticated
375
+ USING (
376
+ (SELECT public.is_admin()) IS TRUE
377
+ OR (uploader_id = (SELECT auth.uid())
378
+ AND (SELECT public.get_current_user_role()) = ANY (ARRAY['ADMIN'::public.user_role,'WRITER'::public.user_role]))
379
+ );
380
+
381
+ -- ---------------------------------------------------------------------------
382
+ -- 10. cortex_request_log: per-user request accounting for the AI routes (writer-openrouter-cost-abuse)
383
+ -- ---------------------------------------------------------------------------
384
+ -- The four /api/ai/* routes accepted unlimited calls from any WRITER with no per-user rate or
385
+ -- spend cap, each one billed to the operator's OpenRouter key. The routes now record one row per
386
+ -- accepted request (service client) and refuse with 429 once a user exceeds the window budget;
387
+ -- the index serves that count. Service-role only: RLS is ON with NO anon/authenticated policy
388
+ -- (service_role bypasses RLS), and whatever Supabase's default privileges granted at CREATE is
389
+ -- revoked before the exact grant below, so a browser session can neither read other users'
390
+ -- activity nor pad or purge its own counter. Old rows are pruned by the same server path
391
+ -- (DELETE granted for that).
392
+ CREATE TABLE IF NOT EXISTS public.cortex_request_log (
393
+ id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
394
+ user_id uuid NOT NULL,
395
+ route text NOT NULL,
396
+ created_at timestamptz NOT NULL DEFAULT now()
397
+ );
398
+ CREATE INDEX IF NOT EXISTS cortex_request_log_user_created_idx
399
+ ON public.cortex_request_log (user_id, created_at DESC);
400
+ ALTER TABLE public.cortex_request_log ENABLE ROW LEVEL SECURITY;
401
+ REVOKE ALL ON TABLE public.cortex_request_log FROM PUBLIC, anon, authenticated, service_role;
402
+ GRANT SELECT, INSERT, DELETE ON TABLE public.cortex_request_log TO service_role;
403
+
404
+ -- ---------------------------------------------------------------------------
405
+ -- 11. Seed the shopper-facing copy the new checkout/coupon guards return (EN + FR)
406
+ -- ---------------------------------------------------------------------------
407
+ -- The coupon validation path now returns one uniform key for every rejection (so it is not an
408
+ -- existence oracle), and the server-side shipping re-validation adds two keys. t(key) returns the
409
+ -- KEY for an unseeded key, so these must ship or shoppers see a raw key. Bilingual per CLAUDE.md.
410
+ INSERT INTO public.translations (key, translations, created_at, updated_at)
411
+ VALUES
412
+ ('ecommerce.coupon_invalid', '{"en":"This coupon code is invalid or can''t be applied to your cart.","fr":"Ce code promo est invalide ou ne peut pas être appliqué à votre panier."}'::jsonb, now(), now()),
413
+ ('ecommerce.checkout_invalid_shipping_method', '{"en":"That shipping method is not available for your address. Please choose another.","fr":"Ce mode de livraison n''est pas disponible pour votre adresse. Veuillez en choisir un autre."}'::jsonb, now(), now()),
414
+ ('ecommerce.checkout_shipping_address_required', '{"en":"A shipping address is required to calculate shipping.","fr":"Une adresse de livraison est requise pour calculer les frais d''expédition."}'::jsonb, now(), now())
415
+ ON CONFLICT (key) DO NOTHING;
416
+
417
+ -- ---------------------------------------------------------------------------
418
+ -- 12. order_items: a database backstop for the positive-quantity checkout guard
419
+ -- ---------------------------------------------------------------------------
420
+ -- The providers now clamp quantity, but apply_order_inventory_deduction and any other writer
421
+ -- still trust SUM(order_items.quantity). A CHECK closes every path. NOT VALID first so the add is
422
+ -- instant and cannot fail on any pre-existing bad row; VALIDATE separately (safe if all rows pass,
423
+ -- and this is a fresh constraint). Idempotent.
424
+ ALTER TABLE public.order_items DROP CONSTRAINT IF EXISTS order_items_quantity_positive;
425
+ ALTER TABLE public.order_items ADD CONSTRAINT order_items_quantity_positive CHECK (quantity > 0) NOT VALID;
426
+ ALTER TABLE public.order_items VALIDATE CONSTRAINT order_items_quantity_positive;
427
+
428
+ -- ---------------------------------------------------------------------------
429
+ -- 13. cms_interactions: cap how fast one account can create pending interactions
430
+ -- ---------------------------------------------------------------------------
431
+ -- cms_interactions_insert_policy admits unlimited own-user pending rows; a signed-up account can
432
+ -- flood the moderation queue straight through PostgREST (the app-layer limit never sees that path).
433
+ -- A BEFORE INSERT trigger enforces the cap in the DB for ordinary users only; staff and the
434
+ -- service role (moderation, replies) are exempt. SECURITY DEFINER in private, not API-callable.
435
+ CREATE OR REPLACE FUNCTION private.enforce_cms_interaction_rate_limit()
436
+ RETURNS trigger
437
+ LANGUAGE plpgsql SECURITY DEFINER
438
+ SET search_path TO ''
439
+ AS $$
440
+ DECLARE
441
+ recent_count integer;
442
+ pending_total integer;
443
+ BEGIN
444
+ -- Only ordinary signed-in users are limited; service_role/postgres carry no session, staff are trusted.
445
+ IF auth.uid() IS NULL
446
+ OR (SELECT public.get_current_user_role()) = ANY (ARRAY['ADMIN'::public.user_role, 'WRITER'::public.user_role]) THEN
447
+ RETURN NEW;
448
+ END IF;
449
+
450
+ SELECT count(*) INTO recent_count
451
+ FROM public.cms_interactions
452
+ WHERE user_id = auth.uid()
453
+ AND status = 'pending'::public.approval_status
454
+ AND created_at > now() - interval '10 minutes';
455
+ IF recent_count >= 5 THEN
456
+ RAISE EXCEPTION 'Too many submissions. Please wait a few minutes and try again.'
457
+ USING ERRCODE = 'check_violation';
458
+ END IF;
459
+
460
+ SELECT count(*) INTO pending_total
461
+ FROM public.cms_interactions
462
+ WHERE user_id = auth.uid()
463
+ AND status = 'pending'::public.approval_status;
464
+ IF pending_total >= 50 THEN
465
+ RAISE EXCEPTION 'You have too many submissions awaiting review.'
466
+ USING ERRCODE = 'check_violation';
467
+ END IF;
468
+
469
+ RETURN NEW;
470
+ END;
471
+ $$;
472
+
473
+ REVOKE ALL ON FUNCTION private.enforce_cms_interaction_rate_limit() FROM PUBLIC, anon, authenticated;
474
+
475
+ DROP TRIGGER IF EXISTS enforce_cms_interaction_rate_limit ON public.cms_interactions;
476
+ CREATE TRIGGER enforce_cms_interaction_rate_limit
477
+ BEFORE INSERT ON public.cms_interactions
478
+ FOR EACH ROW
479
+ EXECUTE FUNCTION private.enforce_cms_interaction_rate_limit();
480
+
481
+ -- ---------------------------------------------------------------------------
482
+ -- 14. aal2 for coupons and coupon_redemptions (money-adjacent, app reads them via service role)
483
+ -- ---------------------------------------------------------------------------
484
+ -- Widens the section-5 posture to the next tier whose app access never uses the cookie client
485
+ -- (CMS coupon pages and the storefront validator both use the service role), so gating the
486
+ -- authenticated admin policies behind is_admin_mfa() closes the direct-PostgREST aal1 path
487
+ -- without locking a "remember this device" admin out of any screen. Tables the CMS still reads
488
+ -- with the cookie client (orders, user_addresses, site_settings secrets, profiles) are left on the
489
+ -- plain role check until those reads move to the service role (docs/04).
490
+ DROP POLICY IF EXISTS coupons_admin_select_policy ON public.coupons;
491
+ CREATE POLICY coupons_admin_select_policy ON public.coupons
492
+ FOR SELECT TO authenticated USING (((SELECT public.is_admin_mfa()) IS TRUE));
493
+ DROP POLICY IF EXISTS coupons_admin_insert_policy ON public.coupons;
494
+ CREATE POLICY coupons_admin_insert_policy ON public.coupons
495
+ FOR INSERT TO authenticated WITH CHECK (((SELECT public.is_admin_mfa()) IS TRUE));
496
+ DROP POLICY IF EXISTS coupons_admin_update_policy ON public.coupons;
497
+ CREATE POLICY coupons_admin_update_policy ON public.coupons
498
+ FOR UPDATE TO authenticated USING (((SELECT public.is_admin_mfa()) IS TRUE)) WITH CHECK (((SELECT public.is_admin_mfa()) IS TRUE));
499
+ DROP POLICY IF EXISTS coupons_admin_delete_policy ON public.coupons;
500
+ CREATE POLICY coupons_admin_delete_policy ON public.coupons
501
+ FOR DELETE TO authenticated USING (((SELECT public.is_admin_mfa()) IS TRUE));
502
+ DROP POLICY IF EXISTS coupon_redemptions_admin_select_policy ON public.coupon_redemptions;
503
+ CREATE POLICY coupon_redemptions_admin_select_policy ON public.coupon_redemptions
504
+ FOR SELECT TO authenticated USING (((SELECT public.is_admin_mfa()) IS TRUE));