@nextblock-cms/db 0.17.0 → 0.17.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 (46) hide show
  1. package/package.json +1 -1
  2. package/supabase/migrations/02000_catchup_gen1.sql +5880 -0
  3. package/supabase/migrations/{00000000000000_baseline_schema.sql → 02001_baseline_schema.sql} +305 -25
  4. package/supabase/migrations/{00000000000001_baseline_constraints_and_indexes.sql → 02002_baseline_constraints_and_indexes.sql} +194 -3
  5. package/supabase/migrations/{00000000000002_baseline_security_and_grants.sql → 02003_baseline_security_and_grants.sql} +403 -161
  6. package/supabase/migrations/02004_baseline_seed.sql +565 -0
  7. package/supabase/migrations/00000000000003_baseline_seed.sql +0 -468
  8. package/supabase/migrations/00000000000004_default_logo_email_safe.sql +0 -24
  9. package/supabase/migrations/00000000000005_add_custom_canonical.sql +0 -13
  10. package/supabase/migrations/00000000000006_home_live_demo_promo.sql +0 -67
  11. package/supabase/migrations/00000000000007_home_live_demo_promo_copy_fix.sql +0 -49
  12. package/supabase/migrations/00000000000008_setup_article_reorder.sql +0 -30
  13. package/supabase/migrations/00000000000009_home_live_demo_promo_contrast.sql +0 -52
  14. package/supabase/migrations/00000000000010_drop_github_username.sql +0 -79
  15. package/supabase/migrations/00000000000011_language_detection_admin_only.sql +0 -21
  16. package/supabase/migrations/00000000000012_cortex_ai_stock_photo_settings.sql +0 -25
  17. package/supabase/migrations/00000000000013_youtube_nocookie_embeds.sql +0 -136
  18. package/supabase/migrations/00000000000014_site_themes.sql +0 -254
  19. package/supabase/migrations/00000000000015_scheduled_publishing.sql +0 -43
  20. package/supabase/migrations/00000000000016_product_revisions_and_revision_baseline.sql +0 -310
  21. package/supabase/migrations/00000000000017_cortex_ai_mcp_server.sql +0 -91
  22. package/supabase/migrations/00000000000018_site_scripts.sql +0 -96
  23. package/supabase/migrations/00000000000019_site_script_revisions.sql +0 -85
  24. package/supabase/migrations/00000000000020_updating_article.sql +0 -162
  25. package/supabase/migrations/00000000000021_updating_article_git_merge.sql +0 -91
  26. package/supabase/migrations/00000000000022_updating_article_accuracy.sql +0 -94
  27. package/supabase/migrations/00000000000023_updating_article_layout_not_host.sql +0 -87
  28. package/supabase/migrations/00000000000024_updating_article_newline_fix.sql +0 -70
  29. package/supabase/migrations/00000000000025_rebrand_nextblock_dev.sql +0 -53
  30. package/supabase/migrations/00000000000026_product_inquiries.sql +0 -118
  31. package/supabase/migrations/00000000000027_message_threads.sql +0 -391
  32. package/supabase/migrations/00000000000028_interaction_replies.sql +0 -86
  33. package/supabase/migrations/00000000000029_form_endpoints_default_empty.sql +0 -52
  34. package/supabase/migrations/00000000000030_seo_redirects_and_robots.sql +0 -149
  35. package/supabase/migrations/00000000000031_seo_robots_settings_admin_only.sql +0 -35
  36. package/supabase/migrations/00000000000032_seed_seo_score_optimizations.sql +0 -1172
  37. package/supabase/migrations/00000000000033_seed_seo_score_optimizations_p2.sql +0 -353
  38. package/supabase/migrations/00000000000034_seed_seo_french_legal_readability.sql +0 -101
  39. package/supabase/migrations/00000000000035_digital_products_seo_optimizations.sql +0 -134
  40. package/supabase/migrations/00000000000036_seed_posts_missing_seo_metadata.sql +0 -37
  41. package/supabase/migrations/00000000000037_reposition_marketing_and_cortex_mcp.sql +0 -501
  42. package/supabase/migrations/00000000000038_mcp_guide_visual_refresh.sql +0 -64
  43. package/supabase/migrations/00000000000039_mcp_guide_diagram_dimensions.sql +0 -21
  44. package/supabase/migrations/00000000000040_home_comparison_chart_free_cms.sql +0 -81
  45. package/supabase/migrations/00000000000041_marketing_review_free_cms_trial.sql +0 -141
  46. package/supabase/migrations/00000000000042_how_nextblock_works_css_diagram.sql +0 -66
