create-nextblock 0.15.2 → 0.15.4
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/package.json +1 -1
- package/templates/nextblock-template/app/cms/settings/cortex-ai/{StoredCortexAiSettingsClient.tsx → CortexAiSettingsClient.tsx} +948 -652
- package/templates/nextblock-template/app/cms/settings/cortex-ai/McpServerSettingsCard.tsx +49 -5
- package/templates/nextblock-template/app/cms/settings/cortex-ai/mcp-actions.ts +25 -0
- package/templates/nextblock-template/app/cms/settings/cortex-ai/page.tsx +28 -47
- package/templates/nextblock-template/docs/08-NEXTBLOCK-CORTEX-AI-ARCHITECTURE.md +27 -0
- package/templates/nextblock-template/package.json +1 -1
- package/templates/nextblock-template/tools/update.mjs +18 -0
- package/templates/nextblock-template/app/cms/settings/cortex-ai/SandboxCortexAiSettingsClient.tsx +0 -688
|
@@ -21,7 +21,9 @@ import {
|
|
|
21
21
|
AlertTriangle,
|
|
22
22
|
Check,
|
|
23
23
|
Copy,
|
|
24
|
+
Info,
|
|
24
25
|
KeyRound,
|
|
26
|
+
Lock,
|
|
25
27
|
Plug,
|
|
26
28
|
Plus,
|
|
27
29
|
Trash2,
|
|
@@ -43,6 +45,18 @@ type McpServerSettingsCardProps = {
|
|
|
43
45
|
localMcpUrl: string;
|
|
44
46
|
/** The publicly reachable endpoint, derived from NEXT_PUBLIC_URL. */
|
|
45
47
|
mcpUrl: string;
|
|
48
|
+
/**
|
|
49
|
+
* Locks every control that writes (the toggles, minting, revoking) while leaving
|
|
50
|
+
* the endpoint, the client snippets, and the copy buttons fully usable.
|
|
51
|
+
*
|
|
52
|
+
* Used by the shared sandbox: a visitor must still be able to see that MCP access
|
|
53
|
+
* exists and what connecting looks like, but the settings row and the token table
|
|
54
|
+
* belong to every other visitor too. Hiding the card instead — which is what the
|
|
55
|
+
* sandbox did before this — taught evaluators that NextBlock had no MCP support.
|
|
56
|
+
*/
|
|
57
|
+
readOnly?: boolean;
|
|
58
|
+
/** Explains the lock. Required in spirit whenever `readOnly` is set. */
|
|
59
|
+
readOnlyNotice?: string;
|
|
46
60
|
tokens: McpAccessTokenSummary[];
|
|
47
61
|
};
|
|
48
62
|
|
|
@@ -96,6 +110,8 @@ export function McpServerSettingsCard({
|
|
|
96
110
|
enabled,
|
|
97
111
|
localMcpUrl,
|
|
98
112
|
mcpUrl,
|
|
113
|
+
readOnly = false,
|
|
114
|
+
readOnlyNotice,
|
|
99
115
|
tokens,
|
|
100
116
|
}: McpServerSettingsCardProps) {
|
|
101
117
|
const router = useRouter();
|
|
@@ -213,7 +229,12 @@ export function McpServerSettingsCard({
|
|
|
213
229
|
? `claude mcp add --transport http nextblock ${url}`
|
|
214
230
|
: `claude mcp add --transport http nextblock ${url} --header "Authorization: Bearer ${tokenForSnippet}"`;
|
|
215
231
|
|
|
232
|
+
// Belt-and-braces: the controls below are disabled in read-only mode, and the
|
|
233
|
+
// server actions refuse sandbox writes on their own. These early returns just
|
|
234
|
+
// mean a stray call from here can never even reach the network.
|
|
216
235
|
function persistSettings(next: { allowLocalhostWithoutToken: boolean; enabled: boolean }) {
|
|
236
|
+
if (readOnly) return;
|
|
237
|
+
|
|
217
238
|
setError(null);
|
|
218
239
|
startTransition(async () => {
|
|
219
240
|
const result = await saveMcpSettingsAction(next);
|
|
@@ -231,6 +252,8 @@ export function McpServerSettingsCard({
|
|
|
231
252
|
}
|
|
232
253
|
|
|
233
254
|
function handleCreateToken() {
|
|
255
|
+
if (readOnly) return;
|
|
256
|
+
|
|
234
257
|
setError(null);
|
|
235
258
|
setMintedToken(null);
|
|
236
259
|
|
|
@@ -255,6 +278,8 @@ export function McpServerSettingsCard({
|
|
|
255
278
|
}
|
|
256
279
|
|
|
257
280
|
function handleRevoke(id: string) {
|
|
281
|
+
if (readOnly) return;
|
|
282
|
+
|
|
258
283
|
setError(null);
|
|
259
284
|
startTransition(async () => {
|
|
260
285
|
const result = await revokeMcpAccessTokenAction({ id });
|
|
@@ -283,6 +308,12 @@ export function McpServerSettingsCard({
|
|
|
283
308
|
{tokens.length} active {tokens.length === 1 ? 'token' : 'tokens'}
|
|
284
309
|
</Badge>
|
|
285
310
|
)}
|
|
311
|
+
{readOnly && (
|
|
312
|
+
<Badge variant="outline" className="gap-1 font-normal">
|
|
313
|
+
<Lock className="h-3 w-3" />
|
|
314
|
+
Read-only
|
|
315
|
+
</Badge>
|
|
316
|
+
)}
|
|
286
317
|
</CardTitle>
|
|
287
318
|
<CardDescription className="text-xs">
|
|
288
319
|
Expose this CMS to external AI clients over the Model Context Protocol. Claude Code,
|
|
@@ -293,6 +324,14 @@ export function McpServerSettingsCard({
|
|
|
293
324
|
</CardHeader>
|
|
294
325
|
|
|
295
326
|
<CardContent className="space-y-4 pt-0">
|
|
327
|
+
{readOnly && readOnlyNotice && (
|
|
328
|
+
<Alert>
|
|
329
|
+
<Info className="h-4 w-4" />
|
|
330
|
+
<AlertTitle>Configured per install</AlertTitle>
|
|
331
|
+
<AlertDescription className="text-xs">{readOnlyNotice}</AlertDescription>
|
|
332
|
+
</Alert>
|
|
333
|
+
)}
|
|
334
|
+
|
|
296
335
|
{error && (
|
|
297
336
|
<Alert variant="destructive">
|
|
298
337
|
<AlertTriangle className="h-4 w-4" />
|
|
@@ -307,7 +346,7 @@ export function McpServerSettingsCard({
|
|
|
307
346
|
<Checkbox
|
|
308
347
|
id="mcp_enabled"
|
|
309
348
|
checked={isEnabled}
|
|
310
|
-
disabled={isPending}
|
|
349
|
+
disabled={isPending || readOnly}
|
|
311
350
|
onCheckedChange={(checked) => {
|
|
312
351
|
const next = checked === true;
|
|
313
352
|
setIsEnabled(next);
|
|
@@ -329,7 +368,7 @@ export function McpServerSettingsCard({
|
|
|
329
368
|
<Checkbox
|
|
330
369
|
id="mcp_allow_localhost"
|
|
331
370
|
checked={allowLocalhost}
|
|
332
|
-
disabled={isPending || !isEnabled}
|
|
371
|
+
disabled={isPending || !isEnabled || readOnly}
|
|
333
372
|
onCheckedChange={(checked) => {
|
|
334
373
|
const next = checked === true;
|
|
335
374
|
setAllowLocalhost(next);
|
|
@@ -397,6 +436,7 @@ export function McpServerSettingsCard({
|
|
|
397
436
|
id="mcp_token_name"
|
|
398
437
|
value={tokenName}
|
|
399
438
|
maxLength={80}
|
|
439
|
+
disabled={readOnly}
|
|
400
440
|
placeholder="My laptop — Claude Code"
|
|
401
441
|
onChange={(event) => setTokenName(event.target.value)}
|
|
402
442
|
/>
|
|
@@ -411,6 +451,7 @@ export function McpServerSettingsCard({
|
|
|
411
451
|
min={1}
|
|
412
452
|
max={3650}
|
|
413
453
|
value={expiresInDays}
|
|
454
|
+
disabled={readOnly}
|
|
414
455
|
placeholder="Never"
|
|
415
456
|
onChange={(event) => setExpiresInDays(event.target.value)}
|
|
416
457
|
/>
|
|
@@ -419,6 +460,7 @@ export function McpServerSettingsCard({
|
|
|
419
460
|
<Checkbox
|
|
420
461
|
id="mcp_token_write"
|
|
421
462
|
checked={allowWrites}
|
|
463
|
+
disabled={readOnly}
|
|
422
464
|
onCheckedChange={(checked) => setAllowWrites(checked === true)}
|
|
423
465
|
/>
|
|
424
466
|
<Label htmlFor="mcp_token_write" className="text-xs">
|
|
@@ -428,7 +470,7 @@ export function McpServerSettingsCard({
|
|
|
428
470
|
<Button
|
|
429
471
|
type="button"
|
|
430
472
|
size="sm"
|
|
431
|
-
disabled={isPending || !tokenName.trim()}
|
|
473
|
+
disabled={isPending || readOnly || !tokenName.trim()}
|
|
432
474
|
onClick={handleCreateToken}
|
|
433
475
|
>
|
|
434
476
|
<Plus className="mr-1.5 h-3.5 w-3.5" />
|
|
@@ -481,7 +523,7 @@ export function McpServerSettingsCard({
|
|
|
481
523
|
type="button"
|
|
482
524
|
variant="ghost"
|
|
483
525
|
size="sm"
|
|
484
|
-
disabled={isPending}
|
|
526
|
+
disabled={isPending || readOnly}
|
|
485
527
|
className="h-7 shrink-0 text-destructive hover:text-destructive"
|
|
486
528
|
onClick={() => handleRevoke(token.id)}
|
|
487
529
|
>
|
|
@@ -504,7 +546,9 @@ export function McpServerSettingsCard({
|
|
|
504
546
|
? 'No token needed: localhost trust covers this connection while the dev server runs. These snippets deliberately send no Authorization header — an invalid one would be rejected rather than falling back to localhost trust.'
|
|
505
547
|
: mintedToken
|
|
506
548
|
? 'These snippets include the token you just created.'
|
|
507
|
-
:
|
|
549
|
+
: readOnly
|
|
550
|
+
? `This is the exact configuration you would paste on your own install, with ${TOKEN_PLACEHOLDER} standing in for the token you mint there.`
|
|
551
|
+
: `Create a token above and these snippets will fill it in; otherwise replace ${TOKEN_PLACEHOLDER}.`}
|
|
508
552
|
</p>
|
|
509
553
|
</div>
|
|
510
554
|
|
|
@@ -28,6 +28,22 @@ const CORTEX_AI_SETTINGS_PATH = '/cms/settings/cortex-ai';
|
|
|
28
28
|
const MAX_TOKEN_NAME_LENGTH = 80;
|
|
29
29
|
const MAX_TOKENS = 20;
|
|
30
30
|
|
|
31
|
+
/**
|
|
32
|
+
* Sandbox writes are refused here, not just in the UI.
|
|
33
|
+
*
|
|
34
|
+
* The settings row and the token table are shared by every sandbox visitor, and the
|
|
35
|
+
* card is now rendered there (read-only) so people can see that MCP access exists —
|
|
36
|
+
* which means these actions are reachable from a sandbox page. A disabled checkbox is
|
|
37
|
+
* a suggestion; this is the actual boundary. Mirrors the guards in `actions.ts`.
|
|
38
|
+
*/
|
|
39
|
+
function sandboxRejection(what: string): { error: string; success: false } | null {
|
|
40
|
+
if (process.env.NEXT_PUBLIC_IS_SANDBOX !== 'true') {
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
return { error: `Sandbox environment cannot ${what}.`, success: false };
|
|
45
|
+
}
|
|
46
|
+
|
|
31
47
|
export type McpAccessTokenSummary = {
|
|
32
48
|
createdAt: string;
|
|
33
49
|
expiresAt: string | null;
|
|
@@ -90,6 +106,9 @@ export async function saveMcpSettingsAction(input: {
|
|
|
90
106
|
allowLocalhostWithoutToken: boolean;
|
|
91
107
|
enabled: boolean;
|
|
92
108
|
}): Promise<{ error?: string; success: boolean }> {
|
|
109
|
+
const rejected = sandboxRejection('change MCP server settings');
|
|
110
|
+
if (rejected) return rejected;
|
|
111
|
+
|
|
93
112
|
try {
|
|
94
113
|
const { supabase } = await requireAdminSupabaseClient();
|
|
95
114
|
const value = normalizeCortexAiMcpSettings(input);
|
|
@@ -117,6 +136,9 @@ export async function createMcpAccessTokenAction(input: {
|
|
|
117
136
|
name: string;
|
|
118
137
|
scopes: CortexAiMcpScope[];
|
|
119
138
|
}): Promise<{ error?: string; success: boolean; token?: string; tokenPrefix?: string }> {
|
|
139
|
+
const rejected = sandboxRejection('mint MCP access tokens');
|
|
140
|
+
if (rejected) return rejected;
|
|
141
|
+
|
|
120
142
|
try {
|
|
121
143
|
const { supabase, userId } = await requireAdminSupabaseClient();
|
|
122
144
|
|
|
@@ -177,6 +199,9 @@ export async function createMcpAccessTokenAction(input: {
|
|
|
177
199
|
export async function revokeMcpAccessTokenAction(input: {
|
|
178
200
|
id: string;
|
|
179
201
|
}): Promise<{ error?: string; success: boolean }> {
|
|
202
|
+
const rejected = sandboxRejection('revoke MCP access tokens');
|
|
203
|
+
if (rejected) return rejected;
|
|
204
|
+
|
|
180
205
|
try {
|
|
181
206
|
const { supabase } = await requireAdminSupabaseClient();
|
|
182
207
|
const id = String(input.id || '').trim();
|
|
@@ -2,10 +2,9 @@ import { headers } from 'next/headers';
|
|
|
2
2
|
|
|
3
3
|
import { listCortexAiCompatibleOpenRouterModels } from '@nextblock-cms/cortex';
|
|
4
4
|
import { getCortexAiSettingsStatus } from './actions';
|
|
5
|
-
import {
|
|
5
|
+
import { CortexAiSettingsClient } from './CortexAiSettingsClient';
|
|
6
|
+
import { getMcpSettingsStatus, type McpSettingsStatus } from './mcp-actions';
|
|
6
7
|
import { McpServerSettingsCard } from './McpServerSettingsCard';
|
|
7
|
-
import { SandboxCortexAiSettingsClient } from './SandboxCortexAiSettingsClient';
|
|
8
|
-
import { StoredCortexAiSettingsClient } from './StoredCortexAiSettingsClient';
|
|
9
8
|
import { redirect } from 'next/navigation';
|
|
10
9
|
|
|
11
10
|
type CortexAiSettingsPageProps = {
|
|
@@ -15,16 +14,12 @@ type CortexAiSettingsPageProps = {
|
|
|
15
14
|
}>;
|
|
16
15
|
};
|
|
17
16
|
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
dateStyle: 'medium',
|
|
25
|
-
timeStyle: 'short',
|
|
26
|
-
}).format(new Date(value));
|
|
27
|
-
}
|
|
17
|
+
const SANDBOX_MCP_NOTICE =
|
|
18
|
+
'This is a shared sandbox, so the switches and the token list are locked — enabling a remote ' +
|
|
19
|
+
'write surface or minting a token here would apply to every visitor at once. Everything else ' +
|
|
20
|
+
'works: the endpoint and the client snippets below are exactly what you get on your own ' +
|
|
21
|
+
'NextBlock install, where you flip the switch, mint a token, and paste the config into Claude ' +
|
|
22
|
+
'Code, Claude Desktop, Cursor, or VS Code.';
|
|
28
23
|
|
|
29
24
|
/**
|
|
30
25
|
* The origin an external MCP client should dial.
|
|
@@ -80,16 +75,15 @@ export default async function CortexAiSettingsPage({
|
|
|
80
75
|
redirect('/cms/dashboard');
|
|
81
76
|
}
|
|
82
77
|
|
|
78
|
+
const isSandbox = process.env.NEXT_PUBLIC_IS_SANDBOX === 'true';
|
|
83
79
|
const params: { error?: string; success?: string } = searchParams
|
|
84
80
|
? await searchParams
|
|
85
81
|
: {};
|
|
86
|
-
|
|
87
|
-
const selectedModelUpdatedAt = formatDate(status.selectedModel?.updatedAt || null);
|
|
88
|
-
const stockKeysUpdatedAt = formatDate(status.stockKeysUpdatedAt);
|
|
82
|
+
|
|
89
83
|
let compatibleModels: Awaited<ReturnType<typeof listCortexAiCompatibleOpenRouterModels>> = [];
|
|
90
84
|
let modelCatalogError: string | null = null;
|
|
91
85
|
|
|
92
|
-
if (status.hasStoredOpenRouterKey ||
|
|
86
|
+
if (status.hasStoredOpenRouterKey || isSandbox) {
|
|
93
87
|
try {
|
|
94
88
|
compatibleModels = await listCortexAiCompatibleOpenRouterModels();
|
|
95
89
|
} catch (error) {
|
|
@@ -98,44 +92,30 @@ export default async function CortexAiSettingsPage({
|
|
|
98
92
|
}
|
|
99
93
|
}
|
|
100
94
|
|
|
101
|
-
if (process.env.NEXT_PUBLIC_IS_SANDBOX === 'true') {
|
|
102
|
-
return (
|
|
103
|
-
<SandboxCortexAiSettingsClient
|
|
104
|
-
compatibleModels={compatibleModels as any}
|
|
105
|
-
isPackageActive={status.isPackageActive}
|
|
106
|
-
hasEnvOpenRouterKey={status.hasEnvOpenRouterKey}
|
|
107
|
-
maskedEnvOpenRouterKey={status.maskedEnvOpenRouterKey}
|
|
108
|
-
modelCatalogError={modelCatalogError}
|
|
109
|
-
activeStockProvider={status.activeStockProvider}
|
|
110
|
-
hasStoredPexelsKey={status.hasStoredPexelsKey}
|
|
111
|
-
maskedStoredPexelsKey={status.maskedStoredPexelsKey}
|
|
112
|
-
hasStoredUnsplashKey={status.hasStoredUnsplashKey}
|
|
113
|
-
maskedStoredUnsplashKey={status.maskedStoredUnsplashKey}
|
|
114
|
-
hasEnvPexelsKey={status.hasEnvPexelsKey}
|
|
115
|
-
hasEnvUnsplashKey={status.hasEnvUnsplashKey}
|
|
116
|
-
unsplashAppName={status.unsplashAppName}
|
|
117
|
-
agentSettings={status.agentSettings}
|
|
118
|
-
/>
|
|
119
|
-
);
|
|
120
|
-
}
|
|
121
|
-
|
|
122
95
|
const [mcpStatus, siteOrigin, localOrigin] = await Promise.all([
|
|
123
96
|
getMcpSettingsStatus(),
|
|
124
97
|
resolveSiteOrigin(),
|
|
125
98
|
resolveLocalOrigin(),
|
|
126
99
|
]);
|
|
127
100
|
|
|
101
|
+
// The sandbox sees the card, the endpoint, and the snippets — but never the token
|
|
102
|
+
// list. Those rows belong to whoever runs the sandbox, and every visitor here shares
|
|
103
|
+
// one admin login, so listing another visitor's token names would be a leak with no
|
|
104
|
+
// upside (the card is read-only anyway, so nothing in it is actionable).
|
|
105
|
+
const visibleMcpStatus: McpSettingsStatus = isSandbox
|
|
106
|
+
? { settings: mcpStatus.settings, tokens: [] }
|
|
107
|
+
: mcpStatus;
|
|
108
|
+
|
|
128
109
|
return (
|
|
129
|
-
<
|
|
130
|
-
|
|
110
|
+
<CortexAiSettingsClient
|
|
111
|
+
isSandbox={isSandbox}
|
|
112
|
+
compatibleModels={compatibleModels}
|
|
131
113
|
isPackageActive={status.isPackageActive}
|
|
132
114
|
hasEnvOpenRouterKey={status.hasEnvOpenRouterKey}
|
|
133
115
|
maskedEnvOpenRouterKey={status.maskedEnvOpenRouterKey}
|
|
134
116
|
hasStoredOpenRouterKey={status.hasStoredOpenRouterKey}
|
|
135
117
|
maskedStoredOpenRouterKey={status.maskedStoredOpenRouterKey}
|
|
136
|
-
storedKeyUpdatedAt={storedKeyUpdatedAt}
|
|
137
118
|
selectedModel={status.selectedModel}
|
|
138
|
-
selectedModelUpdatedAt={selectedModelUpdatedAt}
|
|
139
119
|
hasEncryptionKey={status.hasEncryptionKey}
|
|
140
120
|
modelCatalogError={modelCatalogError}
|
|
141
121
|
activeStockProvider={status.activeStockProvider}
|
|
@@ -145,19 +125,20 @@ export default async function CortexAiSettingsPage({
|
|
|
145
125
|
maskedStoredUnsplashKey={status.maskedStoredUnsplashKey}
|
|
146
126
|
hasEnvPexelsKey={status.hasEnvPexelsKey}
|
|
147
127
|
hasEnvUnsplashKey={status.hasEnvUnsplashKey}
|
|
148
|
-
stockKeysUpdatedAt={stockKeysUpdatedAt}
|
|
149
128
|
unsplashAppName={status.unsplashAppName}
|
|
150
129
|
agentSettings={status.agentSettings}
|
|
151
130
|
successMessage={params.success}
|
|
152
131
|
errorMessage={params.error}
|
|
153
132
|
>
|
|
154
133
|
<McpServerSettingsCard
|
|
155
|
-
allowLocalhostWithoutToken={
|
|
156
|
-
enabled={
|
|
134
|
+
allowLocalhostWithoutToken={visibleMcpStatus.settings.allowLocalhostWithoutToken}
|
|
135
|
+
enabled={visibleMcpStatus.settings.enabled}
|
|
157
136
|
localMcpUrl={`${localOrigin}/api/mcp`}
|
|
158
137
|
mcpUrl={`${siteOrigin}/api/mcp`}
|
|
159
|
-
|
|
138
|
+
readOnly={isSandbox}
|
|
139
|
+
readOnlyNotice={isSandbox ? SANDBOX_MCP_NOTICE : undefined}
|
|
140
|
+
tokens={visibleMcpStatus.tokens}
|
|
160
141
|
/>
|
|
161
|
-
</
|
|
142
|
+
</CortexAiSettingsClient>
|
|
162
143
|
);
|
|
163
144
|
}
|
|
@@ -125,11 +125,38 @@ Known incomplete or future work:
|
|
|
125
125
|
| `apps/nextblock/app/cms/layout.tsx` | Server layout checks package activation for ecommerce and Cortex AI. |
|
|
126
126
|
| `apps/nextblock/app/cms/CmsClientLayout.tsx` | Adds Cortex AI settings nav item, wraps CMS in the page-context provider, and conditionally renders global chat. |
|
|
127
127
|
| `apps/nextblock/app/cms/settings/cortex-ai/page.tsx` | Settings page for activation/key status, BYOK forms, and compatible model selection. |
|
|
128
|
+
| `apps/nextblock/app/cms/settings/cortex-ai/CortexAiSettingsClient.tsx` | The single settings UI. One component for production **and** sandbox — see below. |
|
|
128
129
|
| `apps/nextblock/app/cms/settings/cortex-ai/actions.ts` | Server actions for reading, saving, and clearing BYOK keys and model selections. |
|
|
129
130
|
| `apps/nextblock/app/cms/dashboard/actions.ts` | Dashboard package state; checks `cortex-ai` to hide/show AI premium CTA. |
|
|
130
131
|
| `apps/nextblock/components/Header.tsx` and `apps/nextblock/components/ResponsiveNav.tsx` | Hydration-safe public header controls after Radix ID mismatch fixes. |
|
|
131
132
|
| `apps/nextblock/app/cms/components/FeedbackModal.tsx` | Hydration-safe feedback dialog trigger. |
|
|
132
133
|
|
|
134
|
+
### One settings UI, sandbox included
|
|
135
|
+
|
|
136
|
+
`page.tsx` has **one** render path. There is no sandbox variant component, and adding
|
|
137
|
+
one back would re-create a bug this repo hit twice: the page used to fork into
|
|
138
|
+
`StoredCortexAiSettingsClient` / `SandboxCortexAiSettingsClient`, which shared a layout
|
|
139
|
+
only by copy-paste, so every redesign landed on production and silently skipped the
|
|
140
|
+
sandbox — and the MCP card, mounted only on the production branch, never appeared in the
|
|
141
|
+
sandbox at all.
|
|
142
|
+
|
|
143
|
+
`CortexAiSettingsClient` takes `isSandbox` and follows one rule for anything the shared
|
|
144
|
+
sandbox cannot do: **disable it, never hide it.** A visitor evaluating NextBlock has to be
|
|
145
|
+
able to see that stock-photo keys, agent tuning, and MCP access exist and what they look
|
|
146
|
+
like; a hidden control teaches them the feature does not exist. Locked cards carry a
|
|
147
|
+
`Read-only` badge and say what changes on a real install.
|
|
148
|
+
|
|
149
|
+
Two settings stay writable in the sandbox because they have a per-visitor channel: the
|
|
150
|
+
OpenRouter key and the model selection, which live in this browser's `localStorage` and
|
|
151
|
+
travel to the AI routes as `x-sandbox-openrouter-*` headers. Everything else is
|
|
152
|
+
server-backed and refused by the `NEXT_PUBLIC_IS_SANDBOX` guards in `actions.ts` and
|
|
153
|
+
`mcp-actions.ts` — the disabled control is the hint, those guards are the boundary.
|
|
154
|
+
|
|
155
|
+
The MCP card renders in the sandbox with `readOnly`: toggles, minting, and revoking are
|
|
156
|
+
disabled, and the token list is passed in empty (those rows belong to the host, and every
|
|
157
|
+
sandbox visitor shares one admin login). The endpoint URL, the client picker, and the
|
|
158
|
+
copy-paste snippets stay fully live, since that is the part worth showing.
|
|
159
|
+
|
|
133
160
|
## Package Activation
|
|
134
161
|
|
|
135
162
|
The package id is `cortex-ai`. Do not use the old id `ai`.
|
|
@@ -1075,6 +1075,24 @@ async function updateSchema(install, core, flags) {
|
|
|
1075
1075
|
fail(`Could not read the migration history: ${applied.error}`);
|
|
1076
1076
|
return { ok: false, applied: 0 };
|
|
1077
1077
|
}
|
|
1078
|
+
// Versions recorded remotely that have no file here mean the two histories have diverged
|
|
1079
|
+
// — most often an install that predates the July 2026 re-baseline, where the old 000–044
|
|
1080
|
+
// numbering is still recorded. That matters because Supabase (and this applier) match
|
|
1081
|
+
// history by VERSION ONLY, never by content: a local file whose number is already
|
|
1082
|
+
// recorded is skipped in silence, with no error and no output. Warn rather than block —
|
|
1083
|
+
// a hand-written migration of the operator's own is a perfectly legitimate cause.
|
|
1084
|
+
const localVersions = new Set(files.map((f) => f.version));
|
|
1085
|
+
const remoteOnly = [...applied.versions].filter((v) => !localVersions.has(v)).sort();
|
|
1086
|
+
if (remoteOnly.length > 0) {
|
|
1087
|
+
warn(`${remoteOnly.length} version(s) are recorded in the database with no matching file:`);
|
|
1088
|
+
for (const v of remoteOnly.slice(0, 10)) info(C.dim(` ${v}`));
|
|
1089
|
+
if (remoteOnly.length > 10) info(C.dim(` … and ${remoteOnly.length - 10} more`));
|
|
1090
|
+
info('Migrations are matched by version, never by content, so a local file reusing one');
|
|
1091
|
+
info('of those numbers would never run. If this install predates the migration');
|
|
1092
|
+
info(`re-baseline, reconcile it once with ${C.cyan('npm run db:migrate:repair-history')}.`);
|
|
1093
|
+
say();
|
|
1094
|
+
}
|
|
1095
|
+
|
|
1078
1096
|
const pending = files.filter((f) => !applied.versions.has(f.version));
|
|
1079
1097
|
|
|
1080
1098
|
if (pending.length === 0) {
|