@nextblock-cms/db 0.21.2 → 0.22.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +47 -47
- package/package.json +1 -1
- package/supabase/functions/nextblock-migration-ingest/index.ts +8 -8
- package/supabase/migrations/02000_catchup_gen1.sql +5880 -5880
- package/supabase/migrations/02001_baseline_schema.sql +2734 -2734
- package/supabase/migrations/02002_baseline_constraints_and_indexes.sql +1262 -1262
- package/supabase/migrations/02003_baseline_security_and_grants.sql +1577 -1577
- package/supabase/migrations/02004_baseline_seed.sql +565 -565
- package/supabase/migrations/02005_home_live_demo_promo_cadence_15min.sql +53 -53
- package/supabase/migrations/02006_home_marketing_contrast.sql +56 -56
- package/supabase/migrations/02007_home_pillar_caption_contrast.sql +44 -44
- package/supabase/migrations/02008_home_fr_parity.sql +89 -89
- package/supabase/migrations/02009_fr_cortex_posts.sql +67 -67
- package/supabase/migrations/02010_shop_hero_redesign.sql +72 -72
- package/supabase/migrations/02011_drop_backdrop_blur_on_pages.sql +26 -26
- package/supabase/migrations/02012_drop_variant_backdrop_blur.sql +17 -17
- package/supabase/migrations/02013_advisor_rls_and_trigger_grants.sql +102 -102
- package/supabase/migrations/02014_package_activations_staff_read.sql +18 -18
- package/supabase/migrations/02018_install_paths_and_seed_copy_accuracy.sql +1820 -1820
- package/supabase/migrations/02019_open_handed_licensing_and_mcp_clients.sql +448 -448
- package/supabase/migrations/02020_commerce_product_accuracy.sql +93 -0
- package/supabase/migrations/02021_profiles_staff_read_and_role_guard.sql +362 -0
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
-- 02020: Commerce Pro license product copy, EN + FR (vendor-only rows: nextblock.dev has them,
|
|
2
|
+
-- fresh installs do not, so this is a no-op everywhere else). Copy only; no schema.
|
|
3
|
+
--
|
|
4
|
+
-- The page claimed things the code does not do:
|
|
5
|
+
-- * "real-time exchange rates" -- rates are ECB reference rates from api.frankfurter.dev, refreshed
|
|
6
|
+
-- at most once a day from the CMS layout via after() (apps/nextblock/lib/commerce/currency-rates-refresh.ts),
|
|
7
|
+
-- with a "Sync Live Rates" button and the optional /api/cron/sync-currencies route. The Vercel cron
|
|
8
|
+
-- that used to run this was dropped because vercel.json ships to every 1-click install.
|
|
9
|
+
-- * "< 50ms Cart API Response" -- there is no cart API route at all.
|
|
10
|
+
-- * "135+ Currencies Supported" / "40+ Tax Jurisdictions" -- currencies are rows an operator creates,
|
|
11
|
+
-- and tax coverage is Stripe's, not ours.
|
|
12
|
+
-- * "Every API path is edge-cached" -- public pages come from the 5-minute content cache that clears
|
|
13
|
+
-- on publish; HTML is served must-revalidate.
|
|
14
|
+
-- * "optimistic locking" -- stock is deducted under SELECT ... FOR UPDATE (libs/ecommerce order-inventory.ts).
|
|
15
|
+
-- * "email notifications" -- no order email exists; orders produce printable invoices.
|
|
16
|
+
-- * "shares inventory and pricing" -- stock is shared through the SKU, prices are per language row.
|
|
17
|
+
-- The page also never mentioned the 30-day free trial, the French buttons pointed at the English
|
|
18
|
+
-- product, the cards skipped from h3 to h5, and neither language had SEO meta (45/100 in the audit;
|
|
19
|
+
-- the new copy scores 85). Same copy as apps/nextblock/app/api/cron/reset-sandbox/commerce-product-copy.ts,
|
|
20
|
+
-- which re-seeds the sandbox after every reset.
|
|
21
|
+
--
|
|
22
|
+
-- Guarded: each product is replaced only while it still carries the shipped signature, so a product
|
|
23
|
+
-- the vendor rewrote by hand is left alone (a NOTICE names it). Idempotent: the new signature stops
|
|
24
|
+
-- a second run.
|
|
25
|
+
--
|
|
26
|
+
-- NOTE: keep the word "sandbox" OUT of this filename -- generate-sandbox-reset.ts excludes any
|
|
27
|
+
-- migration whose filename contains "sandbox" from the reset bundle.
|
|
28
|
+
|
|
29
|
+
DO $nb_02020_products$
|
|
30
|
+
DECLARE
|
|
31
|
+
v_id uuid;
|
|
32
|
+
v_lang bigint;
|
|
33
|
+
BEGIN
|
|
34
|
+
|
|
35
|
+
-- English
|
|
36
|
+
v_id := NULL;
|
|
37
|
+
SELECT p.id, p.language_id INTO v_id, v_lang
|
|
38
|
+
FROM public.products AS p JOIN public.languages AS l ON l.id = p.language_id
|
|
39
|
+
WHERE p.slug = 'nextblock-commerce-pro-commerce-license' AND l.code = 'en'
|
|
40
|
+
ORDER BY p.id LIMIT 1;
|
|
41
|
+
IF v_id IS NOT NULL THEN
|
|
42
|
+
IF EXISTS (SELECT 1 FROM public.blocks WHERE product_id = v_id AND content::text LIKE $t$%Enterprise E-Commerce Engine%$t$)
|
|
43
|
+
AND NOT EXISTS (SELECT 1 FROM public.blocks WHERE product_id = v_id AND content::text LIKE $t$%Days free, no card%$t$) THEN
|
|
44
|
+
DELETE FROM public.blocks WHERE product_id = v_id;
|
|
45
|
+
INSERT INTO public.blocks (product_id, language_id, block_type, content, "order") VALUES
|
|
46
|
+
(v_id, v_lang, 'section', $nbcm0en${"padding":{"top":"xl","bottom":"xl"},"background":{"type":"gradient","gradient":{"type":"linear","stops":[{"color":"#022c22","position":0},{"color":"#064e3b","position":40},{"color":"#0f172a","position":100}],"direction":"135deg"}},"column_gap":"xl","column_blocks":[[{"content":{"html_content":"<p class=\"text-xs uppercase tracking-[0.3em] text-emerald-400 font-semibold mb-4\">Enterprise E-Commerce Engine</p>\n<h2 class=\"text-3xl md:text-5xl font-extrabold text-white leading-tight mb-5\">Turn Next.js Into a<br/>Global Storefront.</h2>\n<p class=\"text-base md:text-lg text-slate-200 leading-relaxed mb-6\">Commerce Pro is a composable, developer-first engine that powers physical product catalogs, digital licensing, and subscription commerce — all from your existing Next.js stack. Try it free for 30 days, no credit card.</p>"},"block_type":"text"},{"content":{"url":"https://nextblock.dev/product/nextblock-commerce-pro-commerce-license","size":"lg","text":"Get Commerce Pro →","variant":"default","position":"left"},"block_type":"button"}],[{"content":{"html_content":"<div class=\"rounded-2xl border border-emerald-700 bg-slate-950 p-6 shadow-xl sm:p-8\">\n<h3 class=\"text-lg font-bold text-white mb-5\">Why Teams Choose Commerce Pro</h3>\n<ul class=\"space-y-4 text-sm leading-relaxed text-slate-300\">\n <li class=\"flex items-start gap-3\">\n <span class=\"flex-shrink-0 w-6 h-6 rounded-full bg-emerald-950 flex items-center justify-center text-emerald-300 text-xs font-bold\">✓</span>\n <span><strong class=\"text-white\">Multi-Currency Pricing</strong> — daily exchange-rate sync, charm pricing rules, and a currency suggested by the visitor's locale.</span>\n </li>\n <li class=\"flex items-start gap-3\">\n <span class=\"flex-shrink-0 w-6 h-6 rounded-full bg-emerald-950 flex items-center justify-center text-emerald-300 text-xs font-bold\">✓</span>\n <span><strong class=\"text-white\">Tax Automation</strong> — switch on Stripe Tax and checkout calculates and collects tax for you, with the tax lines stored on every order.</span>\n </li>\n <li class=\"flex items-start gap-3\">\n <span class=\"flex-shrink-0 w-6 h-6 rounded-full bg-emerald-950 flex items-center justify-center text-emerald-300 text-xs font-bold\">✓</span>\n <span><strong class=\"text-white\">Resilient Stock Tracking</strong> — inventory locks at checkout to prevent over-selling, with per-variant control.</span>\n </li>\n <li class=\"flex items-start gap-3\">\n <span class=\"flex-shrink-0 w-6 h-6 rounded-full bg-emerald-950 flex items-center justify-center text-emerald-300 text-xs font-bold\">✓</span>\n <span><strong class=\"text-white\">Flexible Shipping Zones</strong> — rate tables, free-shipping thresholds, and per-country rules out of the box.</span>\n </li>\n</ul>\n</div>"},"block_type":"text"}]],"container_type":"container","responsive_columns":{"mobile":1,"tablet":1,"desktop":2},"vertical_alignment":"center"}$nbcm0en$::jsonb, 0),
|
|
47
|
+
(v_id, v_lang, 'section', $nbcm1en${"padding":{"top":"lg","bottom":"lg"},"background":{"type":"theme","theme":"muted"},"column_gap":"lg","column_blocks":[[{"content":{"html_content":"<p class=\"text-center\"><span class=\"block text-2xl font-extrabold text-foreground\">30</span><span class=\"text-xs text-muted-foreground uppercase tracking-wider\">Days free, no card</span></p>"},"block_type":"text"}],[{"content":{"html_content":"<p class=\"text-center\"><span class=\"block text-2xl font-extrabold text-foreground\">2</span><span class=\"text-xs text-muted-foreground uppercase tracking-wider\">Checkout providers</span></p>"},"block_type":"text"}],[{"content":{"html_content":"<p class=\"text-center\"><span class=\"block text-2xl font-extrabold text-foreground\">Daily</span><span class=\"text-xs text-muted-foreground uppercase tracking-wider\">Exchange-rate sync</span></p>"},"block_type":"text"}],[{"content":{"html_content":"<p class=\"text-center\"><span class=\"block text-2xl font-extrabold text-foreground\">EN + FR</span><span class=\"text-xs text-muted-foreground uppercase tracking-wider\">Linked storefronts</span></p>"},"block_type":"text"}]],"container_type":"container","responsive_columns":{"mobile":1,"tablet":2,"desktop":4},"vertical_alignment":"center"}$nbcm1en$::jsonb, 1),
|
|
48
|
+
(v_id, v_lang, 'section', $nbcm2en${"padding":{"top":"xl","bottom":"xl"},"background":{"type":"none"},"column_gap":"lg","column_blocks":[[{"content":{"html_content":"<div class=\"h-full rounded-2xl border border-slate-200 bg-white p-6 shadow-sm transition-colors hover:border-emerald-300 hover:bg-slate-50 dark:border-white/10 dark:bg-slate-950 dark:hover:border-emerald-500 sm:p-7\">\n<div class=\"mb-4 inline-flex h-10 w-10 items-center justify-center rounded-xl bg-emerald-50 text-emerald-700 dark:bg-emerald-950 dark:text-emerald-300\"><svg class=\"h-5 w-5\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" viewBox=\"0 0 24 24\" aria-hidden=\"true\"><rect x=\"3\" y=\"5\" width=\"18\" height=\"14\" rx=\"2\"></rect><path d=\"M3 10h18M7 15h3\"></path></svg></div>\n<h4 class=\"text-base font-bold text-slate-900 dark:text-white mb-2\">Headless Stripe Checkout</h4>\n<p class=\"text-sm text-slate-600 dark:text-slate-300 leading-relaxed\">Integrated checkout session creation for single or multiple items. Auto-fulfillment fires via webhook events, with built-in idempotency guards.</p>\n</div>"},"block_type":"text"}],[{"content":{"html_content":"<div class=\"h-full rounded-2xl border border-slate-200 bg-white p-6 shadow-sm transition-colors hover:border-emerald-300 hover:bg-slate-50 dark:border-white/10 dark:bg-slate-950 dark:hover:border-emerald-500 sm:p-7\">\n<div class=\"mb-4 inline-flex h-10 w-10 items-center justify-center rounded-xl bg-emerald-50 text-emerald-700 dark:bg-emerald-950 dark:text-emerald-300\"><svg class=\"h-5 w-5\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" viewBox=\"0 0 24 24\" aria-hidden=\"true\"><path d=\"M15 7a4 4 0 1 1-2.83 6.83L7 19H4v-3h3l2.17-2.17A4 4 0 0 1 15 7Z\"></path><path d=\"M17.5 8.5h.01\"></path></svg></div>\n<h4 class=\"text-base font-bold text-slate-900 dark:text-white mb-2\">Freemius Digital Licensing</h4>\n<p class=\"text-sm text-slate-600 dark:text-slate-300 leading-relaxed\">Distribute software downloads, validate license keys, and manage SaaS trial periods natively — no third-party integration layer required.</p>\n</div>"},"block_type":"text"}],[{"content":{"html_content":"<div class=\"h-full rounded-2xl border border-slate-200 bg-white p-6 shadow-sm transition-colors hover:border-emerald-300 hover:bg-slate-50 dark:border-white/10 dark:bg-slate-950 dark:hover:border-emerald-500 sm:p-7\">\n<div class=\"mb-4 inline-flex h-10 w-10 items-center justify-center rounded-xl bg-emerald-50 text-emerald-700 dark:bg-emerald-950 dark:text-emerald-300\"><svg class=\"h-5 w-5\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" viewBox=\"0 0 24 24\" aria-hidden=\"true\"><rect x=\"4\" y=\"5\" width=\"16\" height=\"14\" rx=\"2\"></rect><path d=\"m4 15 4-4 4 4 3-3 5 5\"></path><circle cx=\"15\" cy=\"9\" r=\"1\"></circle></svg></div>\n<h4 class=\"text-base font-bold text-slate-900 dark:text-white mb-2\">Visual Merchandising</h4>\n<p class=\"text-sm text-slate-600 dark:text-slate-300 leading-relaxed\">Content creators can add product details pages, highlight promotions, and build full landing pages — all inside the block editor, no code needed.</p>\n</div>"},"block_type":"text"}]],"container_type":"container","responsive_columns":{"mobile":1,"tablet":2,"desktop":3},"vertical_alignment":"stretch"}$nbcm2en$::jsonb, 2),
|
|
49
|
+
(v_id, v_lang, 'section', $nbcm3en${"padding":{"top":"xl","bottom":"xl"},"background":{"type":"gradient","gradient":{"type":"linear","stops":[{"color":"#020617","position":0},{"color":"#0f172a","position":100}],"direction":"180deg"}},"column_gap":"xl","column_blocks":[[{"content":{"html_content":"<p class=\"text-xs uppercase tracking-[0.3em] text-emerald-400 font-semibold mb-4\">Under The Hood</p>\n<h3 class=\"text-2xl md:text-3xl font-extrabold text-white mb-4\">Built for Production Scale</h3>\n<p class=\"text-slate-300 leading-relaxed mb-5\">Commerce Pro was designed from day one for high-traffic stores. Store pages come from a public content cache that clears the moment you publish, every database query is indexed, and every webhook handler is idempotent.</p>\n<ul class=\"space-y-3 text-sm text-slate-400\">\n <li class=\"flex items-start gap-2.5\">\n <span class=\"text-emerald-400\">→</span>\n <span>Variant-level inventory with row-locked stock deduction</span>\n </li>\n <li class=\"flex items-start gap-2.5\">\n <span class=\"text-emerald-400\">→</span>\n <span>Automatic shipping zone calculation and rate tables</span>\n </li>\n <li class=\"flex items-start gap-2.5\">\n <span class=\"text-emerald-400\">→</span>\n <span>Real-time order status with Stripe webhook sync</span>\n </li>\n <li class=\"flex items-start gap-2.5\">\n <span class=\"text-emerald-400\">→</span>\n <span>Product attributes and filterable facets system</span>\n </li>\n</ul>"},"block_type":"text"}],[{"content":{"html_content":"<div class=\"space-y-4\">\n<div class=\"p-5 rounded-xl border border-slate-700 bg-slate-900\">\n <h4 class=\"text-sm font-bold text-white mb-1\">Order Management</h4>\n <p class=\"text-xs text-slate-400 leading-relaxed\">Full order lifecycle from cart to fulfillment. Automatic status transitions, printable invoices, and refund handling baked in.</p>\n</div>\n<div class=\"p-5 rounded-xl border border-slate-700 bg-slate-900\">\n <h4 class=\"text-sm font-bold text-white mb-1\">Product Categories</h4>\n <p class=\"text-xs text-slate-400 leading-relaxed\">Hierarchical taxonomy with slugs, media attachments, and full i18n support. Categories are managed directly in the CMS dashboard.</p>\n</div>\n<div class=\"p-5 rounded-xl border border-slate-700 bg-slate-900\">\n <h4 class=\"text-sm font-bold text-white mb-1\">Multi-Language Storefronts</h4>\n <p class=\"text-xs text-slate-400 leading-relaxed\">Products, categories, and checkout flows are fully translatable. Each language variant keeps its own prices and SEO metadata, and shares stock with its twins through the product SKU.</p>\n</div>\n</div>"},"block_type":"text"}]],"container_type":"container","responsive_columns":{"mobile":1,"tablet":1,"desktop":2},"vertical_alignment":"center"}$nbcm3en$::jsonb, 3),
|
|
50
|
+
(v_id, v_lang, 'section', $nbcm4en${"padding":{"top":"xl","bottom":"xl"},"background":{"type":"gradient","gradient":{"type":"linear","stops":[{"color":"#064e3b","position":0},{"color":"#022c22","position":100}],"direction":"135deg"}},"column_gap":"none","column_blocks":[[{"content":{"level":2,"textAlign":"center","textColor":"background","text_content":"Ready to launch your storefront?"},"block_type":"heading"},{"content":{"html_content":"<p class=\"text-center text-emerald-100 max-w-xl mx-auto mt-2 mb-6\">Start selling today with Commerce Pro. Multi-currency pricing, automatic tax, and shipping zones are ready out of the box, and the first 30 days are free.</p>"},"block_type":"text"},{"content":{"url":"https://nextblock.dev/product/nextblock-commerce-pro-commerce-license","size":"lg","text":"Start the 30-Day Free Trial","variant":"secondary","position":"center"},"block_type":"button"}]],"container_type":"container","responsive_columns":{"mobile":1,"tablet":1,"desktop":1},"vertical_alignment":"center"}$nbcm4en$::jsonb, 4);
|
|
51
|
+
UPDATE public.products
|
|
52
|
+
SET title = $t$NextBlock™ Commerce Pro - Commerce License$t$,
|
|
53
|
+
short_description = $t$NextBlock™ Commerce Pro turns your CMS into a storefront: products and variants, multi-currency pricing with daily rate sync, automatic tax through Stripe Tax, shipping zones, and checkout with Stripe or Freemius. Free for 30 days, no credit card.$t$,
|
|
54
|
+
meta_title = $t$NextBlock™ Commerce Pro | Next.js Storefront License$t$,
|
|
55
|
+
meta_description = $t$Sell products, licences and subscriptions from your Next.js site: multi-currency pricing, Stripe Tax, shipping zones, Stripe or Freemius checkout.$t$,
|
|
56
|
+
updated_at = now()
|
|
57
|
+
WHERE id = v_id;
|
|
58
|
+
ELSIF NOT EXISTS (SELECT 1 FROM public.blocks WHERE product_id = v_id AND content::text LIKE $t$%Days free, no card%$t$) THEN
|
|
59
|
+
RAISE NOTICE '02020: product nextblock-commerce-pro-commerce-license (en) differs from the shipped copy; left unchanged.';
|
|
60
|
+
END IF;
|
|
61
|
+
END IF;
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
-- French
|
|
65
|
+
v_id := NULL;
|
|
66
|
+
SELECT p.id, p.language_id INTO v_id, v_lang
|
|
67
|
+
FROM public.products AS p JOIN public.languages AS l ON l.id = p.language_id
|
|
68
|
+
WHERE p.slug = 'nextblock-commerce-pro-commerce-license-fr' AND l.code = 'fr'
|
|
69
|
+
ORDER BY p.id LIMIT 1;
|
|
70
|
+
IF v_id IS NOT NULL THEN
|
|
71
|
+
IF EXISTS (SELECT 1 FROM public.blocks WHERE product_id = v_id AND content::text LIKE $t$%Moteur E-Commerce d'Entreprise%$t$)
|
|
72
|
+
AND NOT EXISTS (SELECT 1 FROM public.blocks WHERE product_id = v_id AND content::text LIKE $t$%Jours gratuits, sans carte%$t$) THEN
|
|
73
|
+
DELETE FROM public.blocks WHERE product_id = v_id;
|
|
74
|
+
INSERT INTO public.blocks (product_id, language_id, block_type, content, "order") VALUES
|
|
75
|
+
(v_id, v_lang, 'section', $nbcm0fr${"padding":{"top":"xl","bottom":"xl"},"background":{"type":"gradient","gradient":{"type":"linear","stops":[{"color":"#022c22","position":0},{"color":"#064e3b","position":40},{"color":"#0f172a","position":100}],"direction":"135deg"}},"column_gap":"xl","column_blocks":[[{"content":{"html_content":"<p class=\"text-xs uppercase tracking-[0.3em] text-emerald-400 font-semibold mb-4\">Moteur E-Commerce d'Entreprise</p>\n<h2 class=\"text-3xl md:text-5xl font-extrabold text-white leading-tight mb-5\">Faites de Next.js une<br/>Boutique Mondiale.</h2>\n<p class=\"text-base md:text-lg text-slate-200 leading-relaxed mb-6\">Commerce Pro est un moteur composable pensé pour les développeurs : catalogues physiques, licences numériques et abonnements — le tout depuis votre stack Next.js existant. Essayez-le gratuitement pendant 30 jours, sans carte de crédit.</p>"},"block_type":"text"},{"content":{"url":"https://nextblock.dev/product/nextblock-commerce-pro-commerce-license-fr","size":"lg","text":"Obtenir Commerce Pro →","variant":"default","position":"left"},"block_type":"button"}],[{"content":{"html_content":"<div class=\"rounded-2xl border border-emerald-700 bg-slate-950 p-6 shadow-xl sm:p-8\">\n<h3 class=\"text-lg font-bold text-white mb-5\">Pourquoi choisir Commerce Pro</h3>\n<ul class=\"space-y-4 text-sm leading-relaxed text-slate-300\">\n <li class=\"flex items-start gap-3\">\n <span class=\"flex-shrink-0 w-6 h-6 rounded-full bg-emerald-950 flex items-center justify-center text-emerald-300 text-xs font-bold\">✓</span>\n <span><strong class=\"text-white\">Multi-Devises</strong> — synchronisation quotidienne des taux de change, arrondis personnalisés et devise suggérée selon la langue du visiteur.</span>\n </li>\n <li class=\"flex items-start gap-3\">\n <span class=\"flex-shrink-0 w-6 h-6 rounded-full bg-emerald-950 flex items-center justify-center text-emerald-300 text-xs font-bold\">✓</span>\n <span><strong class=\"text-white\">Taxes automatisées</strong> — activez Stripe Tax et le paiement calcule et collecte la taxe pour vous, avec le détail conservé sur chaque commande.</span>\n </li>\n <li class=\"flex items-start gap-3\">\n <span class=\"flex-shrink-0 w-6 h-6 rounded-full bg-emerald-950 flex items-center justify-center text-emerald-300 text-xs font-bold\">✓</span>\n <span><strong class=\"text-white\">Gestion des Stocks</strong> — verrouillage de l'inventaire au checkout pour éviter les surventes, avec contrôle par variante.</span>\n </li>\n <li class=\"flex items-start gap-3\">\n <span class=\"flex-shrink-0 w-6 h-6 rounded-full bg-emerald-950 flex items-center justify-center text-emerald-300 text-xs font-bold\">✓</span>\n <span><strong class=\"text-white\">Zones d'expédition flexibles</strong> — tables de tarifs, seuils de livraison gratuite et règles par pays inclus.</span>\n </li>\n</ul>\n</div>"},"block_type":"text"}]],"container_type":"container","responsive_columns":{"mobile":1,"tablet":1,"desktop":2},"vertical_alignment":"center"}$nbcm0fr$::jsonb, 0),
|
|
76
|
+
(v_id, v_lang, 'section', $nbcm1fr${"padding":{"top":"lg","bottom":"lg"},"background":{"type":"theme","theme":"muted"},"column_gap":"lg","column_blocks":[[{"content":{"html_content":"<p class=\"text-center\"><span class=\"block text-2xl font-extrabold text-foreground\">30</span><span class=\"text-xs text-muted-foreground uppercase tracking-wider\">Jours gratuits, sans carte</span></p>"},"block_type":"text"}],[{"content":{"html_content":"<p class=\"text-center\"><span class=\"block text-2xl font-extrabold text-foreground\">2</span><span class=\"text-xs text-muted-foreground uppercase tracking-wider\">Fournisseurs de paiement</span></p>"},"block_type":"text"}],[{"content":{"html_content":"<p class=\"text-center\"><span class=\"block text-2xl font-extrabold text-foreground\">Quotidienne</span><span class=\"text-xs text-muted-foreground uppercase tracking-wider\">Synchro des taux</span></p>"},"block_type":"text"}],[{"content":{"html_content":"<p class=\"text-center\"><span class=\"block text-2xl font-extrabold text-foreground\">EN + FR</span><span class=\"text-xs text-muted-foreground uppercase tracking-wider\">Boutiques liées</span></p>"},"block_type":"text"}]],"container_type":"container","responsive_columns":{"mobile":1,"tablet":2,"desktop":4},"vertical_alignment":"center"}$nbcm1fr$::jsonb, 1),
|
|
77
|
+
(v_id, v_lang, 'section', $nbcm2fr${"padding":{"top":"xl","bottom":"xl"},"background":{"type":"none"},"column_gap":"lg","column_blocks":[[{"content":{"html_content":"<div class=\"h-full rounded-2xl border border-slate-200 bg-white p-6 shadow-sm transition-colors hover:border-emerald-300 hover:bg-slate-50 dark:border-white/10 dark:bg-slate-950 dark:hover:border-emerald-500 sm:p-7\">\n<div class=\"mb-4 inline-flex h-10 w-10 items-center justify-center rounded-xl bg-emerald-50 text-emerald-700 dark:bg-emerald-950 dark:text-emerald-300\"><svg class=\"h-5 w-5\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" viewBox=\"0 0 24 24\" aria-hidden=\"true\"><rect x=\"3\" y=\"5\" width=\"18\" height=\"14\" rx=\"2\"></rect><path d=\"M3 10h18M7 15h3\"></path></svg></div>\n<h4 class=\"text-base font-bold text-slate-900 dark:text-white mb-2\">Checkout Stripe Headless</h4>\n<p class=\"text-sm text-slate-600 dark:text-slate-300 leading-relaxed\">Création de sessions Stripe Checkout pour un ou plusieurs articles. Traitement automatique des commandes par webhooks avec protection d'idempotence.</p>\n</div>"},"block_type":"text"}],[{"content":{"html_content":"<div class=\"h-full rounded-2xl border border-slate-200 bg-white p-6 shadow-sm transition-colors hover:border-emerald-300 hover:bg-slate-50 dark:border-white/10 dark:bg-slate-950 dark:hover:border-emerald-500 sm:p-7\">\n<div class=\"mb-4 inline-flex h-10 w-10 items-center justify-center rounded-xl bg-emerald-50 text-emerald-700 dark:bg-emerald-950 dark:text-emerald-300\"><svg class=\"h-5 w-5\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" viewBox=\"0 0 24 24\" aria-hidden=\"true\"><path d=\"M15 7a4 4 0 1 1-2.83 6.83L7 19H4v-3h3l2.17-2.17A4 4 0 0 1 15 7Z\"></path><path d=\"M17.5 8.5h.01\"></path></svg></div>\n<h4 class=\"text-base font-bold text-slate-900 dark:text-white mb-2\">Licences Numériques Freemius</h4>\n<p class=\"text-sm text-slate-600 dark:text-slate-300 leading-relaxed\">Distribuez vos logiciels, validez les clés de licence et gérez les périodes d'essai SaaS — aucune intégration tierce requise.</p>\n</div>"},"block_type":"text"}],[{"content":{"html_content":"<div class=\"h-full rounded-2xl border border-slate-200 bg-white p-6 shadow-sm transition-colors hover:border-emerald-300 hover:bg-slate-50 dark:border-white/10 dark:bg-slate-950 dark:hover:border-emerald-500 sm:p-7\">\n<div class=\"mb-4 inline-flex h-10 w-10 items-center justify-center rounded-xl bg-emerald-50 text-emerald-700 dark:bg-emerald-950 dark:text-emerald-300\"><svg class=\"h-5 w-5\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" viewBox=\"0 0 24 24\" aria-hidden=\"true\"><rect x=\"4\" y=\"5\" width=\"16\" height=\"14\" rx=\"2\"></rect><path d=\"m4 15 4-4 4 4 3-3 5 5\"></path><circle cx=\"15\" cy=\"9\" r=\"1\"></circle></svg></div>\n<h4 class=\"text-base font-bold text-slate-900 dark:text-white mb-2\">Merchandising Visuel</h4>\n<p class=\"text-sm text-slate-600 dark:text-slate-300 leading-relaxed\">Vos équipes éditoriales enrichissent les fiches produits de landing pages, promotions et galeries — directement dans l'éditeur de blocs.</p>\n</div>"},"block_type":"text"}]],"container_type":"container","responsive_columns":{"mobile":1,"tablet":2,"desktop":3},"vertical_alignment":"stretch"}$nbcm2fr$::jsonb, 2),
|
|
78
|
+
(v_id, v_lang, 'section', $nbcm3fr${"padding":{"top":"xl","bottom":"xl"},"background":{"type":"gradient","gradient":{"type":"linear","stops":[{"color":"#020617","position":0},{"color":"#0f172a","position":100}],"direction":"180deg"}},"column_gap":"xl","column_blocks":[[{"content":{"html_content":"<p class=\"text-xs uppercase tracking-[0.3em] text-emerald-400 font-semibold mb-4\">Sous le capot</p>\n<h3 class=\"text-2xl md:text-3xl font-extrabold text-white mb-4\">Conçu pour la production à grande échelle</h3>\n<p class=\"text-slate-300 leading-relaxed mb-5\">Commerce Pro a été conçu dès le départ pour les boutiques à fort trafic. Les pages de la boutique proviennent d'un cache de contenu public vidé dès la publication, chaque requête est indexée, et chaque gestionnaire de webhook est idempotent.</p>\n<ul class=\"space-y-3 text-sm text-slate-400\">\n <li class=\"flex items-start gap-2.5\">\n <span class=\"text-emerald-400\">→</span>\n <span>Inventaire par variante avec déduction du stock sous verrou de ligne</span>\n </li>\n <li class=\"flex items-start gap-2.5\">\n <span class=\"text-emerald-400\">→</span>\n <span>Calcul automatique des zones d'expédition et tables de tarifs</span>\n </li>\n <li class=\"flex items-start gap-2.5\">\n <span class=\"text-emerald-400\">→</span>\n <span>Statut des commandes en temps réel via synchronisation Stripe webhook</span>\n </li>\n <li class=\"flex items-start gap-2.5\">\n <span class=\"text-emerald-400\">→</span>\n <span>Système d'attributs produits et de facettes filtrables</span>\n </li>\n</ul>"},"block_type":"text"}],[{"content":{"html_content":"<div class=\"space-y-4\">\n<div class=\"p-5 rounded-xl border border-slate-700 bg-slate-900\">\n <h4 class=\"text-sm font-bold text-white mb-1\">Gestion des commandes</h4>\n <p class=\"text-xs text-slate-400 leading-relaxed\">Cycle de vie complet : du panier à la livraison. Transitions automatiques, factures imprimables et gestion des remboursements intégrées.</p>\n</div>\n<div class=\"p-5 rounded-xl border border-slate-700 bg-slate-900\">\n <h4 class=\"text-sm font-bold text-white mb-1\">Catégories de produits</h4>\n <p class=\"text-xs text-slate-400 leading-relaxed\">Taxonomie hiérarchique avec slugs, médias associés et support i18n complet. Les catégories sont gérées directement dans le tableau de bord du CMS.</p>\n</div>\n<div class=\"p-5 rounded-xl border border-slate-700 bg-slate-900\">\n <h4 class=\"text-sm font-bold text-white mb-1\">Boutiques multilingues</h4>\n <p class=\"text-xs text-slate-400 leading-relaxed\">Produits, catégories et flux de paiement entièrement traduisibles. Chaque variante linguistique garde ses propres prix et métadonnées SEO, et partage le stock avec ses jumelles grâce au SKU du produit.</p>\n</div>\n</div>"},"block_type":"text"}]],"container_type":"container","responsive_columns":{"mobile":1,"tablet":1,"desktop":2},"vertical_alignment":"center"}$nbcm3fr$::jsonb, 3),
|
|
79
|
+
(v_id, v_lang, 'section', $nbcm4fr${"padding":{"top":"xl","bottom":"xl"},"background":{"type":"gradient","gradient":{"type":"linear","stops":[{"color":"#064e3b","position":0},{"color":"#022c22","position":100}],"direction":"135deg"}},"column_gap":"none","column_blocks":[[{"content":{"level":2,"textAlign":"center","textColor":"background","text_content":"Prêt à lancer votre boutique ?"},"block_type":"heading"},{"content":{"html_content":"<p class=\"text-center text-emerald-100 max-w-xl mx-auto mt-2 mb-6\">Commencez à vendre dès aujourd'hui avec Commerce Pro. Prix multi-devises, taxes automatiques et zones de livraison sont prêts dès l'installation, et les 30 premiers jours sont gratuits.</p>"},"block_type":"text"},{"content":{"url":"https://nextblock.dev/product/nextblock-commerce-pro-commerce-license-fr","size":"lg","text":"Commencer l'essai gratuit de 30 jours","variant":"secondary","position":"center"},"block_type":"button"}]],"container_type":"container","responsive_columns":{"mobile":1,"tablet":1,"desktop":1},"vertical_alignment":"center"}$nbcm4fr$::jsonb, 4);
|
|
80
|
+
UPDATE public.products
|
|
81
|
+
SET title = $t$Licence NextBlock™ Commerce Pro$t$,
|
|
82
|
+
short_description = $t$NextBlock™ Commerce Pro transforme votre CMS en boutique : produits et variantes, prix multi-devises avec synchronisation quotidienne des taux, taxes automatiques via Stripe Tax, zones de livraison et paiement Stripe ou Freemius. Gratuit pendant 30 jours, sans carte de crédit.$t$,
|
|
83
|
+
meta_title = $t$NextBlock™ Commerce Pro | Licence boutique Next.js$t$,
|
|
84
|
+
meta_description = $t$Vendez produits, licences et abonnements depuis votre site Next.js : prix multi-devises, Stripe Tax, zones de livraison, paiement Stripe ou Freemius.$t$,
|
|
85
|
+
updated_at = now()
|
|
86
|
+
WHERE id = v_id;
|
|
87
|
+
ELSIF NOT EXISTS (SELECT 1 FROM public.blocks WHERE product_id = v_id AND content::text LIKE $t$%Jours gratuits, sans carte%$t$) THEN
|
|
88
|
+
RAISE NOTICE '02020: product nextblock-commerce-pro-commerce-license-fr (fr) differs from the shipped copy; left unchanged.';
|
|
89
|
+
END IF;
|
|
90
|
+
END IF;
|
|
91
|
+
|
|
92
|
+
END
|
|
93
|
+
$nb_02020_products$;
|
|
@@ -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
|
+
);
|