@nextblock-cms/db 0.15.10 → 0.16.2
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/lib/supabase/server.d.ts +31 -1
- package/lib/supabase/types.d.ts +41 -11
- package/package.json +1 -1
- package/supabase/migrations/00000000000003_baseline_seed.sql +468 -468
- package/supabase/migrations/00000000000006_home_live_demo_promo.sql +67 -67
- package/supabase/migrations/00000000000007_home_live_demo_promo_copy_fix.sql +49 -49
- package/supabase/migrations/00000000000009_home_live_demo_promo_contrast.sql +52 -52
- package/supabase/migrations/00000000000014_site_themes.sql +254 -254
- package/supabase/migrations/00000000000015_scheduled_publishing.sql +43 -43
- package/supabase/migrations/00000000000016_product_revisions_and_revision_baseline.sql +310 -310
- package/supabase/migrations/00000000000017_cortex_ai_mcp_server.sql +91 -91
- package/supabase/migrations/00000000000018_site_scripts.sql +96 -96
- package/supabase/migrations/00000000000019_site_script_revisions.sql +85 -85
- package/supabase/migrations/00000000000020_updating_article.sql +162 -162
- package/supabase/migrations/00000000000021_updating_article_git_merge.sql +91 -91
- package/supabase/migrations/00000000000022_updating_article_accuracy.sql +94 -94
- package/supabase/migrations/00000000000023_updating_article_layout_not_host.sql +87 -87
- package/supabase/migrations/00000000000024_updating_article_newline_fix.sql +70 -70
- package/supabase/migrations/00000000000025_rebrand_nextblock_dev.sql +53 -53
- package/supabase/migrations/00000000000026_product_inquiries.sql +118 -118
- package/supabase/migrations/00000000000027_message_threads.sql +391 -391
- package/supabase/migrations/00000000000028_interaction_replies.sql +86 -86
- package/supabase/migrations/00000000000029_form_endpoints_default_empty.sql +52 -52
- package/supabase/migrations/00000000000030_seo_redirects_and_robots.sql +149 -0
- package/supabase/migrations/00000000000031_seo_robots_settings_admin_only.sql +35 -0
|
@@ -1,86 +1,86 @@
|
|
|
1
|
-
-- Staff replies on reviews and post comments, and the indexes cms_interactions never had.
|
|
2
|
-
--
|
|
3
|
-
-- WHY THIS IS A DIFFERENT SHAPE FROM MIGRATION 27, AND WHY IT NEEDS NO TOKEN.
|
|
4
|
-
-- `cms_interactions.user_id` is NOT NULL with a foreign key to `profiles`: every review
|
|
5
|
-
-- and comment already comes from a signed-in account with a reachable address, and the
|
|
6
|
-
-- content already renders publicly on /product/{slug} and /article/{slug}. A staff
|
|
7
|
-
-- answer there is PUBLISHED CONTENT plus moderation, not a private conversation.
|
|
8
|
-
-- Routing it through the tokenised thread page would hide a public reply behind a
|
|
9
|
-
-- secret link. So: one inbox in the CMS, two storage models underneath.
|
|
10
|
-
--
|
|
11
|
-
-- THE ONE CONSTRAINT THAT DICTATES THE MODELLING.
|
|
12
|
-
-- `check_rating_only_for_review` is an exhaustive OR over ('review','comment'):
|
|
13
|
-
-- (type='review' AND rating IS NOT NULL AND rating BETWEEN 1 AND 5)
|
|
14
|
-
-- OR (type='comment' AND rating IS NULL)
|
|
15
|
-
-- A type='review' row with a NULL rating is rejected, so a reply to a review cannot be
|
|
16
|
-
-- a 'review' row. Adding a 'reply' value to interaction_type would ALSO violate it —
|
|
17
|
-
-- the OR covers no third value — and PostgreSQL forbids using an enum value in the
|
|
18
|
-
-- transaction that added it, which is exactly one migration file.
|
|
19
|
-
--
|
|
20
|
-
-- THEREFORE a reply is a `type='comment'` row carrying the PARENT's target and a NULL
|
|
21
|
-
-- rating: a combination the existing constraints already accept, unchanged. The bonus
|
|
22
|
-
-- is decisive — `update_product_ratings()` aggregates only
|
|
23
|
-
-- WHERE product_id = ? AND type='review' AND status='approved'
|
|
24
|
-
-- so a reply can never move products.average_rating or products.total_reviews. The new
|
|
25
|
-
-- CHECK below pins that invariant in the schema instead of trusting the action to
|
|
26
|
-
-- remember it.
|
|
27
|
-
--
|
|
28
|
-
-- No new policies are needed. `cms_interactions_insert_policy` already admits an
|
|
29
|
-
-- ADMIN/WRITER self-insert with status='approved'
|
|
30
|
-
-- (auth.uid() = user_id AND (status='pending' OR role IN ('ADMIN','WRITER')))
|
|
31
|
-
-- which is precisely a published staff reply.
|
|
32
|
-
--
|
|
33
|
-
-- Forward-only and idempotent.
|
|
34
|
-
|
|
35
|
-
ALTER TABLE public.cms_interactions ADD COLUMN IF NOT EXISTS parent_id uuid;
|
|
36
|
-
|
|
37
|
-
COMMENT ON COLUMN public.cms_interactions.parent_id IS 'Set on a staff reply: points at the review or comment being answered. Replies are always type=comment with a NULL rating, and carry the parent''s product_id/post_id so check_product_or_post still holds.';
|
|
38
|
-
|
|
39
|
-
DO $rb$ BEGIN
|
|
40
|
-
IF NOT EXISTS (SELECT 1 FROM pg_constraint
|
|
41
|
-
WHERE conname = 'cms_interactions_parent_id_fkey'
|
|
42
|
-
AND conrelid = 'public.cms_interactions'::regclass) THEN
|
|
43
|
-
ALTER TABLE ONLY public.cms_interactions
|
|
44
|
-
ADD CONSTRAINT cms_interactions_parent_id_fkey FOREIGN KEY (parent_id)
|
|
45
|
-
REFERENCES public.cms_interactions(id) ON DELETE CASCADE;
|
|
46
|
-
END IF;
|
|
47
|
-
END $rb$;
|
|
48
|
-
|
|
49
|
-
-- One level only, never itself, no rating, always a comment. Keeps the reply
|
|
50
|
-
-- invisible to the ratings trigger and to the existing storefront review query.
|
|
51
|
-
DO $rb$ BEGIN
|
|
52
|
-
IF NOT EXISTS (SELECT 1 FROM pg_constraint
|
|
53
|
-
WHERE conname = 'cms_interactions_reply_check'
|
|
54
|
-
AND conrelid = 'public.cms_interactions'::regclass) THEN
|
|
55
|
-
ALTER TABLE public.cms_interactions
|
|
56
|
-
ADD CONSTRAINT cms_interactions_reply_check CHECK (
|
|
57
|
-
((parent_id IS NULL) OR
|
|
58
|
-
((parent_id <> id) AND (rating IS NULL) AND (type = 'comment'::public.interaction_type))));
|
|
59
|
-
END IF;
|
|
60
|
-
END $rb$;
|
|
61
|
-
|
|
62
|
-
-- Fetch every reply for a page of parents in one probe.
|
|
63
|
-
CREATE INDEX IF NOT EXISTS cms_interactions_parent_idx
|
|
64
|
-
ON public.cms_interactions USING btree (parent_id, created_at)
|
|
65
|
-
WHERE (parent_id IS NOT NULL);
|
|
66
|
-
|
|
67
|
-
-- cms_interactions has had ZERO secondary indexes since the baseline, while both
|
|
68
|
-
-- public renderers filter on exactly these predicates on every product and article
|
|
69
|
-
-- page. The inbox multiplies read volume on this table, so they go in now.
|
|
70
|
-
CREATE INDEX IF NOT EXISTS cms_interactions_product_type_status_idx
|
|
71
|
-
ON public.cms_interactions USING btree (product_id, type, status, created_at DESC)
|
|
72
|
-
WHERE (product_id IS NOT NULL);
|
|
73
|
-
|
|
74
|
-
CREATE INDEX IF NOT EXISTS cms_interactions_post_type_status_idx
|
|
75
|
-
ON public.cms_interactions USING btree (post_id, type, status, created_at DESC)
|
|
76
|
-
WHERE (post_id IS NOT NULL);
|
|
77
|
-
|
|
78
|
-
-- Inbox listing: newest top-level items, replies excluded.
|
|
79
|
-
CREATE INDEX IF NOT EXISTS cms_interactions_inbox_idx
|
|
80
|
-
ON public.cms_interactions USING btree (created_at DESC)
|
|
81
|
-
WHERE (parent_id IS NULL);
|
|
82
|
-
|
|
83
|
-
INSERT INTO public.translations (key, translations, created_at, updated_at) VALUES
|
|
84
|
-
('interactions.staff_reply', '{"en": "Reply from the team", "fr": "Réponse de l''équipe"}', now(), now()),
|
|
85
|
-
('interactions.staff_badge', '{"en": "Staff", "fr": "Équipe"}', now(), now())
|
|
86
|
-
ON CONFLICT (key) DO NOTHING;
|
|
1
|
+
-- Staff replies on reviews and post comments, and the indexes cms_interactions never had.
|
|
2
|
+
--
|
|
3
|
+
-- WHY THIS IS A DIFFERENT SHAPE FROM MIGRATION 27, AND WHY IT NEEDS NO TOKEN.
|
|
4
|
+
-- `cms_interactions.user_id` is NOT NULL with a foreign key to `profiles`: every review
|
|
5
|
+
-- and comment already comes from a signed-in account with a reachable address, and the
|
|
6
|
+
-- content already renders publicly on /product/{slug} and /article/{slug}. A staff
|
|
7
|
+
-- answer there is PUBLISHED CONTENT plus moderation, not a private conversation.
|
|
8
|
+
-- Routing it through the tokenised thread page would hide a public reply behind a
|
|
9
|
+
-- secret link. So: one inbox in the CMS, two storage models underneath.
|
|
10
|
+
--
|
|
11
|
+
-- THE ONE CONSTRAINT THAT DICTATES THE MODELLING.
|
|
12
|
+
-- `check_rating_only_for_review` is an exhaustive OR over ('review','comment'):
|
|
13
|
+
-- (type='review' AND rating IS NOT NULL AND rating BETWEEN 1 AND 5)
|
|
14
|
+
-- OR (type='comment' AND rating IS NULL)
|
|
15
|
+
-- A type='review' row with a NULL rating is rejected, so a reply to a review cannot be
|
|
16
|
+
-- a 'review' row. Adding a 'reply' value to interaction_type would ALSO violate it —
|
|
17
|
+
-- the OR covers no third value — and PostgreSQL forbids using an enum value in the
|
|
18
|
+
-- transaction that added it, which is exactly one migration file.
|
|
19
|
+
--
|
|
20
|
+
-- THEREFORE a reply is a `type='comment'` row carrying the PARENT's target and a NULL
|
|
21
|
+
-- rating: a combination the existing constraints already accept, unchanged. The bonus
|
|
22
|
+
-- is decisive — `update_product_ratings()` aggregates only
|
|
23
|
+
-- WHERE product_id = ? AND type='review' AND status='approved'
|
|
24
|
+
-- so a reply can never move products.average_rating or products.total_reviews. The new
|
|
25
|
+
-- CHECK below pins that invariant in the schema instead of trusting the action to
|
|
26
|
+
-- remember it.
|
|
27
|
+
--
|
|
28
|
+
-- No new policies are needed. `cms_interactions_insert_policy` already admits an
|
|
29
|
+
-- ADMIN/WRITER self-insert with status='approved'
|
|
30
|
+
-- (auth.uid() = user_id AND (status='pending' OR role IN ('ADMIN','WRITER')))
|
|
31
|
+
-- which is precisely a published staff reply.
|
|
32
|
+
--
|
|
33
|
+
-- Forward-only and idempotent.
|
|
34
|
+
|
|
35
|
+
ALTER TABLE public.cms_interactions ADD COLUMN IF NOT EXISTS parent_id uuid;
|
|
36
|
+
|
|
37
|
+
COMMENT ON COLUMN public.cms_interactions.parent_id IS 'Set on a staff reply: points at the review or comment being answered. Replies are always type=comment with a NULL rating, and carry the parent''s product_id/post_id so check_product_or_post still holds.';
|
|
38
|
+
|
|
39
|
+
DO $rb$ BEGIN
|
|
40
|
+
IF NOT EXISTS (SELECT 1 FROM pg_constraint
|
|
41
|
+
WHERE conname = 'cms_interactions_parent_id_fkey'
|
|
42
|
+
AND conrelid = 'public.cms_interactions'::regclass) THEN
|
|
43
|
+
ALTER TABLE ONLY public.cms_interactions
|
|
44
|
+
ADD CONSTRAINT cms_interactions_parent_id_fkey FOREIGN KEY (parent_id)
|
|
45
|
+
REFERENCES public.cms_interactions(id) ON DELETE CASCADE;
|
|
46
|
+
END IF;
|
|
47
|
+
END $rb$;
|
|
48
|
+
|
|
49
|
+
-- One level only, never itself, no rating, always a comment. Keeps the reply
|
|
50
|
+
-- invisible to the ratings trigger and to the existing storefront review query.
|
|
51
|
+
DO $rb$ BEGIN
|
|
52
|
+
IF NOT EXISTS (SELECT 1 FROM pg_constraint
|
|
53
|
+
WHERE conname = 'cms_interactions_reply_check'
|
|
54
|
+
AND conrelid = 'public.cms_interactions'::regclass) THEN
|
|
55
|
+
ALTER TABLE public.cms_interactions
|
|
56
|
+
ADD CONSTRAINT cms_interactions_reply_check CHECK (
|
|
57
|
+
((parent_id IS NULL) OR
|
|
58
|
+
((parent_id <> id) AND (rating IS NULL) AND (type = 'comment'::public.interaction_type))));
|
|
59
|
+
END IF;
|
|
60
|
+
END $rb$;
|
|
61
|
+
|
|
62
|
+
-- Fetch every reply for a page of parents in one probe.
|
|
63
|
+
CREATE INDEX IF NOT EXISTS cms_interactions_parent_idx
|
|
64
|
+
ON public.cms_interactions USING btree (parent_id, created_at)
|
|
65
|
+
WHERE (parent_id IS NOT NULL);
|
|
66
|
+
|
|
67
|
+
-- cms_interactions has had ZERO secondary indexes since the baseline, while both
|
|
68
|
+
-- public renderers filter on exactly these predicates on every product and article
|
|
69
|
+
-- page. The inbox multiplies read volume on this table, so they go in now.
|
|
70
|
+
CREATE INDEX IF NOT EXISTS cms_interactions_product_type_status_idx
|
|
71
|
+
ON public.cms_interactions USING btree (product_id, type, status, created_at DESC)
|
|
72
|
+
WHERE (product_id IS NOT NULL);
|
|
73
|
+
|
|
74
|
+
CREATE INDEX IF NOT EXISTS cms_interactions_post_type_status_idx
|
|
75
|
+
ON public.cms_interactions USING btree (post_id, type, status, created_at DESC)
|
|
76
|
+
WHERE (post_id IS NOT NULL);
|
|
77
|
+
|
|
78
|
+
-- Inbox listing: newest top-level items, replies excluded.
|
|
79
|
+
CREATE INDEX IF NOT EXISTS cms_interactions_inbox_idx
|
|
80
|
+
ON public.cms_interactions USING btree (created_at DESC)
|
|
81
|
+
WHERE (parent_id IS NULL);
|
|
82
|
+
|
|
83
|
+
INSERT INTO public.translations (key, translations, created_at, updated_at) VALUES
|
|
84
|
+
('interactions.staff_reply', '{"en": "Reply from the team", "fr": "Réponse de l''équipe"}', now(), now()),
|
|
85
|
+
('interactions.staff_badge', '{"en": "Staff", "fr": "Équipe"}', now(), now())
|
|
86
|
+
ON CONFLICT (key) DO NOTHING;
|
|
@@ -1,52 +1,52 @@
|
|
|
1
|
-
-- A contact form has no address of its own by default.
|
|
2
|
-
--
|
|
3
|
-
-- The starter content ships a contact page addressed to `contact@example.com`, and
|
|
4
|
-
-- migration 27 faithfully carried that placeholder into `form_endpoints`. Faithful was
|
|
5
|
-
-- the wrong call. `example.com` is reserved by RFC 2606 precisely so it can never be
|
|
6
|
-
-- registered, so the address is not merely unhelpful — it is guaranteed undeliverable,
|
|
7
|
-
-- while looking like a real setting to every layer downstream. The visitor is thanked,
|
|
8
|
-
-- the relay accepts the message, and nobody is ever notified. Nothing errors, so an
|
|
9
|
-
-- install can run that way indefinitely.
|
|
10
|
-
--
|
|
11
|
-
-- Since the messaging system arrived, a per-form address is not something an operator
|
|
12
|
-
-- needs to think about at all. Submissions are stored as threads and answered in
|
|
13
|
-
-- CMS → Messages; the notification address is a single site-wide setting, and a form
|
|
14
|
-
-- only carries its own address when someone deliberately wants that form routed
|
|
15
|
-
-- elsewhere (a careers form to HR, say).
|
|
16
|
-
--
|
|
17
|
-
-- So the default becomes NULL, meaning "use the site contact address", which resolves
|
|
18
|
-
-- to the address set in CMS → Messages and finally to the first admin's own login. An
|
|
19
|
-
-- install therefore reaches a real human out of the box without configuring anything.
|
|
20
|
-
--
|
|
21
|
-
-- The sandbox is unaffected: `resolveFormRecipient` overrides everything with
|
|
22
|
-
-- SANDBOX_CONTACT_EMAIL when NEXT_PUBLIC_IS_SANDBOX is set, so the hosted demo keeps
|
|
23
|
-
-- routing to the operator's own inbox without storing an address here.
|
|
24
|
-
--
|
|
25
|
-
-- Forward-only and idempotent.
|
|
26
|
-
|
|
27
|
-
-- Reserved domains from RFC 2606 / RFC 6761. Matching on the domain rather than the
|
|
28
|
-
-- exact seeded string also catches an operator who typed their own placeholder.
|
|
29
|
-
UPDATE public.form_endpoints
|
|
30
|
-
SET recipient_email = NULL
|
|
31
|
-
WHERE recipient_email IS NOT NULL
|
|
32
|
-
AND (
|
|
33
|
-
lower(recipient_email) LIKE '%@example.com'
|
|
34
|
-
OR lower(recipient_email) LIKE '%@example.org'
|
|
35
|
-
OR lower(recipient_email) LIKE '%@example.net'
|
|
36
|
-
OR lower(recipient_email) LIKE '%@example.edu'
|
|
37
|
-
OR lower(recipient_email) LIKE '%.example'
|
|
38
|
-
OR lower(recipient_email) LIKE '%.invalid'
|
|
39
|
-
OR lower(recipient_email) LIKE '%.test'
|
|
40
|
-
OR lower(recipient_email) LIKE '%.localhost'
|
|
41
|
-
OR lower(recipient_email) LIKE '%.local'
|
|
42
|
-
);
|
|
43
|
-
|
|
44
|
-
COMMENT ON COLUMN public.form_endpoints.recipient_email IS 'Per-form override. NULL means "use the site contact address" (CMS -> Messages, falling back to the first admin), which is the default and the usual case.';
|
|
45
|
-
|
|
46
|
-
-- Same reasoning for the site-wide rows: a placeholder there is worse than an empty
|
|
47
|
-
-- value, because an empty one falls through to the first admin and actually arrives.
|
|
48
|
-
UPDATE public.site_settings
|
|
49
|
-
SET value = jsonb_set(value, '{contactEmail}', '""'::jsonb)
|
|
50
|
-
WHERE key IN ('forms_contact', 'store_contact')
|
|
51
|
-
AND value ? 'contactEmail'
|
|
52
|
-
AND lower(value->>'contactEmail') LIKE '%@example.%';
|
|
1
|
+
-- A contact form has no address of its own by default.
|
|
2
|
+
--
|
|
3
|
+
-- The starter content ships a contact page addressed to `contact@example.com`, and
|
|
4
|
+
-- migration 27 faithfully carried that placeholder into `form_endpoints`. Faithful was
|
|
5
|
+
-- the wrong call. `example.com` is reserved by RFC 2606 precisely so it can never be
|
|
6
|
+
-- registered, so the address is not merely unhelpful — it is guaranteed undeliverable,
|
|
7
|
+
-- while looking like a real setting to every layer downstream. The visitor is thanked,
|
|
8
|
+
-- the relay accepts the message, and nobody is ever notified. Nothing errors, so an
|
|
9
|
+
-- install can run that way indefinitely.
|
|
10
|
+
--
|
|
11
|
+
-- Since the messaging system arrived, a per-form address is not something an operator
|
|
12
|
+
-- needs to think about at all. Submissions are stored as threads and answered in
|
|
13
|
+
-- CMS → Messages; the notification address is a single site-wide setting, and a form
|
|
14
|
+
-- only carries its own address when someone deliberately wants that form routed
|
|
15
|
+
-- elsewhere (a careers form to HR, say).
|
|
16
|
+
--
|
|
17
|
+
-- So the default becomes NULL, meaning "use the site contact address", which resolves
|
|
18
|
+
-- to the address set in CMS → Messages and finally to the first admin's own login. An
|
|
19
|
+
-- install therefore reaches a real human out of the box without configuring anything.
|
|
20
|
+
--
|
|
21
|
+
-- The sandbox is unaffected: `resolveFormRecipient` overrides everything with
|
|
22
|
+
-- SANDBOX_CONTACT_EMAIL when NEXT_PUBLIC_IS_SANDBOX is set, so the hosted demo keeps
|
|
23
|
+
-- routing to the operator's own inbox without storing an address here.
|
|
24
|
+
--
|
|
25
|
+
-- Forward-only and idempotent.
|
|
26
|
+
|
|
27
|
+
-- Reserved domains from RFC 2606 / RFC 6761. Matching on the domain rather than the
|
|
28
|
+
-- exact seeded string also catches an operator who typed their own placeholder.
|
|
29
|
+
UPDATE public.form_endpoints
|
|
30
|
+
SET recipient_email = NULL
|
|
31
|
+
WHERE recipient_email IS NOT NULL
|
|
32
|
+
AND (
|
|
33
|
+
lower(recipient_email) LIKE '%@example.com'
|
|
34
|
+
OR lower(recipient_email) LIKE '%@example.org'
|
|
35
|
+
OR lower(recipient_email) LIKE '%@example.net'
|
|
36
|
+
OR lower(recipient_email) LIKE '%@example.edu'
|
|
37
|
+
OR lower(recipient_email) LIKE '%.example'
|
|
38
|
+
OR lower(recipient_email) LIKE '%.invalid'
|
|
39
|
+
OR lower(recipient_email) LIKE '%.test'
|
|
40
|
+
OR lower(recipient_email) LIKE '%.localhost'
|
|
41
|
+
OR lower(recipient_email) LIKE '%.local'
|
|
42
|
+
);
|
|
43
|
+
|
|
44
|
+
COMMENT ON COLUMN public.form_endpoints.recipient_email IS 'Per-form override. NULL means "use the site contact address" (CMS -> Messages, falling back to the first admin), which is the default and the usual case.';
|
|
45
|
+
|
|
46
|
+
-- Same reasoning for the site-wide rows: a placeholder there is worse than an empty
|
|
47
|
+
-- value, because an empty one falls through to the first admin and actually arrives.
|
|
48
|
+
UPDATE public.site_settings
|
|
49
|
+
SET value = jsonb_set(value, '{contactEmail}', '""'::jsonb)
|
|
50
|
+
WHERE key IN ('forms_contact', 'store_contact')
|
|
51
|
+
AND value ? 'contactEmail'
|
|
52
|
+
AND lower(value->>'contactEmail') LIKE '%@example.%';
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
-- SEO engine: managed 301/302 redirects and operator-configurable robots directives.
|
|
2
|
+
--
|
|
3
|
+
-- Two unrelated-looking things ship in one migration because they are the same
|
|
4
|
+
-- feature from an operator's point of view: the `/cms/settings/seo` screen is where
|
|
5
|
+
-- someone goes to say "this URL moved" and "do not crawl that". Splitting them
|
|
6
|
+
-- across two migrations would only mean two files that must always be applied
|
|
7
|
+
-- together.
|
|
8
|
+
--
|
|
9
|
+
-- WHY A TABLE AND NOT next.config.js redirects(). Redirects are content, not
|
|
10
|
+
-- configuration. An editor who renames a page's slug needs the old URL to keep
|
|
11
|
+
-- working immediately, without a redeploy and without touching source control.
|
|
12
|
+
-- That rules out the build-time array; it has to be data.
|
|
13
|
+
--
|
|
14
|
+
-- WHY THE PROXY READS THIS WITH THE ANON KEY. apps/nextblock/proxy.ts (Next 16's
|
|
15
|
+
-- renamed middleware) resolves redirects before rendering, and it holds a Supabase
|
|
16
|
+
-- client built from the anon key. So the public SELECT policy below is load-bearing:
|
|
17
|
+
-- without an explicit `TO authenticated, anon` grant the proxy's lookup would return
|
|
18
|
+
-- zero rows for every anonymous visitor -- silently, with no error -- and no redirect
|
|
19
|
+
-- would ever fire. Only is_active rows are exposed, so a half-written rule is never
|
|
20
|
+
-- live.
|
|
21
|
+
--
|
|
22
|
+
-- WHY status_code IS AN integer AND NOT AN ENUM. The same reasoning migration 27
|
|
23
|
+
-- recorded for `source`: a Postgres enum cannot be extended and used inside the same
|
|
24
|
+
-- transaction, which is exactly the scope of one migration file. A CHECK constraint
|
|
25
|
+
-- is replaceable in a single statement. 301 and 302 are the only two values the
|
|
26
|
+
-- admin UI offers; 307/308 are deliberately not exposed, because their
|
|
27
|
+
-- method-preserving semantics surprise operators who just want "this page moved".
|
|
28
|
+
--
|
|
29
|
+
-- LOOP SAFETY is enforced in application code (wouldCreateLoop in
|
|
30
|
+
-- @nextblock-cms/utils, called by the admin server actions) rather than by a
|
|
31
|
+
-- constraint, because detecting a cycle requires walking the whole table and a CHECK
|
|
32
|
+
-- constraint can only see one row. The self-redirect case IS cheap to check per row,
|
|
33
|
+
-- so that one is a constraint -- it is the cycle operators actually hit.
|
|
34
|
+
--
|
|
35
|
+
-- SECURITY POSTURE. A redirect can send every visitor of a path to an arbitrary
|
|
36
|
+
-- external origin, which makes this table an open-redirect surface and a phishing
|
|
37
|
+
-- lever. Writes are therefore ADMIN-only -- WRITER is deliberately excluded, matching
|
|
38
|
+
-- site_scripts rather than the content tables. Reads are public but limited to active
|
|
39
|
+
-- rows. The robots settings live in site_settings, whose existing read policy is
|
|
40
|
+
-- already public for non-secret keys; nothing here is secret.
|
|
41
|
+
--
|
|
42
|
+
-- Forward-only and idempotent.
|
|
43
|
+
|
|
44
|
+
CREATE TABLE IF NOT EXISTS public.cms_redirects (
|
|
45
|
+
id uuid DEFAULT gen_random_uuid() NOT NULL,
|
|
46
|
+
-- The incoming site-relative path to match, normalized by the application to a
|
|
47
|
+
-- leading slash with no trailing slash (except root). Matching is exact: prefix
|
|
48
|
+
-- and wildcard rules are deliberately not supported, because they are the usual
|
|
49
|
+
-- way an operator builds an accidental loop.
|
|
50
|
+
source_path text NOT NULL,
|
|
51
|
+
-- Where to send the visitor. Either another site-relative path or a fully
|
|
52
|
+
-- qualified https URL for an off-site move.
|
|
53
|
+
destination_path text NOT NULL,
|
|
54
|
+
-- 301 permanent (the SEO-meaningful one: search engines transfer ranking signals
|
|
55
|
+
-- and browsers cache it aggressively) or 302 temporary.
|
|
56
|
+
status_code integer DEFAULT 301 NOT NULL,
|
|
57
|
+
is_active boolean DEFAULT true NOT NULL,
|
|
58
|
+
created_at timestamp with time zone DEFAULT now() NOT NULL,
|
|
59
|
+
updated_at timestamp with time zone DEFAULT now() NOT NULL,
|
|
60
|
+
CONSTRAINT cms_redirects_pkey PRIMARY KEY (id),
|
|
61
|
+
-- One rule per source. This UNIQUE constraint also provides the index the proxy
|
|
62
|
+
-- lookup relies on, so no separate plain index on source_path is needed.
|
|
63
|
+
CONSTRAINT cms_redirects_source_path_key UNIQUE (source_path),
|
|
64
|
+
CONSTRAINT cms_redirects_status_code_check
|
|
65
|
+
CHECK ((status_code = ANY (ARRAY[301, 302]))),
|
|
66
|
+
-- A source is always a path on this site; accepting an absolute URL here would
|
|
67
|
+
-- silently never match, since the proxy only ever compares pathnames.
|
|
68
|
+
CONSTRAINT cms_redirects_source_path_check
|
|
69
|
+
CHECK ((source_path ~ '^/')),
|
|
70
|
+
-- A destination is either a site-relative path or an https URL. Plain http is
|
|
71
|
+
-- refused so a redirect can never downgrade a visitor to cleartext.
|
|
72
|
+
CONSTRAINT cms_redirects_destination_path_check
|
|
73
|
+
CHECK (((destination_path ~ '^/') OR (destination_path ~ '^https://'))),
|
|
74
|
+
-- The one cycle a single row can express, and the one operators actually create.
|
|
75
|
+
CONSTRAINT cms_redirects_no_self_redirect_check
|
|
76
|
+
CHECK ((source_path <> destination_path))
|
|
77
|
+
);
|
|
78
|
+
|
|
79
|
+
COMMENT ON TABLE public.cms_redirects IS
|
|
80
|
+
'Operator-managed 301/302 redirects resolved by apps/nextblock/proxy.ts before rendering. Only is_active rows are publicly readable; only ADMIN may write, because a redirect rule is an open-redirect surface.';
|
|
81
|
+
COMMENT ON COLUMN public.cms_redirects.source_path IS
|
|
82
|
+
'Exact site-relative path to match, normalized to a leading slash and no trailing slash (except root). No wildcards, by design.';
|
|
83
|
+
COMMENT ON COLUMN public.cms_redirects.destination_path IS
|
|
84
|
+
'Site-relative path or absolute https URL to send the visitor to.';
|
|
85
|
+
COMMENT ON COLUMN public.cms_redirects.status_code IS
|
|
86
|
+
'301 permanent or 302 temporary. 307/308 are not offered by the admin UI.';
|
|
87
|
+
COMMENT ON COLUMN public.cms_redirects.is_active IS
|
|
88
|
+
'Only active rows are readable by anon, and only active rows are matched by the proxy.';
|
|
89
|
+
|
|
90
|
+
-- The proxy loads the whole active set once per cache window rather than querying per
|
|
91
|
+
-- request, so the hot query is "all active rows" and this partial index is what serves
|
|
92
|
+
-- it. Carrying source_path in the index keeps that read index-only.
|
|
93
|
+
CREATE INDEX IF NOT EXISTS cms_redirects_active_source_idx
|
|
94
|
+
ON public.cms_redirects USING btree (source_path) WHERE (is_active);
|
|
95
|
+
|
|
96
|
+
DROP TRIGGER IF EXISTS set_cms_redirects_updated_at ON public.cms_redirects;
|
|
97
|
+
CREATE TRIGGER set_cms_redirects_updated_at
|
|
98
|
+
BEFORE UPDATE ON public.cms_redirects
|
|
99
|
+
FOR EACH ROW EXECUTE FUNCTION public.set_current_timestamp_updated_at();
|
|
100
|
+
|
|
101
|
+
ALTER TABLE public.cms_redirects ENABLE ROW LEVEL SECURITY;
|
|
102
|
+
|
|
103
|
+
GRANT ALL ON TABLE public.cms_redirects TO anon;
|
|
104
|
+
GRANT ALL ON TABLE public.cms_redirects TO authenticated;
|
|
105
|
+
GRANT ALL ON TABLE public.cms_redirects TO service_role;
|
|
106
|
+
|
|
107
|
+
-- The proxy runs as anon for a logged-out visitor, which is the overwhelming majority
|
|
108
|
+
-- of traffic and the only traffic redirects really matter for. Without this policy the
|
|
109
|
+
-- lookup returns zero rows and the feature is silently dead.
|
|
110
|
+
DROP POLICY IF EXISTS "Public read active redirects" ON public.cms_redirects;
|
|
111
|
+
CREATE POLICY "Public read active redirects" ON public.cms_redirects
|
|
112
|
+
FOR SELECT TO authenticated, anon USING (is_active);
|
|
113
|
+
|
|
114
|
+
DROP POLICY IF EXISTS "Admins read all redirects" ON public.cms_redirects;
|
|
115
|
+
CREATE POLICY "Admins read all redirects" ON public.cms_redirects
|
|
116
|
+
FOR SELECT TO authenticated
|
|
117
|
+
USING (((SELECT public.get_current_user_role()) = 'ADMIN'::public.user_role));
|
|
118
|
+
|
|
119
|
+
DROP POLICY IF EXISTS "Admins insert redirects" ON public.cms_redirects;
|
|
120
|
+
CREATE POLICY "Admins insert redirects" ON public.cms_redirects
|
|
121
|
+
FOR INSERT TO authenticated
|
|
122
|
+
WITH CHECK (((SELECT public.get_current_user_role()) = 'ADMIN'::public.user_role));
|
|
123
|
+
|
|
124
|
+
DROP POLICY IF EXISTS "Admins update redirects" ON public.cms_redirects;
|
|
125
|
+
CREATE POLICY "Admins update redirects" ON public.cms_redirects
|
|
126
|
+
FOR UPDATE TO authenticated
|
|
127
|
+
USING (((SELECT public.get_current_user_role()) = 'ADMIN'::public.user_role))
|
|
128
|
+
WITH CHECK (((SELECT public.get_current_user_role()) = 'ADMIN'::public.user_role));
|
|
129
|
+
|
|
130
|
+
DROP POLICY IF EXISTS "Admins delete redirects" ON public.cms_redirects;
|
|
131
|
+
CREATE POLICY "Admins delete redirects" ON public.cms_redirects
|
|
132
|
+
FOR DELETE TO authenticated
|
|
133
|
+
USING (((SELECT public.get_current_user_role()) = 'ADMIN'::public.user_role));
|
|
134
|
+
|
|
135
|
+
-- Robots directives live in site_settings rather than a table of their own: there is
|
|
136
|
+
-- exactly one robots.txt per install, so a dedicated table would only ever hold one
|
|
137
|
+
-- row, and site_settings already carries the public-read / staff-write policy this
|
|
138
|
+
-- needs. The stored shape matches `RobotsSettings` in @nextblock-cms/utils, and
|
|
139
|
+
-- `normalizeRobotsSettings` tolerates a missing or malformed value -- so this seed is
|
|
140
|
+
-- a convenience for the settings screen, not a correctness requirement for rendering.
|
|
141
|
+
--
|
|
142
|
+
-- ON CONFLICT DO NOTHING because an install that has already configured robots must
|
|
143
|
+
-- not have its rules reset by a replay of this migration.
|
|
144
|
+
INSERT INTO public.site_settings (key, value)
|
|
145
|
+
VALUES (
|
|
146
|
+
'seo_robots_settings',
|
|
147
|
+
'{"customRules": "", "isIndexingEnabled": true, "sitemapEnabled": true, "userAgentRules": [{"allow": ["/"], "disallow": [], "userAgent": "*"}]}'::jsonb
|
|
148
|
+
)
|
|
149
|
+
ON CONFLICT (key) DO NOTHING;
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
-- Restrict writes to the robots.txt settings row to ADMIN only.
|
|
2
|
+
--
|
|
3
|
+
-- `site_settings.seo_robots_settings` is the row that decides whether the whole site
|
|
4
|
+
-- is crawlable: app/robots.ts reads it on every /robots.txt hit and serves a blanket
|
|
5
|
+
-- `Disallow: /` when `isIndexingEnabled` is false. The CMS only offers that switch
|
|
6
|
+
-- under /cms/settings/seo, and `saveRobotsSettings` re-checks for ADMIN before it
|
|
7
|
+
-- writes — but RLS is the independent boundary, and it did not agree. The baseline
|
|
8
|
+
-- write policies let ADMIN *or* WRITER write any key outside the sensitive array, so
|
|
9
|
+
-- a WRITER holding a normal session could PATCH this row through PostgREST and take
|
|
10
|
+
-- the entire site out of Google. That failure is quiet (nothing in the CMS shows it),
|
|
11
|
+
-- slow to notice (search traffic decays over weeks) and hard to attribute after the
|
|
12
|
+
-- fact, which is why the database has to refuse it rather than trusting the one
|
|
13
|
+
-- server action that happens to guard it today.
|
|
14
|
+
--
|
|
15
|
+
-- The SELECT policy is deliberately NOT touched. This key must stay anon-READABLE:
|
|
16
|
+
-- app/robots.ts reads it with the anon (SSG) client on every crawl, and adding the key
|
|
17
|
+
-- to the read policy's sensitive array would make robots.txt fall back to its
|
|
18
|
+
-- permissive defaults for every crawler. Only INSERT/UPDATE/DELETE move to ADMIN-only,
|
|
19
|
+
-- matching the UI boundary — exactly the shape migration 00000000000011 used for
|
|
20
|
+
-- language_detection_settings, which is anon-readable for the same reason.
|
|
21
|
+
--
|
|
22
|
+
-- Every key already present in each policy's array is preserved (dropping one would
|
|
23
|
+
-- silently widen write access back to WRITER for that key); `seo_robots_settings` is
|
|
24
|
+
-- appended to the three write policies only.
|
|
25
|
+
|
|
26
|
+
DROP POLICY IF EXISTS site_settings_insert_policy ON public.site_settings;
|
|
27
|
+
CREATE POLICY site_settings_insert_policy ON public.site_settings FOR INSERT TO authenticated WITH CHECK ((((key <> ALL (ARRAY['cortex_ai_openrouter_api_key'::text, 'bot_protection_secret'::text, 'email_secret'::text, 'payment_secret'::text, 'language_detection_settings'::text, 'cortex_ai_pexels_api_key'::text, 'cortex_ai_unsplash_access_key'::text, 'cortex_ai_mcp_settings'::text, 'seo_robots_settings'::text])) AND (( SELECT public.get_current_user_role() AS get_current_user_role) = ANY (ARRAY['ADMIN'::public.user_role, 'WRITER'::public.user_role]))) OR ((key = ANY (ARRAY['cortex_ai_openrouter_api_key'::text, 'bot_protection_secret'::text, 'email_secret'::text, 'payment_secret'::text, 'language_detection_settings'::text, 'cortex_ai_pexels_api_key'::text, 'cortex_ai_unsplash_access_key'::text, 'cortex_ai_mcp_settings'::text, 'seo_robots_settings'::text])) AND (( SELECT public.get_current_user_role() AS get_current_user_role) = 'ADMIN'::public.user_role))));
|
|
28
|
+
|
|
29
|
+
DROP POLICY IF EXISTS site_settings_update_policy ON public.site_settings;
|
|
30
|
+
CREATE POLICY site_settings_update_policy ON public.site_settings FOR UPDATE TO authenticated USING ((((key <> ALL (ARRAY['cortex_ai_openrouter_api_key'::text, 'bot_protection_secret'::text, 'email_secret'::text, 'payment_secret'::text, 'language_detection_settings'::text, 'cortex_ai_pexels_api_key'::text, 'cortex_ai_unsplash_access_key'::text, 'cortex_ai_mcp_settings'::text, 'seo_robots_settings'::text])) AND (( SELECT public.get_current_user_role() AS get_current_user_role) = ANY (ARRAY['ADMIN'::public.user_role, 'WRITER'::public.user_role]))) OR ((key = ANY (ARRAY['cortex_ai_openrouter_api_key'::text, 'bot_protection_secret'::text, 'email_secret'::text, 'payment_secret'::text, 'language_detection_settings'::text, 'cortex_ai_pexels_api_key'::text, 'cortex_ai_unsplash_access_key'::text, 'cortex_ai_mcp_settings'::text, 'seo_robots_settings'::text])) AND (( SELECT public.get_current_user_role() AS get_current_user_role) = 'ADMIN'::public.user_role)))) WITH CHECK ((((key <> ALL (ARRAY['cortex_ai_openrouter_api_key'::text, 'bot_protection_secret'::text, 'email_secret'::text, 'payment_secret'::text, 'language_detection_settings'::text, 'cortex_ai_pexels_api_key'::text, 'cortex_ai_unsplash_access_key'::text, 'cortex_ai_mcp_settings'::text, 'seo_robots_settings'::text])) AND (( SELECT public.get_current_user_role() AS get_current_user_role) = ANY (ARRAY['ADMIN'::public.user_role, 'WRITER'::public.user_role]))) OR ((key = ANY (ARRAY['cortex_ai_openrouter_api_key'::text, 'bot_protection_secret'::text, 'email_secret'::text, 'payment_secret'::text, 'language_detection_settings'::text, 'cortex_ai_pexels_api_key'::text, 'cortex_ai_unsplash_access_key'::text, 'cortex_ai_mcp_settings'::text, 'seo_robots_settings'::text])) AND (( SELECT public.get_current_user_role() AS get_current_user_role) = 'ADMIN'::public.user_role))));
|
|
31
|
+
|
|
32
|
+
DROP POLICY IF EXISTS site_settings_delete_policy ON public.site_settings;
|
|
33
|
+
CREATE POLICY site_settings_delete_policy ON public.site_settings FOR DELETE TO authenticated USING ((((key <> ALL (ARRAY['cortex_ai_openrouter_api_key'::text, 'bot_protection_secret'::text, 'email_secret'::text, 'payment_secret'::text, 'language_detection_settings'::text, 'cortex_ai_pexels_api_key'::text, 'cortex_ai_unsplash_access_key'::text, 'cortex_ai_mcp_settings'::text, 'seo_robots_settings'::text])) AND (( SELECT public.get_current_user_role() AS get_current_user_role) = ANY (ARRAY['ADMIN'::public.user_role, 'WRITER'::public.user_role]))) OR ((key = ANY (ARRAY['cortex_ai_openrouter_api_key'::text, 'bot_protection_secret'::text, 'email_secret'::text, 'payment_secret'::text, 'language_detection_settings'::text, 'cortex_ai_pexels_api_key'::text, 'cortex_ai_unsplash_access_key'::text, 'cortex_ai_mcp_settings'::text, 'seo_robots_settings'::text])) AND (( SELECT public.get_current_user_role() AS get_current_user_role) = 'ADMIN'::public.user_role))));
|
|
34
|
+
|
|
35
|
+
-- Forward-only and idempotent.
|