create-nextblock 0.15.9 → 0.16.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 (52) hide show
  1. package/package.json +1 -1
  2. package/templates/nextblock-template/app/api/ai/seo/alt-text/route.ts +221 -0
  3. package/templates/nextblock-template/app/api/ai/seo/metadata/route.ts +186 -0
  4. package/templates/nextblock-template/app/api/cron/reset-sandbox/sandboxResetSql.ts +193 -1
  5. package/templates/nextblock-template/app/cms/CmsClientLayout.tsx +7 -1
  6. package/templates/nextblock-template/app/cms/blocks/components/BlockEditorArea.tsx +27 -0
  7. package/templates/nextblock-template/app/cms/blocks/editors/ImageBlockEditor.tsx +406 -229
  8. package/templates/nextblock-template/app/cms/blocks/editors/TextBlockEditor.tsx +171 -6
  9. package/templates/nextblock-template/app/cms/components/FeatureImageField.tsx +254 -245
  10. package/templates/nextblock-template/app/cms/media/components/MediaEditForm.tsx +177 -2
  11. package/templates/nextblock-template/app/cms/pages/[id]/edit/EditPageClient.tsx +28 -0
  12. package/templates/nextblock-template/app/cms/pages/components/PageForm.tsx +649 -406
  13. package/templates/nextblock-template/app/cms/posts/[id]/edit/page.tsx +33 -0
  14. package/templates/nextblock-template/app/cms/posts/components/PostForm.tsx +618 -383
  15. package/templates/nextblock-template/app/cms/settings/seo/RedirectsCard.tsx +514 -0
  16. package/templates/nextblock-template/app/cms/settings/seo/RobotsCard.tsx +529 -0
  17. package/templates/nextblock-template/app/cms/settings/seo/SeoSettingsClient.tsx +57 -0
  18. package/templates/nextblock-template/app/cms/settings/seo/actions.ts +448 -0
  19. package/templates/nextblock-template/app/cms/settings/seo/mappers.ts +93 -0
  20. package/templates/nextblock-template/app/cms/settings/seo/page.tsx +46 -0
  21. package/templates/nextblock-template/app/cms/settings/seo/require-admin.ts +47 -0
  22. package/templates/nextblock-template/app/layout.tsx +1 -1
  23. package/templates/nextblock-template/app/robots.ts +123 -0
  24. package/templates/nextblock-template/app/sitemap.ts +1 -1
  25. package/templates/nextblock-template/components/seo/GenerateMetaButton.tsx +137 -0
  26. package/templates/nextblock-template/components/seo/PageSeoAuditSection.tsx +244 -0
  27. package/templates/nextblock-template/components/seo/SeoAuditPanel.tsx +749 -0
  28. package/templates/nextblock-template/components/seo/SeoIssueList.tsx +195 -0
  29. package/templates/nextblock-template/components/seo/SeoScoreDial.tsx +144 -0
  30. package/templates/nextblock-template/components/seo/SocialPreview.tsx +243 -0
  31. package/templates/nextblock-template/components/seo/SocialPreviewDialog.tsx +110 -0
  32. package/templates/nextblock-template/lib/cortex-ai/alt-text-request.ts +86 -0
  33. package/templates/nextblock-template/lib/cortex-ai/sandbox-headers.ts +60 -0
  34. package/templates/nextblock-template/lib/seo/alt-text-write-back.test.ts +154 -0
  35. package/templates/nextblock-template/lib/seo/alt-text-write-back.ts +109 -0
  36. package/templates/nextblock-template/lib/seo/block-content.ts +123 -0
  37. package/templates/nextblock-template/lib/seo/fix-prompts.test.ts +242 -0
  38. package/templates/nextblock-template/lib/seo/fix-prompts.ts +204 -0
  39. package/templates/nextblock-template/lib/seo/page-audit-context.tsx +140 -0
  40. package/templates/nextblock-template/lib/seo/page-document.test.ts +350 -0
  41. package/templates/nextblock-template/lib/seo/page-document.ts +412 -0
  42. package/templates/nextblock-template/lib/seo/redirect-store.test.ts +479 -0
  43. package/templates/nextblock-template/lib/seo/redirect-store.ts +466 -0
  44. package/templates/nextblock-template/lib/seo/robots-settings-signature.test.ts +102 -0
  45. package/templates/nextblock-template/lib/seo/robots-settings-signature.ts +41 -0
  46. package/templates/nextblock-template/lib/seo/robots-txt.test.ts +370 -0
  47. package/templates/nextblock-template/lib/seo/robots-txt.ts +510 -0
  48. package/templates/nextblock-template/lib/setup/migrations-bundle.ts +10 -0
  49. package/templates/nextblock-template/next-env.d.ts +2 -2
  50. package/templates/nextblock-template/package.json +1 -1
  51. package/templates/nextblock-template/proxy.ts +240 -20
  52. package/templates/nextblock-template/app/robots.txt/route.ts +0 -32
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-nextblock",
3
- "version": "0.15.9",
3
+ "version": "0.16.1",
4
4
  "description": "Scaffold a production-ready NextBlock CMS project — the open-source, full-stack AI-native CMS for Next.js 16, Supabase, and Tailwind CSS.",