@@ -1,310 +0,0 @@
1
- -- 00000000000016_product_revisions_and_revision_baseline.sql
2
- --
3
- -- Revision History, part 1 of 2 (schema). The application-side rewrite lives in
4
- -- apps/nextblock/app/cms/revisions/**.
5
- --
6
- -- Three things happen here:
7
- --
8
- -- 1. products.version — the monotonic counter the hybrid revision engine drives,
9
- -- mirroring pages.version / posts.version.
10
- --
11
- -- 2. product_revisions — a structural mirror of page_revisions / post_revisions.
12
- -- product_id is uuid (products.id is uuid, not bigint), and
13
- -- writes are gated on is_admin() to match products_*_policy
14
- -- rather than the ADMIN|WRITER pattern the page/post revision
15
- -- tables use. A WRITER who could insert a revision but not
16
- -- apply a restore would get a silent no-op restore, because
17
- -- PostgREST returns no error for an UPDATE matching zero rows.
18
- --
19
- -- 3. Revision baseline — every page, post and product gets a real `snapshot` row to
20
- -- restore to. Until now the CMS synthesised a fake "Initial
21
- -- Version" entry in the UI whose Restore button resolved to
22
- -- "current metadata + zero blocks" and wiped the content.
23
- -- There is now an actual stored baseline instead.
24
- --
25
- -- Case A (version = 1, no revisions at all): the live row IS
26
- -- version 1. This covers seeded content — 00000000000003
27
- -- inserts every page and post at version 1 and writes no
28
- -- revision rows — and everything authored since the CMS save
29
- -- path stopped recording revisions. Snapshotting it at
30
- -- version 1 is what makes "restore the original seeded page"
31
- -- real for the first time.
32
- --
33
- -- Case B (version > 1 but no snapshot at or below it): the
34
- -- true v1 is unrecoverable and is NOT fabricated. A snapshot
35
- -- of the current state is stored at the current version so the
36
- -- diff chain has a valid base and future restores resolve.
37
- --
38
- -- Forward-only, idempotent, and it modifies no existing row: every backfill is an
39
- -- INSERT ... WHERE NOT EXISTS ... ON CONFLICT DO NOTHING.
40
-
41
- -- ---------------------------------------------------------------------------
42
- -- 1. products.version
43
- -- ---------------------------------------------------------------------------
44
-
45
- ALTER TABLE public.products
46
- ADD COLUMN IF NOT EXISTS version integer DEFAULT 1 NOT NULL;
47
-
48
- COMMENT ON COLUMN public.products.version IS 'Monotonic version number for hybrid revisions.';
49
-
50
- -- ---------------------------------------------------------------------------
51
- -- 2. product_revisions
52
- -- ---------------------------------------------------------------------------
53
-
54
- CREATE TABLE IF NOT EXISTS public.product_revisions (
55
- id bigint NOT NULL,
56
- product_id uuid NOT NULL,
57
- author_id uuid,
58
- version integer NOT NULL,
59
- revision_type public.revision_type NOT NULL,
60
- content jsonb NOT NULL,
61
- created_at timestamp with time zone DEFAULT now() NOT NULL
62
- );
63
-
64
- COMMENT ON TABLE public.product_revisions IS 'Hybrid (snapshot/diff) revisions for products.';
65
- COMMENT ON COLUMN public.product_revisions.content IS 'If snapshot: full content; if diff: JSON Patch array.';
66
-
67
- DO $rb$
68
- BEGIN
69
- IF NOT EXISTS (
70
- SELECT 1 FROM pg_attribute
71
- WHERE attrelid = 'public.product_revisions'::regclass
72
- AND attname = 'id'
73
- AND attidentity <> ''
74
- ) THEN
75
- ALTER TABLE public.product_revisions
76
- ALTER COLUMN id ADD GENERATED BY DEFAULT AS IDENTITY (
77
- SEQUENCE NAME public.product_revisions_id_seq
78
- START WITH 1
79
- INCREMENT BY 1
80
- NO MINVALUE
81
- NO MAXVALUE
82
- CACHE 1
83
- );
84
- END IF;
85
- END $rb$;
86
-
87
- DO $rb$
88
- BEGIN
89
- IF NOT EXISTS (SELECT 1 FROM pg_constraint
90
- WHERE conname = 'product_revisions_pkey'
91
- AND conrelid = 'public.product_revisions'::regclass) THEN
92
- ALTER TABLE ONLY public.product_revisions
93
- ADD CONSTRAINT product_revisions_pkey PRIMARY KEY (id);
94
- END IF;
95
- END $rb$;
96
-
97
- DO $rb$
98
- BEGIN
99
- IF NOT EXISTS (SELECT 1 FROM pg_constraint
100
- WHERE conname = 'product_revisions_product_version_key'
101
- AND conrelid = 'public.product_revisions'::regclass) THEN
102
- ALTER TABLE ONLY public.product_revisions
103
- ADD CONSTRAINT product_revisions_product_version_key UNIQUE (product_id, version);
104
- END IF;
105
- END $rb$;
106
-
107
- DO $rb$
108
- BEGIN
109
- IF NOT EXISTS (SELECT 1 FROM pg_constraint
110
- WHERE conname = 'product_revisions_author_id_fkey'
111
- AND conrelid = 'public.product_revisions'::regclass) THEN
112
- ALTER TABLE ONLY public.product_revisions
113
- ADD CONSTRAINT product_revisions_author_id_fkey
114
- FOREIGN KEY (author_id) REFERENCES public.profiles(id) ON DELETE SET NULL;
115
- END IF;
116
- END $rb$;
117
-
118
- DO $rb$
119
- BEGIN
120
- IF NOT EXISTS (SELECT 1 FROM pg_constraint
121
- WHERE conname = 'product_revisions_product_id_fkey'
122
- AND conrelid = 'public.product_revisions'::regclass) THEN
123
- ALTER TABLE ONLY public.product_revisions
124
- ADD CONSTRAINT product_revisions_product_id_fkey
125
- FOREIGN KEY (product_id) REFERENCES public.products(id) ON DELETE CASCADE;
126
- END IF;
127
- END $rb$;
128
-
129
- CREATE INDEX IF NOT EXISTS idx_product_revisions_author_id
130
- ON public.product_revisions USING btree (author_id);
131
-
132
- CREATE INDEX IF NOT EXISTS idx_product_revisions_product_id_version
133
- ON public.product_revisions USING btree (product_id, version);
134
-
135
- ALTER TABLE public.product_revisions ENABLE ROW LEVEL SECURITY;
136
-
137
- DROP POLICY IF EXISTS product_revisions_read_policy ON public.product_revisions;
138
- CREATE POLICY product_revisions_read_policy ON public.product_revisions
139
- FOR SELECT TO authenticated USING (true);
140
-
141
- DROP POLICY IF EXISTS product_revisions_insert_policy ON public.product_revisions;
142
- CREATE POLICY product_revisions_insert_policy ON public.product_revisions
143
- FOR INSERT TO authenticated
144
- WITH CHECK (((SELECT public.is_admin() AS is_admin) IS TRUE));
145
-
146
- DROP POLICY IF EXISTS product_revisions_update_policy ON public.product_revisions;
147
- CREATE POLICY product_revisions_update_policy ON public.product_revisions
148
- FOR UPDATE TO authenticated
149
- USING (((SELECT public.is_admin() AS is_admin) IS TRUE))
150
- WITH CHECK (((SELECT public.is_admin() AS is_admin) IS TRUE));
151
-
152
- DROP POLICY IF EXISTS product_revisions_delete_policy ON public.product_revisions;
153
- CREATE POLICY product_revisions_delete_policy ON public.product_revisions
154
- FOR DELETE TO authenticated
155
- USING (((SELECT public.is_admin() AS is_admin) IS TRUE));
156
-
157
- GRANT ALL ON TABLE public.product_revisions TO anon;
158
- GRANT ALL ON TABLE public.product_revisions TO authenticated;
159
- GRANT ALL ON TABLE public.product_revisions TO service_role;
160
- GRANT ALL ON SEQUENCE public.product_revisions_id_seq TO anon;
161
- GRANT ALL ON SEQUENCE public.product_revisions_id_seq TO authenticated;
162
- GRANT ALL ON SEQUENCE public.product_revisions_id_seq TO service_role;
163
-
164
- -- ---------------------------------------------------------------------------
165
- -- 3. Revision baseline backfill
166
- --
167
- -- The JSON shape must match FullPageContent / FullPostContent / FullProductContent
168
- -- in apps/nextblock/app/cms/revisions/utils.ts exactly, or the first diff taken
169
- -- against a baseline row will be full of phantom operations.
170
- --
171
- -- Timestamps are rendered with an explicit millisecond-precision UTC format so they
172
- -- match JavaScript's Date#toISOString() ("2026-07-03T17:52:15.643Z"). Postgres'
173
- -- default jsonb rendering of timestamptz ("2026-07-03T17:52:15.643901+00:00") would
174
- -- differ from the value the application writes and produce a spurious diff on the
175
- -- very next save.
176
- -- ---------------------------------------------------------------------------
177
-
178
- -- 3a. Pages
179
- INSERT INTO public.page_revisions (page_id, author_id, version, revision_type, content)
180
- SELECT
181
- p.id,
182
- NULL::uuid,
183
- p.version,
184
- 'snapshot'::public.revision_type,
185
- jsonb_build_object(
186
- 'meta', jsonb_build_object(
187
- 'title', p.title,
188
- 'slug', p.slug,
189
- 'language_id', p.language_id,
190
- 'status', p.status,
191
- 'meta_title', p.meta_title,
192
- 'meta_description', p.meta_description,
193
- 'custom_canonical', p.custom_canonical,
194
- 'published_at', to_char(p.published_at AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS.MS"Z"'),
195
- 'feature_image_id', p.feature_image_id
196
- ),
197
- 'blocks', COALESCE((
198
- SELECT jsonb_agg(
199
- jsonb_build_object(
200
- 'language_id', b.language_id,
201
- 'block_type', b.block_type,
202
- 'content', b.content,
203
- 'order', b."order"
204
- ) ORDER BY b."order" ASC, b.id ASC
205
- )
206
- FROM public.blocks b
207
- WHERE b.page_id = p.id
208
- ), '[]'::jsonb)
209
- )
210
- FROM public.pages p
211
- WHERE NOT EXISTS (
212
- SELECT 1 FROM public.page_revisions r
213
- WHERE r.page_id = p.id
214
- AND r.revision_type = 'snapshot'
215
- AND r.version <= p.version
216
- )
217
- ON CONFLICT (page_id, version) DO NOTHING;
218
-
219
- -- 3b. Posts
220
- INSERT INTO public.post_revisions (post_id, author_id, version, revision_type, content)
221
- SELECT
222
- po.id,
223
- NULL::uuid,
224
- po.version,
225
- 'snapshot'::public.revision_type,
226
- jsonb_build_object(
227
- 'meta', jsonb_build_object(
228
- 'title', po.title,
229
- 'slug', po.slug,
230
- 'language_id', po.language_id,
231
- 'status', po.status,
232
- 'meta_title', po.meta_title,
233
- 'meta_description', po.meta_description,
234
- 'custom_canonical', po.custom_canonical,
235
- 'published_at', to_char(po.published_at AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS.MS"Z"'),
236
- 'feature_image_id', po.feature_image_id,
237
- 'label', po.label,
238
- 'excerpt', po.excerpt,
239
- 'subtitle', po.subtitle
240
- ),
241
- 'blocks', COALESCE((
242
- SELECT jsonb_agg(
243
- jsonb_build_object(
244
- 'language_id', b.language_id,
245
- 'block_type', b.block_type,
246
- 'content', b.content,
247
- 'order', b."order"
248
- ) ORDER BY b."order" ASC, b.id ASC
249
- )
250
- FROM public.blocks b
251
- WHERE b.post_id = po.id
252
- ), '[]'::jsonb)
253
- )
254
- FROM public.posts po
255
- WHERE NOT EXISTS (
256
- SELECT 1 FROM public.post_revisions r
257
- WHERE r.post_id = po.id
258
- AND r.revision_type = 'snapshot'
259
- AND r.version <= po.version
260
- )
261
- ON CONFLICT (post_id, version) DO NOTHING;
262
-
263
- -- 3c. Products.
264
- --
265
- -- Content only. price/prices/sale_*/scheduled_*/stock/sku/average_rating/total_reviews
266
- -- are deliberately excluded from the snapshot: pricing and inventory are mutated from
267
- -- outside the editor (promotions, Freemius sync, order fulfilment), ratings are derived
268
- -- aggregates, and inventory_items is keyed by bare SKU text with no FK to products — so
269
- -- replaying commerce state on restore would reach rows the editor never touched.
270
- -- Restoring a product restores its content, not its commerce state.
271
- INSERT INTO public.product_revisions (product_id, author_id, version, revision_type, content)
272
- SELECT
273
- pr.id,
274
- NULL::uuid,
275
- pr.version,
276
- 'snapshot'::public.revision_type,
277
- jsonb_build_object(
278
- 'meta', jsonb_build_object(
279
- 'title', pr.title,
280
- 'slug', pr.slug,
281
- 'language_id', pr.language_id,
282
- 'status', pr.status,
283
- 'meta_title', pr.meta_title,
284
- 'meta_description', pr.meta_description,
285
- 'custom_canonical', pr.custom_canonical,
286
- 'published_at', to_char(pr.published_at AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS.MS"Z"'),
287
- 'short_description', pr.short_description,
288
- 'description_json', pr.description_json
289
- ),
290
- 'blocks', COALESCE((
291
- SELECT jsonb_agg(
292
- jsonb_build_object(
293
- 'language_id', b.language_id,
294
- 'block_type', b.block_type,
295
- 'content', b.content,
296
- 'order', b."order"
297
- ) ORDER BY b."order" ASC, b.id ASC
298
- )
299
- FROM public.blocks b
300
- WHERE b.product_id = pr.id
301
- ), '[]'::jsonb)
302
- )
303
- FROM public.products pr
304
- WHERE NOT EXISTS (
305
- SELECT 1 FROM public.product_revisions r
306
- WHERE r.product_id = pr.id
307
- AND r.revision_type = 'snapshot'
308
- AND r.version <= pr.version
309
- )
310
- ON CONFLICT (product_id, version) DO NOTHING;
@@ -1,91 +0,0 @@
1
- -- Cortex AI MCP (Model Context Protocol) server access.
2
- --
3
- -- Adds the bearer-token store that gates /api/mcp, the endpoint that exposes the
4
- -- Cortex AI tool registry to external MCP clients (Claude Code, Claude Desktop,
5
- -- Cursor, VS Code). Two pieces:
6
- --
7
- -- 1. public.mcp_access_tokens — one row per issued token. We store ONLY the
8
- -- SHA-256 hash of the token, never the token itself: the plaintext is shown
9
- -- to the admin exactly once at mint time and is unrecoverable afterwards, so
10
- -- a database leak cannot be replayed against the MCP endpoint. `token_prefix`
11
- -- is the non-secret leading fragment kept purely so the UI can tell two tokens
12
- -- apart in a list.
13
- --
14
- -- 2. cortex_ai_mcp_settings — a non-secret JSON site_settings row holding the
15
- -- server on/off switch and the localhost-trust flag. It is added to all four
16
- -- site_settings policies so only authenticated ADMINs can read or write it;
17
- -- the MCP route itself reads it through the service-role client, which
18
- -- bypasses RLS.
19
- --
20
- -- Forward-only. Recreates the four site_settings policies idempotently, preserving
21
- -- every key already in each policy's sensitive array (note that
22
- -- language_detection_settings stays anon-READABLE and so is absent from the SELECT
23
- -- policy, exactly as migration 00000000000012 left it).
24
-
25
- CREATE TABLE IF NOT EXISTS public.mcp_access_tokens (
26
- id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
27
- name text NOT NULL,
28
- -- Lowercase hex SHA-256 of the plaintext token. Unique so a lookup is a single
29
- -- indexed equality probe and duplicate mints are impossible.
30
- token_hash text NOT NULL UNIQUE,
31
- -- Non-secret display fragment, e.g. "nbmcp_a1b2c3d4". Never enough to authenticate.
32
- token_prefix text NOT NULL,
33
- -- 'read' grants the read-only tools; 'write' additionally grants the mutating ones.
34
- scopes text[] NOT NULL DEFAULT ARRAY['read', 'write']::text[],
35
- created_by uuid REFERENCES auth.users (id) ON DELETE SET NULL,
36
- created_at timestamptz NOT NULL DEFAULT now(),
37
- last_used_at timestamptz,
38
- expires_at timestamptz,
39
- revoked_at timestamptz
40
- );
41
-
42
- COMMENT ON TABLE public.mcp_access_tokens IS
43
- 'Bearer tokens for the Cortex AI MCP server at /api/mcp. Stores SHA-256 hashes only; plaintext is displayed once at mint time.';
44
-
45
- CREATE INDEX IF NOT EXISTS mcp_access_tokens_token_hash_idx
46
- ON public.mcp_access_tokens (token_hash);
47
-
48
- -- Orders the admin token list newest-first without a sort.
49
- CREATE INDEX IF NOT EXISTS mcp_access_tokens_created_at_idx
50
- ON public.mcp_access_tokens (created_at DESC);
51
-
52
- ALTER TABLE public.mcp_access_tokens ENABLE ROW LEVEL SECURITY;
53
-
54
- -- Tokens are credentials: admin-only, with no anon or WRITER access at all. The
55
- -- MCP route verifies them with the service-role client, which bypasses RLS.
56
- DROP POLICY IF EXISTS mcp_access_tokens_admin_select ON public.mcp_access_tokens;
57
- CREATE POLICY mcp_access_tokens_admin_select ON public.mcp_access_tokens
58
- FOR SELECT TO authenticated
59
- USING ((SELECT public.get_current_user_role()) = 'ADMIN'::public.user_role);
60
-
61
- DROP POLICY IF EXISTS mcp_access_tokens_admin_insert ON public.mcp_access_tokens;
62
- CREATE POLICY mcp_access_tokens_admin_insert ON public.mcp_access_tokens
63
- FOR INSERT TO authenticated
64
- WITH CHECK ((SELECT public.get_current_user_role()) = 'ADMIN'::public.user_role);
65
-
66
- DROP POLICY IF EXISTS mcp_access_tokens_admin_update ON public.mcp_access_tokens;
67
- CREATE POLICY mcp_access_tokens_admin_update ON public.mcp_access_tokens
68
- FOR UPDATE TO authenticated
69
- USING ((SELECT public.get_current_user_role()) = 'ADMIN'::public.user_role)
70
- WITH CHECK ((SELECT public.get_current_user_role()) = 'ADMIN'::public.user_role);
71
-
72
- DROP POLICY IF EXISTS mcp_access_tokens_admin_delete ON public.mcp_access_tokens;
73
- CREATE POLICY mcp_access_tokens_admin_delete ON public.mcp_access_tokens
74
- FOR DELETE TO authenticated
75
- USING ((SELECT public.get_current_user_role()) = 'ADMIN'::public.user_role);
76
-
77
- GRANT SELECT, INSERT, UPDATE, DELETE ON public.mcp_access_tokens TO authenticated;
78
- GRANT ALL ON public.mcp_access_tokens TO service_role;
79
-
80
- -- Add cortex_ai_mcp_settings to the admin-only site_settings group (all four policies).
81
- DROP POLICY IF EXISTS site_settings_read_policy ON public.site_settings;
82
- CREATE POLICY site_settings_read_policy ON public.site_settings FOR SELECT USING (((key <> ALL (ARRAY['cortex_ai_openrouter_api_key'::text, 'bot_protection_secret'::text, 'email_secret'::text, 'payment_secret'::text, 'cortex_ai_pexels_api_key'::text, 'cortex_ai_unsplash_access_key'::text, 'cortex_ai_mcp_settings'::text])) OR ((key = ANY (ARRAY['cortex_ai_openrouter_api_key'::text, 'bot_protection_secret'::text, 'email_secret'::text, 'payment_secret'::text, 'cortex_ai_pexels_api_key'::text, 'cortex_ai_unsplash_access_key'::text, 'cortex_ai_mcp_settings'::text])) AND (( SELECT auth.role() AS role) = 'authenticated'::text) AND (( SELECT public.get_current_user_role() AS get_current_user_role) = 'ADMIN'::public.user_role))));
83
-
84
- DROP POLICY IF EXISTS site_settings_insert_policy ON public.site_settings;
85
- CREATE POLICY site_settings_insert_policy ON public.site_settings FOR INSERT TO authenticated WITH CHECK ((((key <> ALL (ARRAY['cortex_ai_openrouter_api_key'::text, 'bot_protection_secret'::text, 'email_secret'::text, 'payment_secret'::text, 'language_detection_settings'::text, 'cortex_ai_pexels_api_key'::text, 'cortex_ai_unsplash_access_key'::text, 'cortex_ai_mcp_settings'::text])) AND (( SELECT public.get_current_user_role() AS get_current_user_role) = ANY (ARRAY['ADMIN'::public.user_role, 'WRITER'::public.user_role]))) OR ((key = ANY (ARRAY['cortex_ai_openrouter_api_key'::text, 'bot_protection_secret'::text, 'email_secret'::text, 'payment_secret'::text, 'language_detection_settings'::text, 'cortex_ai_pexels_api_key'::text, 'cortex_ai_unsplash_access_key'::text, 'cortex_ai_mcp_settings'::text])) AND (( SELECT public.get_current_user_role() AS get_current_user_role) = 'ADMIN'::public.user_role))));
86
-
87
- DROP POLICY IF EXISTS site_settings_update_policy ON public.site_settings;
88
- CREATE POLICY site_settings_update_policy ON public.site_settings FOR UPDATE TO authenticated USING ((((key <> ALL (ARRAY['cortex_ai_openrouter_api_key'::text, 'bot_protection_secret'::text, 'email_secret'::text, 'payment_secret'::text, 'language_detection_settings'::text, 'cortex_ai_pexels_api_key'::text, 'cortex_ai_unsplash_access_key'::text, 'cortex_ai_mcp_settings'::text])) AND (( SELECT public.get_current_user_role() AS get_current_user_role) = ANY (ARRAY['ADMIN'::public.user_role, 'WRITER'::public.user_role]))) OR ((key = ANY (ARRAY['cortex_ai_openrouter_api_key'::text, 'bot_protection_secret'::text, 'email_secret'::text, 'payment_secret'::text, 'language_detection_settings'::text, 'cortex_ai_pexels_api_key'::text, 'cortex_ai_unsplash_access_key'::text, 'cortex_ai_mcp_settings'::text])) AND (( SELECT public.get_current_user_role() AS get_current_user_role) = 'ADMIN'::public.user_role)))) WITH CHECK ((((key <> ALL (ARRAY['cortex_ai_openrouter_api_key'::text, 'bot_protection_secret'::text, 'email_secret'::text, 'payment_secret'::text, 'language_detection_settings'::text, 'cortex_ai_pexels_api_key'::text, 'cortex_ai_unsplash_access_key'::text, 'cortex_ai_mcp_settings'::text])) AND (( SELECT public.get_current_user_role() AS get_current_user_role) = ANY (ARRAY['ADMIN'::public.user_role, 'WRITER'::public.user_role]))) OR ((key = ANY (ARRAY['cortex_ai_openrouter_api_key'::text, 'bot_protection_secret'::text, 'email_secret'::text, 'payment_secret'::text, 'language_detection_settings'::text, 'cortex_ai_pexels_api_key'::text, 'cortex_ai_unsplash_access_key'::text, 'cortex_ai_mcp_settings'::text])) AND (( SELECT public.get_current_user_role() AS get_current_user_role) = 'ADMIN'::public.user_role))));
89
-
90
- DROP POLICY IF EXISTS site_settings_delete_policy ON public.site_settings;
91
- CREATE POLICY site_settings_delete_policy ON public.site_settings FOR DELETE TO authenticated USING ((((key <> ALL (ARRAY['cortex_ai_openrouter_api_key'::text, 'bot_protection_secret'::text, 'email_secret'::text, 'payment_secret'::text, 'language_detection_settings'::text, 'cortex_ai_pexels_api_key'::text, 'cortex_ai_unsplash_access_key'::text, 'cortex_ai_mcp_settings'::text])) AND (( SELECT public.get_current_user_role() AS get_current_user_role) = ANY (ARRAY['ADMIN'::public.user_role, 'WRITER'::public.user_role]))) OR ((key = ANY (ARRAY['cortex_ai_openrouter_api_key'::text, 'bot_protection_secret'::text, 'email_secret'::text, 'payment_secret'::text, 'language_detection_settings'::text, 'cortex_ai_pexels_api_key'::text, 'cortex_ai_unsplash_access_key'::text, 'cortex_ai_mcp_settings'::text])) AND (( SELECT public.get_current_user_role() AS get_current_user_role) = 'ADMIN'::public.user_role))));
@@ -1,96 +0,0 @@
1
- -- Site scripts: admin-authored JavaScript injected into every page of the public site.
2
- --
3
- -- Rich-text blocks can already carry an inline <script>, but that script belongs to
4
- -- one block on one page. This table is for behaviour that spans the site: chat
5
- -- widgets, third-party embeds, and the scroll/animation helpers that page classes
6
- -- rely on. Each row gets a name, an on/off switch, and a defined injection point.
7
- --
8
- -- NOT the same thing as `site_settings.privacy_settings -> custom_scripts`, which is
9
- -- a single consent-gated blob for marketing tags and only fires once a visitor
10
- -- accepts cookies. Rows here are functional site code and run unconditionally, so
11
- -- anything requiring consent belongs in that setting instead, not this table.
12
- --
13
- -- Scripts are emitted with the request's CSP nonce by the root layout, so they run
14
- -- under the site's existing Content-Security-Policy rather than forcing it open.
15
- --
16
- -- Security posture: this is arbitrary JavaScript on every page, so writes are
17
- -- ADMIN-only (WRITER is deliberately excluded, unlike most content tables) and the
18
- -- public may read only rows that are switched on, so a half-written draft is never
19
- -- served to a visitor.
20
-
21
- CREATE TABLE IF NOT EXISTS public.site_scripts (
22
- id uuid DEFAULT gen_random_uuid() NOT NULL,
23
- name text NOT NULL,
24
- description text,
25
- -- Raw JavaScript, stored WITHOUT the surrounding <script> tag. The layout adds
26
- -- the tag so the nonce and attributes are always applied by us, never by the
27
- -- author. Ignored when `src` is set.
28
- code text DEFAULT ''::text NOT NULL,
29
- -- When set, an external script is loaded from this URL and `code` is ignored.
30
- src text,
31
- -- Where the tag is emitted. 'head' runs before first paint (blocking, use
32
- -- sparingly); 'body_end' runs once the markup exists and is the right default
33
- -- for anything that queries the DOM.
34
- placement text DEFAULT 'body_end'::text NOT NULL,
35
- -- Applies to external `src` scripts; inline code ignores it.
36
- load_strategy text DEFAULT 'default'::text NOT NULL,
37
- is_active boolean DEFAULT false NOT NULL,
38
- sort_order integer DEFAULT 0 NOT NULL,
39
- created_at timestamp with time zone DEFAULT now() NOT NULL,
40
- updated_at timestamp with time zone DEFAULT now() NOT NULL,
41
- CONSTRAINT site_scripts_pkey PRIMARY KEY (id),
42
- CONSTRAINT site_scripts_placement_check
43
- CHECK ((placement = ANY (ARRAY['head'::text, 'body_start'::text, 'body_end'::text]))),
44
- CONSTRAINT site_scripts_load_strategy_check
45
- CHECK ((load_strategy = ANY (ARRAY['default'::text, 'defer'::text, 'async'::text]))),
46
- -- An external script must be https so it cannot be downgraded in transit.
47
- CONSTRAINT site_scripts_src_scheme_check
48
- CHECK ((src IS NULL OR src ~ '^https://')),
49
- -- A row has to actually do something: inline code or an external src.
50
- CONSTRAINT site_scripts_has_payload_check
51
- CHECK ((src IS NOT NULL OR length(btrim(code)) > 0))
52
- );
53
-
54
- COMMENT ON TABLE public.site_scripts IS
55
- 'Admin-authored JavaScript injected into the public site by the root layout, with the request CSP nonce applied. Only is_active rows are publicly readable; only ADMIN may write. Distinct from privacy_settings.custom_scripts, which is consent-gated marketing tags.';
56
-
57
- CREATE INDEX IF NOT EXISTS site_scripts_active_placement_sort_idx
58
- ON public.site_scripts USING btree (is_active, placement, sort_order);
59
-
60
- DROP TRIGGER IF EXISTS set_site_scripts_updated_at ON public.site_scripts;
61
- CREATE TRIGGER set_site_scripts_updated_at
62
- BEFORE UPDATE ON public.site_scripts
63
- FOR EACH ROW EXECUTE FUNCTION public.set_current_timestamp_updated_at();
64
-
65
- ALTER TABLE public.site_scripts ENABLE ROW LEVEL SECURITY;
66
-
67
- GRANT ALL ON TABLE public.site_scripts TO anon;
68
- GRANT ALL ON TABLE public.site_scripts TO authenticated;
69
- GRANT ALL ON TABLE public.site_scripts TO service_role;
70
-
71
- -- Anonymous visitors need the active scripts to render the page. Inactive rows stay
72
- -- private so a half-written script is never exposed before it is switched on.
73
- DROP POLICY IF EXISTS "Public read active site scripts" ON public.site_scripts;
74
- CREATE POLICY "Public read active site scripts" ON public.site_scripts
75
- FOR SELECT TO authenticated, anon USING (is_active);
76
-
77
- DROP POLICY IF EXISTS "Admins read all site scripts" ON public.site_scripts;
78
- CREATE POLICY "Admins read all site scripts" ON public.site_scripts
79
- FOR SELECT TO authenticated
80
- USING (((SELECT public.get_current_user_role()) = 'ADMIN'::public.user_role));
81
-
82
- DROP POLICY IF EXISTS "Admins insert site scripts" ON public.site_scripts;
83
- CREATE POLICY "Admins insert site scripts" ON public.site_scripts
84
- FOR INSERT TO authenticated
85
- WITH CHECK (((SELECT public.get_current_user_role()) = 'ADMIN'::public.user_role));
86
-
87
- DROP POLICY IF EXISTS "Admins update site scripts" ON public.site_scripts;
88
- CREATE POLICY "Admins update site scripts" ON public.site_scripts
89
- FOR UPDATE TO authenticated
90
- USING (((SELECT public.get_current_user_role()) = 'ADMIN'::public.user_role))
91
- WITH CHECK (((SELECT public.get_current_user_role()) = 'ADMIN'::public.user_role));
92
-
93
- DROP POLICY IF EXISTS "Admins delete site scripts" ON public.site_scripts;
94
- CREATE POLICY "Admins delete site scripts" ON public.site_scripts
95
- FOR DELETE TO authenticated
96
- USING (((SELECT public.get_current_user_role()) = 'ADMIN'::public.user_role));
@@ -1,85 +0,0 @@
1
- -- Audit trail and undo for site scripts.
2
- --
3
- -- `site_scripts` ships arbitrary JavaScript to every visitor, which makes it the
4
- -- highest-privilege write in the CMS: a bad or malicious snippet can read cookies,
5
- -- watch checkout forms, or phone home. Content has Revision History for exactly this
6
- -- reason; code needs it more, not less. Every create/update/delete writes one row
7
- -- here, and every row is a complete, restorable snapshot — so this table is both the
8
- -- log ("who shipped what, when, from where") and the undo.
9
- --
10
- -- APPEND-ONLY BY CONSTRUCTION. There are no UPDATE or DELETE policies, and the
11
- -- trigger below rejects both even for the service role, which otherwise bypasses
12
- -- RLS. An audit trail that the compromised credential can rewrite is not an audit
13
- -- trail. Reverting therefore writes a NEW 'revert' row rather than removing history.
14
- --
15
- -- `script_id` and `actor_user_id` are deliberately PLAIN uuids with no foreign keys:
16
- -- an FK with ON DELETE SET NULL would have to UPDATE this table when a script or a
17
- -- profile is deleted, which the append-only trigger forbids. `script_name` is
18
- -- denormalised so a deleted script is still identifiable in the log.
19
-
20
- CREATE TABLE IF NOT EXISTS public.site_script_revisions (
21
- id uuid DEFAULT gen_random_uuid() NOT NULL,
22
- script_id uuid,
23
- script_name text NOT NULL,
24
- revision_type text NOT NULL,
25
- -- Null when the actor could not be resolved (e.g. a localhost dev connection).
26
- actor_user_id uuid,
27
- -- Which surface made the change, so an unexpected edit can be traced back to
28
- -- the dashboard or to an MCP token.
29
- source text DEFAULT 'cms'::text NOT NULL,
30
- summary text,
31
- -- Full restorable state of the script at this revision. For 'delete' it is the
32
- -- state immediately BEFORE removal, so restoring it brings the script back.
33
- snapshot jsonb NOT NULL,
34
- created_at timestamp with time zone DEFAULT now() NOT NULL,
35
- CONSTRAINT site_script_revisions_pkey PRIMARY KEY (id),
36
- CONSTRAINT site_script_revisions_type_check
37
- CHECK ((revision_type = ANY (ARRAY['create'::text, 'update'::text, 'delete'::text, 'revert'::text]))),
38
- CONSTRAINT site_script_revisions_source_check
39
- CHECK ((source = ANY (ARRAY['cms'::text, 'mcp'::text]))),
40
- CONSTRAINT site_script_revisions_snapshot_is_object_check
41
- CHECK ((jsonb_typeof(snapshot) = 'object'))
42
- );
43
-
44
- COMMENT ON TABLE public.site_script_revisions IS
45
- 'Append-only audit log and undo history for site_scripts. Each row is a restorable snapshot. UPDATE and DELETE are blocked by trigger, including for the service role.';
46
-
47
- CREATE INDEX IF NOT EXISTS site_script_revisions_script_created_idx
48
- ON public.site_script_revisions USING btree (script_id, created_at DESC);
49
-
50
- CREATE INDEX IF NOT EXISTS site_script_revisions_created_idx
51
- ON public.site_script_revisions USING btree (created_at DESC);
52
-
53
- -- Enforced in the database rather than the application so it holds for every
54
- -- caller, including the service-role client the MCP server uses.
55
- CREATE OR REPLACE FUNCTION public.prevent_site_script_revision_rewrite() RETURNS trigger
56
- LANGUAGE plpgsql
57
- SET search_path = ''
58
- AS $$
59
- BEGIN
60
- RAISE EXCEPTION 'site_script_revisions is append-only; % is not permitted', TG_OP
61
- USING ERRCODE = 'restrict_violation';
62
- END;
63
- $$;
64
-
65
- DROP TRIGGER IF EXISTS trg_site_script_revisions_append_only ON public.site_script_revisions;
66
- CREATE TRIGGER trg_site_script_revisions_append_only
67
- BEFORE UPDATE OR DELETE ON public.site_script_revisions
68
- FOR EACH ROW EXECUTE FUNCTION public.prevent_site_script_revision_rewrite();
69
-
70
- ALTER TABLE public.site_script_revisions ENABLE ROW LEVEL SECURITY;
71
-
72
- GRANT SELECT, INSERT ON TABLE public.site_script_revisions TO authenticated;
73
- GRANT ALL ON TABLE public.site_script_revisions TO service_role;
74
-
75
- -- Read is ADMIN-only: snapshots contain the full source of scripts that may not be
76
- -- active yet, and the log itself reveals operational history.
77
- DROP POLICY IF EXISTS "Admins read site script revisions" ON public.site_script_revisions;
78
- CREATE POLICY "Admins read site script revisions" ON public.site_script_revisions
79
- FOR SELECT TO authenticated
80
- USING (((SELECT public.get_current_user_role()) = 'ADMIN'::public.user_role));
81
-
82
- DROP POLICY IF EXISTS "Admins insert site script revisions" ON public.site_script_revisions;
83
- CREATE POLICY "Admins insert site script revisions" ON public.site_script_revisions
84
- FOR INSERT TO authenticated
85
- WITH CHECK (((SELECT public.get_current_user_role()) = 'ADMIN'::public.user_role));