@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.
Files changed (25) hide show
  1. package/lib/supabase/server.d.ts +31 -1
  2. package/lib/supabase/types.d.ts +41 -11
  3. package/package.json +1 -1
  4. package/supabase/migrations/00000000000003_baseline_seed.sql +468 -468
  5. package/supabase/migrations/00000000000006_home_live_demo_promo.sql +67 -67
  6. package/supabase/migrations/00000000000007_home_live_demo_promo_copy_fix.sql +49 -49
  7. package/supabase/migrations/00000000000009_home_live_demo_promo_contrast.sql +52 -52
  8. package/supabase/migrations/00000000000014_site_themes.sql +254 -254
  9. package/supabase/migrations/00000000000015_scheduled_publishing.sql +43 -43
  10. package/supabase/migrations/00000000000016_product_revisions_and_revision_baseline.sql +310 -310
  11. package/supabase/migrations/00000000000017_cortex_ai_mcp_server.sql +91 -91
  12. package/supabase/migrations/00000000000018_site_scripts.sql +96 -96
  13. package/supabase/migrations/00000000000019_site_script_revisions.sql +85 -85
  14. package/supabase/migrations/00000000000020_updating_article.sql +162 -162
  15. package/supabase/migrations/00000000000021_updating_article_git_merge.sql +91 -91
  16. package/supabase/migrations/00000000000022_updating_article_accuracy.sql +94 -94
  17. package/supabase/migrations/00000000000023_updating_article_layout_not_host.sql +87 -87
  18. package/supabase/migrations/00000000000024_updating_article_newline_fix.sql +70 -70
  19. package/supabase/migrations/00000000000025_rebrand_nextblock_dev.sql +53 -53
  20. package/supabase/migrations/00000000000026_product_inquiries.sql +118 -118
  21. package/supabase/migrations/00000000000027_message_threads.sql +391 -391
  22. package/supabase/migrations/00000000000028_interaction_replies.sql +86 -86
  23. package/supabase/migrations/00000000000029_form_endpoints_default_empty.sql +52 -52
  24. package/supabase/migrations/00000000000030_seo_redirects_and_robots.sql +149 -0
  25. 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.