5
5
  "main": "index.js",
6
6
  "bin": {
@@ -0,0 +1,221 @@
1
+ import { NextResponse } from 'next/server';
2
+
3
+ import { createClient, verifyPackageOnline } from '@nextblock-cms/db/server';
4
+
5
+ import { generateCortexAiAltText } from '@nextblock-cms/cortex';
6
+ import {
7
+ safeParseCortexAiModelSelection,
8
+ summarizeCortexAiRoutingError,
9
+ } from '@nextblock-cms/cortex';
10
+
11
+ import { z } from '../../../../../lib/zod-config';
12
+
13
+ export const dynamic = 'force-dynamic';
14
+
15
+ type SupabaseServerClient = ReturnType<typeof createClient>;
16
+
17
+ /**
18
+ * The request contract for the alt-text endpoint.
19
+ *
20
+ * `z.strictObject` rather than `z.object`, for the same reason the rest of the
21
+ * Cortex surface uses it: an unknown key is almost always a caller that has drifted
22
+ * from the contract — a stale client still sending `mediaId`, or a hand-rolled
23
+ * fetch that guessed at the field names — and silently discarding it produces a
24
+ * generation that ignores half of what the caller asked for. Rejecting loudly at
25
+ * the boundary turns that into a 400 the developer can actually read.
26
+ *
27
+ * `maxLength` is validated only for shape (a positive integer), not for range. The
28
+ * engine in `ai-vision.ts` deliberately CLAMPS an out-of-range budget into its own
29
+ * 20..1000 bounds instead of throwing, precisely so that a careless call site
30
+ * cannot break a media upload flow; duplicating those bounds here would both
31
+ * contradict that decision and leave two copies of the same numbers free to drift
32
+ * apart.
33
+ */
34
+ const altTextRequestSchema = z.strictObject({
35
+ context: z.string().max(2000).optional(),
36
+ imageUrl: z.string().min(1),
37
+ maxLength: z.number().int().positive().optional(),
38
+ });
39
+
40
+ async function requireCmsWriterAccess(supabase: SupabaseServerClient) {
41
+ const {
42
+ data: { user },
43
+ error: userError,
44
+ } = await supabase.auth.getUser();
45
+
46
+ if (userError || !user) {
47
+ return null;
48
+ }
49
+
50
+ const { data: profile, error: profileError } = await supabase
51
+ .from('profiles')
52
+ .select('role')
53
+ .eq('id', user.id)
54
+ .single();
55
+
56
+ if (profileError || !profile || !['ADMIN', 'WRITER'].includes(profile.role)) {
57
+ return null;
58
+ }
59
+
60
+ return { userId: user.id };
61
+ }
62
+
63
+ function jsonError(message: string, status: number) {
64
+ return NextResponse.json({ error: message }, { status });
65
+ }
66
+
67
+ /**
68
+ * Return a human-readable reason the supplied image URL cannot be sent to a vision
69
+ * model, or `null` when it is usable.
70
+ *
71
+ * This duplicates a check that `generateCortexAiAltText` also performs, and it does
72
+ * so deliberately. The engine's copy throws, which would surface here as a 500 —
73
+ * the status code that means "the server broke" — when the truth is that the caller
74
+ * sent an unusable value and needs a 400 telling them exactly that. Running the
75
+ * check first gives the common mistake the right status and an actionable message,
76
+ * while the engine keeps its own guard for every other call site.
77
+ *
78
+ * The mistake being guarded against is concrete rather than hypothetical:
79
+ * `resolveMediaUrl()` returns a bare `/${objectKey}` whenever no R2 base URL is
80
+ * configured, so an install that has not finished its media setup hands every
81
+ * caller a site-relative path. Because the Cortex OpenRouter provider is created
82
+ * without `supportedUrls`, the AI SDK does not forward a link to the model — it
83
+ * downloads the image server-side and inlines it as base64 — so a relative path
84
+ * would fail inside the SDK's own fetch, once per model in the fallback chain, with
85
+ * an error that never mentions the actual problem.
86
+ *
87
+ * The scheme test is strict rather than allowlist-based: any absolute URL is
88
+ * acceptable as long as it is http or https, but `file:`, `data:`, and `blob:` are
89
+ * refused. Those are the schemes that would either read the server's own disk or
90
+ * push an unbounded inline payload through every model in the fallback chain.
91
+ */
92
+ function describeUnusableImageUrl(imageUrl: string): string | null {
93
+ const trimmed = imageUrl.trim();
94
+
95
+ if (!trimmed) {
96
+ return 'An image URL is required to generate alt text.';
97
+ }
98
+
99
+ let parsed: URL;
100
+
101
+ try {
102
+ parsed = new URL(trimmed);
103
+ } catch {
104
+ return `Alt text generation needs an absolute http(s) image URL, but received "${trimmed}". A storage key or site-relative path has to be resolved to a publicly fetchable URL first, because the model provider downloads the image server-side rather than following the link.`;
105
+ }
106
+
107
+ if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
108
+ return `Alt text generation needs an http(s) image URL, but received the "${parsed.protocol}" scheme.`;
109
+ }
110
+
111
+ return null;
112
+ }
113
+
114
+ export async function POST(request: Request) {
115
+ try {
116
+ const supabase = createClient();
117
+ const access = await requireCmsWriterAccess(supabase);
118
+
119
+ if (!access) {
120
+ return jsonError('You do not have permission to generate image alt text.', 403);
121
+ }
122
+
123
+ // The package gate that /api/ai/generate-blocks is missing (docs/08 follow-up
124
+ // #6). Alt-text generation is a paid Cortex AI feature that burns either the
125
+ // operator's own OpenRouter credit or the shared house key, so an install
126
+ // without an active `cortex-ai` activation must not reach a model at all. The
127
+ // package id is `cortex-ai`; `ai` is not a package id anywhere in this system.
128
+ const isCortexAiActive = await verifyPackageOnline('cortex-ai');
129
+
130
+ if (!isCortexAiActive) {
131
+ return jsonError('NextBlock Cortex AI is not active for this workspace.', 403);
132
+ }
133
+
134
+ const body = await request.json().catch(() => null);
135
+ const parsedRequest = altTextRequestSchema.safeParse(body);
136
+
137
+ if (!parsedRequest.success) {
138
+ return jsonError('Invalid Cortex AI alt text request.', 400);
139
+ }
140
+
141
+ const unusableImageUrlReason = describeUnusableImageUrl(parsedRequest.data.imageUrl);
142
+
143
+ if (unusableImageUrlReason) {
144
+ return jsonError(unusableImageUrlReason, 400);
145
+ }
146
+
147
+ // Sandbox installs have no server-side OpenRouter credential at all: the demo
148
+ // visitor pastes their own key into the browser, it lives in localStorage under
149
+ // `cortex_ai_sandbox_openrouter_api_key`, and it rides along on these headers.
150
+ // The env guard matters as much as the headers do — outside a sandbox the
151
+ // server must never accept a caller-supplied credential, because that would let
152
+ // any authenticated writer redirect generation through a key the operator never
153
+ // chose.
154
+ const sandboxKey =
155
+ process.env.NEXT_PUBLIC_IS_SANDBOX === 'true'
156
+ ? request.headers.get('x-sandbox-openrouter-key')
157
+ : null;
158
+ const sandboxModelRaw =
159
+ process.env.NEXT_PUBLIC_IS_SANDBOX === 'true'
160
+ ? request.headers.get('x-sandbox-openrouter-model')
161
+ : null;
162
+
163
+ let modelSelection = null;
164
+ if (sandboxModelRaw) {
165
+ try {
166
+ modelSelection = safeParseCortexAiModelSelection(JSON.parse(sandboxModelRaw));
167
+ } catch {
168
+ // Ignore malformed sandbox model headers.
169
+ }
170
+ }
171
+
172
+ // `generateCortexAiAltText` takes a requested model id rather than a whole
173
+ // stored selection, because its vision routing policy has to decide for itself
174
+ // whether an id is plausibly multimodal before it will route an image to it.
175
+ // The header is still parsed through `safeParseCortexAiModelSelection` so that
176
+ // a malformed value is discarded rather than forwarded as a bogus id, and the
177
+ // id is only honoured alongside the sandbox key so that a caller-supplied model
178
+ // choice can never be applied to the operator's own credential.
179
+ const result = await generateCortexAiAltText({
180
+ apiKey: sandboxKey || undefined,
181
+ context: parsedRequest.data.context,
182
+ imageUrl: parsedRequest.data.imageUrl,
183
+ maxLength: parsedRequest.data.maxLength,
184
+ modelId: sandboxKey && modelSelection ? modelSelection.modelId : undefined,
185
+ });
186
+
187
+ return NextResponse.json(
188
+ {
189
+ altText: result.altText,
190
+ credentialSource: result.credentialSource,
191
+ modelId: result.modelId,
192
+ },
193
+ {
194
+ headers: {
195
+ 'x-cortex-ai-credential-source': result.credentialSource,
196
+ 'x-cortex-ai-model': result.modelId,
197
+ },
198
+ }
199
+ );
200
+ } catch (error) {
201
+ // A CortexAiRoutingError is recognised STRUCTURALLY rather than with
202
+ // `instanceof`. The class is defined in @nextblock-cms/cortex, which resolves to
203
+ // library source inside this monorepo but to a published package in a
204
+ // standalone install; when both shapes end up in one bundle the two class
205
+ // identities are not the same object and `instanceof` silently returns false,
206
+ // swallowing the per-model attempt log that is the only useful diagnostic for a
207
+ // routing failure.
208
+ if (error && typeof error === 'object' && 'attempts' in error) {
209
+ console.error(
210
+ '[Cortex AI] Failed to generate alt text after model attempts:',
211
+ JSON.stringify((error as { attempts: unknown }).attempts, null, 2)
212
+ );
213
+ }
214
+
215
+ console.error('[Cortex AI] Failed to generate alt text:', error);
216
+ return jsonError(
217
+ summarizeCortexAiRoutingError(error, 'Failed to generate image alt text.'),
218
+ 500
219
+ );
220
+ }
221
+ }
@@ -0,0 +1,186 @@
1
+ import { NextResponse } from 'next/server';
2
+
3
+ import { createClient, verifyPackageOnline } from '@nextblock-cms/db/server';
4
+
5
+ import { generateCortexAiSeoMetadata } from '@nextblock-cms/cortex';
6
+ import {
7
+ safeParseCortexAiModelSelection,
8
+ summarizeCortexAiRoutingError,
9
+ } from '@nextblock-cms/cortex';
10
+
11
+ import { z } from '../../../../../lib/zod-config';
12
+
13
+ export const dynamic = 'force-dynamic';
14
+
15
+ type SupabaseServerClient = ReturnType<typeof createClient>;
16
+
17
+ /**
18
+ * How much page copy this endpoint will accept in one request.
19
+ *
20
+ * The engine truncates the content to `CORTEX_AI_SEO_METADATA_CONTENT_BUDGET`
21
+ * (6000 characters) before it ever reaches a model, so anything beyond that is
22
+ * discarded rather than summarised. This ceiling is therefore not a content rule —
23
+ * it is a request-body bound, set far above the engine's budget so that a genuinely
24
+ * long post is never rejected with a confusing 400, but low enough that a runaway
25
+ * client cannot push megabytes of HTML through the JSON parser on every keystroke
26
+ * of an editor that generates metadata as you type.
27
+ */
28
+ const SEO_METADATA_CONTENT_REQUEST_LIMIT = 200_000;
29
+
30
+ /**
31
+ * The request contract for the metadata endpoint.
32
+ *
33
+ * Strict, for the reason every Cortex request schema is strict: an unrecognised key
34
+ * means the caller and this route disagree about the contract, and the failure mode
35
+ * of quietly dropping it is a generation that ignored the focus keyword or the
36
+ * locale the caller thought it had supplied. A 400 naming the mismatch is far
37
+ * cheaper to debug than metadata that is merely subtly wrong.
38
+ *
39
+ * Only `content` is required. Everything else is a hint the engine folds into the
40
+ * prompt when present and omits entirely when absent, which is why each optional
41
+ * field is bounded but never given a minimum beyond one character — an empty string
42
+ * from a form field that the user left blank should not fail the whole request, and
43
+ * the engine's own `?.trim() || null` normalisation already treats blank as absent.
44
+ */
45
+ const seoMetadataRequestSchema = z.strictObject({
46
+ content: z.string().min(1).max(SEO_METADATA_CONTENT_REQUEST_LIMIT),
47
+ focusKeyword: z.string().max(200).optional(),
48
+ locale: z.string().max(64).optional(),
49
+ siteTitle: z.string().max(200).optional(),
50
+ title: z.string().max(500).optional(),
51
+ });
52
+
53
+ async function requireCmsWriterAccess(supabase: SupabaseServerClient) {
54
+ const {
55
+ data: { user },
56
+ error: userError,
57
+ } = await supabase.auth.getUser();
58
+
59
+ if (userError || !user) {
60
+ return null;
61
+ }
62
+
63
+ const { data: profile, error: profileError } = await supabase
64
+ .from('profiles')
65
+ .select('role')
66
+ .eq('id', user.id)
67
+ .single();
68
+
69
+ if (profileError || !profile || !['ADMIN', 'WRITER'].includes(profile.role)) {
70
+ return null;
71
+ }
72
+
73
+ return { userId: user.id };
74
+ }
75
+
76
+ function jsonError(message: string, status: number) {
77
+ return NextResponse.json({ error: message }, { status });
78
+ }
79
+
80
+ export async function POST(request: Request) {
81
+ try {
82
+ const supabase = createClient();
83
+ const access = await requireCmsWriterAccess(supabase);
84
+
85
+ if (!access) {
86
+ return jsonError('You do not have permission to generate SEO metadata.', 403);
87
+ }
88
+
89
+ // The package gate that /api/ai/generate-blocks is missing (docs/08 follow-up
90
+ // #6). Metadata generation is a paid Cortex AI feature that burns either the
91
+ // operator's own OpenRouter credit or the shared house key, so an install
92
+ // without an active `cortex-ai` activation must not reach a model at all. The
93
+ // package id is `cortex-ai`; `ai` is not a package id anywhere in this system.
94
+ const isCortexAiActive = await verifyPackageOnline('cortex-ai');
95
+
96
+ if (!isCortexAiActive) {
97
+ return jsonError('NextBlock Cortex AI is not active for this workspace.', 403);
98
+ }
99
+
100
+ const body = await request.json().catch(() => null);
101
+ const parsedRequest = seoMetadataRequestSchema.safeParse(body);
102
+
103
+ if (!parsedRequest.success) {
104
+ return jsonError('Invalid Cortex AI SEO metadata request.', 400);
105
+ }
106
+
107
+ // Sandbox installs have no server-side OpenRouter credential at all: the demo
108
+ // visitor pastes their own key into the browser, it lives in localStorage under
109
+ // `cortex_ai_sandbox_openrouter_api_key`, and it rides along on these headers.
110
+ // The env guard matters as much as the headers do — outside a sandbox the
111
+ // server must never accept a caller-supplied credential, because that would let
112
+ // any authenticated writer redirect generation through a key the operator never
113
+ // chose.
114
+ const sandboxKey =
115
+ process.env.NEXT_PUBLIC_IS_SANDBOX === 'true'
116
+ ? request.headers.get('x-sandbox-openrouter-key')
117
+ : null;
118
+ const sandboxModelRaw =
119
+ process.env.NEXT_PUBLIC_IS_SANDBOX === 'true'
120
+ ? request.headers.get('x-sandbox-openrouter-model')
121
+ : null;
122
+
123
+ let modelSelection = null;
124
+ if (sandboxModelRaw) {
125
+ try {
126
+ modelSelection = safeParseCortexAiModelSelection(JSON.parse(sandboxModelRaw));
127
+ } catch {
128
+ // Ignore malformed sandbox model headers.
129
+ }
130
+ }
131
+
132
+ // `generateCortexAiSeoMetadata` accepts a requested model id rather than a
133
+ // whole stored selection; the header is still parsed through
134
+ // `safeParseCortexAiModelSelection` so that a malformed value is discarded
135
+ // instead of being forwarded as a bogus id. Note that the ordinary text routing
136
+ // policy will IGNORE the requested id whenever the credential came from the
137
+ // environment, which is exactly the intended behaviour here: a model choice is
138
+ // only ever honoured when it arrives with the key that will pay for it.
139
+ const result = await generateCortexAiSeoMetadata({
140
+ apiKey: sandboxKey || undefined,
141
+ content: parsedRequest.data.content,
142
+ focusKeyword: parsedRequest.data.focusKeyword,
143
+ locale: parsedRequest.data.locale,
144
+ modelId: sandboxKey && modelSelection ? modelSelection.modelId : undefined,
145
+ siteTitle: parsedRequest.data.siteTitle,
146
+ title: parsedRequest.data.title,
147
+ });
148
+
149
+ return NextResponse.json(
150
+ {
151
+ credentialSource: result.credentialSource,
152
+ metaDescription: result.metaDescription,
153
+ metaTitle: result.metaTitle,
154
+ modelId: result.modelId,
155
+ ogDescription: result.ogDescription,
156
+ ogTitle: result.ogTitle,
157
+ },
158
+ {
159
+ headers: {
160
+ 'x-cortex-ai-credential-source': result.credentialSource,
161
+ 'x-cortex-ai-model': result.modelId,
162
+ },
163
+ }
164
+ );
165
+ } catch (error) {
166
+ // A CortexAiRoutingError is recognised STRUCTURALLY rather than with
167
+ // `instanceof`. The class is defined in @nextblock-cms/cortex, which resolves to
168
+ // library source inside this monorepo but to a published package in a
169
+ // standalone install; when both shapes end up in one bundle the two class
170
+ // identities are not the same object and `instanceof` silently returns false,
171
+ // swallowing the per-model attempt log that is the only useful diagnostic for a
172
+ // routing failure.
173
+ if (error && typeof error === 'object' && 'attempts' in error) {
174
+ console.error(
175
+ '[Cortex AI] Failed to generate SEO metadata after model attempts:',
176
+ JSON.stringify((error as { attempts: unknown }).attempts, null, 2)
177
+ );
178
+ }
179
+
180
+ console.error('[Cortex AI] Failed to generate SEO metadata:', error);
181
+ return jsonError(
182
+ summarizeCortexAiRoutingError(error, 'Failed to generate SEO metadata.'),
183
+ 500
184
+ );
185
+ }
186
+ }
@@ -8028,6 +8028,196 @@ UPDATE public.site_settings
8028
8028
  AND lower(value->>'contactEmail') LIKE '%@example.%';
8029
8029
 
8030
8030
 
8031
+ -- >>> FROM: 00000000000030_seo_redirects_and_robots.sql <<<
8032
+ -- SEO engine: managed 301/302 redirects and operator-configurable robots directives.
8033
+ --
8034
+ -- Two unrelated-looking things ship in one migration because they are the same
8035
+ -- feature from an operator's point of view: the \`/cms/settings/seo\` screen is where
8036
+ -- someone goes to say "this URL moved" and "do not crawl that". Splitting them
8037
+ -- across two migrations would only mean two files that must always be applied
8038
+ -- together.
8039
+ --
8040
+ -- WHY A TABLE AND NOT next.config.js redirects(). Redirects are content, not
8041
+ -- configuration. An editor who renames a page's slug needs the old URL to keep
8042
+ -- working immediately, without a redeploy and without touching source control.
8043
+ -- That rules out the build-time array; it has to be data.
8044
+ --
8045
+ -- WHY THE PROXY READS THIS WITH THE ANON KEY. apps/nextblock/proxy.ts (Next 16's
8046
+ -- renamed middleware) resolves redirects before rendering, and it holds a Supabase
8047
+ -- client built from the anon key. So the public SELECT policy below is load-bearing:
8048
+ -- without an explicit \`TO authenticated, anon\` grant the proxy's lookup would return
8049
+ -- zero rows for every anonymous visitor -- silently, with no error -- and no redirect
8050
+ -- would ever fire. Only is_active rows are exposed, so a half-written rule is never
8051
+ -- live.
8052
+ --
8053
+ -- WHY status_code IS AN integer AND NOT AN ENUM. The same reasoning migration 27
8054
+ -- recorded for \`source\`: a Postgres enum cannot be extended and used inside the same
8055
+ -- transaction, which is exactly the scope of one migration file. A CHECK constraint
8056
+ -- is replaceable in a single statement. 301 and 302 are the only two values the
8057
+ -- admin UI offers; 307/308 are deliberately not exposed, because their
8058
+ -- method-preserving semantics surprise operators who just want "this page moved".
8059
+ --
8060
+ -- LOOP SAFETY is enforced in application code (wouldCreateLoop in
8061
+ -- @nextblock-cms/utils, called by the admin server actions) rather than by a
8062
+ -- constraint, because detecting a cycle requires walking the whole table and a CHECK
8063
+ -- constraint can only see one row. The self-redirect case IS cheap to check per row,
8064
+ -- so that one is a constraint -- it is the cycle operators actually hit.
8065
+ --
8066
+ -- SECURITY POSTURE. A redirect can send every visitor of a path to an arbitrary
8067
+ -- external origin, which makes this table an open-redirect surface and a phishing
8068
+ -- lever. Writes are therefore ADMIN-only -- WRITER is deliberately excluded, matching
8069
+ -- site_scripts rather than the content tables. Reads are public but limited to active
8070
+ -- rows. The robots settings live in site_settings, whose existing read policy is
8071
+ -- already public for non-secret keys; nothing here is secret.
8072
+ --
8073
+ -- Forward-only and idempotent.
8074
+
8075
+ CREATE TABLE IF NOT EXISTS public.cms_redirects (
8076
+ id uuid DEFAULT gen_random_uuid() NOT NULL,
8077
+ -- The incoming site-relative path to match, normalized by the application to a
8078
+ -- leading slash with no trailing slash (except root). Matching is exact: prefix
8079
+ -- and wildcard rules are deliberately not supported, because they are the usual
8080
+ -- way an operator builds an accidental loop.
8081
+ source_path text NOT NULL,
8082
+ -- Where to send the visitor. Either another site-relative path or a fully
8083
+ -- qualified https URL for an off-site move.
8084
+ destination_path text NOT NULL,
8085
+ -- 301 permanent (the SEO-meaningful one: search engines transfer ranking signals
8086
+ -- and browsers cache it aggressively) or 302 temporary.
8087
+ status_code integer DEFAULT 301 NOT NULL,
8088
+ is_active boolean DEFAULT true NOT NULL,
8089
+ created_at timestamp with time zone DEFAULT now() NOT NULL,
8090
+ updated_at timestamp with time zone DEFAULT now() NOT NULL,
8091
+ CONSTRAINT cms_redirects_pkey PRIMARY KEY (id),
8092
+ -- One rule per source. This UNIQUE constraint also provides the index the proxy
8093
+ -- lookup relies on, so no separate plain index on source_path is needed.
8094
+ CONSTRAINT cms_redirects_source_path_key UNIQUE (source_path),
8095
+ CONSTRAINT cms_redirects_status_code_check
8096
+ CHECK ((status_code = ANY (ARRAY[301, 302]))),
8097
+ -- A source is always a path on this site; accepting an absolute URL here would
8098
+ -- silently never match, since the proxy only ever compares pathnames.
8099
+ CONSTRAINT cms_redirects_source_path_check
8100
+ CHECK ((source_path ~ '^/')),
8101
+ -- A destination is either a site-relative path or an https URL. Plain http is
8102
+ -- refused so a redirect can never downgrade a visitor to cleartext.
8103
+ CONSTRAINT cms_redirects_destination_path_check
8104
+ CHECK (((destination_path ~ '^/') OR (destination_path ~ '^https://'))),
8105
+ -- The one cycle a single row can express, and the one operators actually create.
8106
+ CONSTRAINT cms_redirects_no_self_redirect_check
8107
+ CHECK ((source_path <> destination_path))
8108
+ );
8109
+
8110
+ COMMENT ON TABLE public.cms_redirects IS
8111
+ '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.';
8112
+ COMMENT ON COLUMN public.cms_redirects.source_path IS
8113
+ 'Exact site-relative path to match, normalized to a leading slash and no trailing slash (except root). No wildcards, by design.';
8114
+ COMMENT ON COLUMN public.cms_redirects.destination_path IS
8115
+ 'Site-relative path or absolute https URL to send the visitor to.';
8116
+ COMMENT ON COLUMN public.cms_redirects.status_code IS
8117
+ '301 permanent or 302 temporary. 307/308 are not offered by the admin UI.';
8118
+ COMMENT ON COLUMN public.cms_redirects.is_active IS
8119
+ 'Only active rows are readable by anon, and only active rows are matched by the proxy.';
8120
+
8121
+ -- The proxy loads the whole active set once per cache window rather than querying per
8122
+ -- request, so the hot query is "all active rows" and this partial index is what serves
8123
+ -- it. Carrying source_path in the index keeps that read index-only.
8124
+ CREATE INDEX IF NOT EXISTS cms_redirects_active_source_idx
8125
+ ON public.cms_redirects USING btree (source_path) WHERE (is_active);
8126
+
8127
+ DROP TRIGGER IF EXISTS set_cms_redirects_updated_at ON public.cms_redirects;
8128
+ CREATE TRIGGER set_cms_redirects_updated_at
8129
+ BEFORE UPDATE ON public.cms_redirects
8130
+ FOR EACH ROW EXECUTE FUNCTION public.set_current_timestamp_updated_at();
8131
+
8132
+ ALTER TABLE public.cms_redirects ENABLE ROW LEVEL SECURITY;
8133
+
8134
+ GRANT ALL ON TABLE public.cms_redirects TO anon;
8135
+ GRANT ALL ON TABLE public.cms_redirects TO authenticated;
8136
+ GRANT ALL ON TABLE public.cms_redirects TO service_role;
8137
+
8138
+ -- The proxy runs as anon for a logged-out visitor, which is the overwhelming majority
8139
+ -- of traffic and the only traffic redirects really matter for. Without this policy the
8140
+ -- lookup returns zero rows and the feature is silently dead.
8141
+ DROP POLICY IF EXISTS "Public read active redirects" ON public.cms_redirects;
8142
+ CREATE POLICY "Public read active redirects" ON public.cms_redirects
8143
+ FOR SELECT TO authenticated, anon USING (is_active);
8144
+
8145
+ DROP POLICY IF EXISTS "Admins read all redirects" ON public.cms_redirects;
8146
+ CREATE POLICY "Admins read all redirects" ON public.cms_redirects
8147
+ FOR SELECT TO authenticated
8148
+ USING (((SELECT public.get_current_user_role()) = 'ADMIN'::public.user_role));
8149
+
8150
+ DROP POLICY IF EXISTS "Admins insert redirects" ON public.cms_redirects;
8151
+ CREATE POLICY "Admins insert redirects" ON public.cms_redirects
8152
+ FOR INSERT TO authenticated
8153
+ WITH CHECK (((SELECT public.get_current_user_role()) = 'ADMIN'::public.user_role));
8154
+
8155
+ DROP POLICY IF EXISTS "Admins update redirects" ON public.cms_redirects;
8156
+ CREATE POLICY "Admins update redirects" ON public.cms_redirects
8157
+ FOR UPDATE TO authenticated
8158
+ USING (((SELECT public.get_current_user_role()) = 'ADMIN'::public.user_role))
8159
+ WITH CHECK (((SELECT public.get_current_user_role()) = 'ADMIN'::public.user_role));
8160
+
8161
+ DROP POLICY IF EXISTS "Admins delete redirects" ON public.cms_redirects;
8162
+ CREATE POLICY "Admins delete redirects" ON public.cms_redirects
8163
+ FOR DELETE TO authenticated
8164
+ USING (((SELECT public.get_current_user_role()) = 'ADMIN'::public.user_role));
8165
+
8166
+ -- Robots directives live in site_settings rather than a table of their own: there is
8167
+ -- exactly one robots.txt per install, so a dedicated table would only ever hold one
8168
+ -- row, and site_settings already carries the public-read / staff-write policy this
8169
+ -- needs. The stored shape matches \`RobotsSettings\` in @nextblock-cms/utils, and
8170
+ -- \`normalizeRobotsSettings\` tolerates a missing or malformed value -- so this seed is
8171
+ -- a convenience for the settings screen, not a correctness requirement for rendering.
8172
+ --
8173
+ -- ON CONFLICT DO NOTHING because an install that has already configured robots must
8174
+ -- not have its rules reset by a replay of this migration.
8175
+ INSERT INTO public.site_settings (key, value)
8176
+ VALUES (
8177
+ 'seo_robots_settings',
8178
+ '{"customRules": "", "isIndexingEnabled": true, "sitemapEnabled": true, "userAgentRules": [{"allow": ["/"], "disallow": [], "userAgent": "*"}]}'::jsonb
8179
+ )
8180
+ ON CONFLICT (key) DO NOTHING;
8181
+
8182
+
8183
+ -- >>> FROM: 00000000000031_seo_robots_settings_admin_only.sql <<<
8184
+ -- Restrict writes to the robots.txt settings row to ADMIN only.
8185
+ --
8186
+ -- \`site_settings.seo_robots_settings\` is the row that decides whether the whole site
8187
+ -- is crawlable: app/robots.ts reads it on every /robots.txt hit and serves a blanket
8188
+ -- \`Disallow: /\` when \`isIndexingEnabled\` is false. The CMS only offers that switch
8189
+ -- under /cms/settings/seo, and \`saveRobotsSettings\` re-checks for ADMIN before it
8190
+ -- writes — but RLS is the independent boundary, and it did not agree. The baseline
8191
+ -- write policies let ADMIN *or* WRITER write any key outside the sensitive array, so
8192
+ -- a WRITER holding a normal session could PATCH this row through PostgREST and take
8193
+ -- the entire site out of Google. That failure is quiet (nothing in the CMS shows it),
8194
+ -- slow to notice (search traffic decays over weeks) and hard to attribute after the
8195
+ -- fact, which is why the database has to refuse it rather than trusting the one
8196
+ -- server action that happens to guard it today.
8197
+ --
8198
+ -- The SELECT policy is deliberately NOT touched. This key must stay anon-READABLE:
8199
+ -- app/robots.ts reads it with the anon (SSG) client on every crawl, and adding the key
8200
+ -- to the read policy's sensitive array would make robots.txt fall back to its
8201
+ -- permissive defaults for every crawler. Only INSERT/UPDATE/DELETE move to ADMIN-only,
8202
+ -- matching the UI boundary — exactly the shape migration 00000000000011 used for
8203
+ -- language_detection_settings, which is anon-readable for the same reason.
8204
+ --
8205
+ -- Every key already present in each policy's array is preserved (dropping one would
8206
+ -- silently widen write access back to WRITER for that key); \`seo_robots_settings\` is
8207
+ -- appended to the three write policies only.
8208
+
8209
+ DROP POLICY IF EXISTS site_settings_insert_policy ON public.site_settings;
8210
+ 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))));
8211
+
8212
+ DROP POLICY IF EXISTS site_settings_update_policy ON public.site_settings;
8213
+ 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))));
8214
+
8215
+ DROP POLICY IF EXISTS site_settings_delete_policy ON public.site_settings;
8216
+ 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))));
8217
+
8218
+ -- Forward-only and idempotent.
8219
+
8220
+
8031
8221
  -- Step D: Record the applied migrations in history (truncated in Step B) so
8032
8222
  -- \`npm run db:migrate:check\` reports up to date instead of listing every file as pending.
8033
8223
  INSERT INTO supabase_migrations.schema_migrations (version, name) VALUES
@@ -8060,7 +8250,9 @@ UPDATE public.site_settings
8060
8250
  ('00000000000026', 'product_inquiries'),
8061
8251
  ('00000000000027', 'message_threads'),
8062
8252
  ('00000000000028', 'interaction_replies'),
8063
- ('00000000000029', 'form_endpoints_default_empty')
8253
+ ('00000000000029', 'form_endpoints_default_empty'),
8254
+ ('00000000000030', 'seo_redirects_and_robots'),
8255
+ ('00000000000031', 'seo_robots_settings_admin_only')
8064
8256
  ON CONFLICT (version) DO NOTHING;
8065
8257
 
8066
8258
  -- Step E: Anchor preserved profiles
@@ -9,7 +9,7 @@ import {
9
9
  LayoutDashboard, FileText, PenTool, Users, Settings, ChevronRight, LogOut, Menu, ListTree, Image as ImageIconLucide, X, Languages as LanguagesIconLucide, MessageSquare,
10
10
  Copyright as CopyrightIcon, ShoppingBag, ListOrdered, CreditCard, Package, Coins, MessageSquareText,
11
11
  ExternalLink, Paintbrush, Brain, TicketPercent, ShieldAlert, Folder, DatabaseBackup, Boxes, Tag,
12
- ShieldCheck, Code2, Cookie, LineChart, Mail, UserPlus, SlidersHorizontal,
12
+ ShieldCheck, Code2, Cookie, LineChart, Mail, UserPlus, SlidersHorizontal, Search,
13
13
  } from "lucide-react"
14
14
  import TwoFactorReminderBanner from "./components/TwoFactorReminderBanner"
15
15
  import SystemAlertsBanner, { type SystemAlertItem } from "./components/SystemAlertsBanner"
@@ -249,6 +249,9 @@ export default function CmsClientLayout({
249
249
  else if (pathname.startsWith("/cms/settings/copyright")) pageTitle = "Copyright Settings";
250
250
  else if (pathname.startsWith("/cms/settings/global-css")) pageTitle = "Themes & CSS";
251
251
  else if (pathname.startsWith("/cms/settings/site-scripts")) pageTitle = "Site Scripts";
252
+ // Must stay ABOVE the generic "/cms/settings" arm below: this chain is
253
+ // first-match-wins, so anything placed after that fallback is dead code.
254
+ else if (pathname.startsWith("/cms/settings/seo")) pageTitle = "SEO & Redirects";
252
255
  else if (pathname.startsWith("/cms/settings/extra-translations")) pageTitle = "Extra Translations";
253
256
  else if (pathname.startsWith("/cms/settings/backup-restore")) pageTitle = "Backup And Restore";
254
257
  else if (pathname.startsWith("/cms/settings/currencies")) pageTitle = "Currency Settings";
@@ -455,6 +458,9 @@ export default function CmsClientLayout({
455
458
  <NavItem href="/cms/settings/google-analytics" icon={LineChart} isActive={pathname.startsWith("/cms/settings/google-analytics")} adminOnly isAdmin={isAdmin} onClick={closeSidebarOnMobile}>
456
459
  Google Analytics
457
460
  </NavItem>
461
+ <NavItem href="/cms/settings/seo" icon={Search} isActive={pathname.startsWith("/cms/settings/seo")} adminOnly isAdmin={isAdmin} onClick={closeSidebarOnMobile}>
462
+ SEO &amp; Redirects
463
+ </NavItem>
458
464
  <NavItem href="/cms/settings/extra-translations" icon={MessageSquare} isActive={pathname.startsWith("/cms/settings/extra-translations")} adminOnly isAdmin={isAdmin} onClick={closeSidebarOnMobile}>
459
465
  Extra Translations
460
466
  </NavItem>