create-nextblock 0.14.4 → 0.14.6

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 (55) hide show
  1. package/package.json +1 -1
  2. package/templates/nextblock-template/AGENTS.md +9 -0
  3. package/templates/nextblock-template/CLAUDE.md +1 -0
  4. package/templates/nextblock-template/app/[slug]/page.tsx +7 -2
  5. package/templates/nextblock-template/app/[slug]/page.utils.ts +8 -3
  6. package/templates/nextblock-template/app/actions/postActions.ts +3 -0
  7. package/templates/nextblock-template/app/actions/visibilityActions.ts +210 -0
  8. package/templates/nextblock-template/app/actions/visualEditingActions.test.ts +83 -3
  9. package/templates/nextblock-template/app/actions/visualEditingActions.ts +34 -14
  10. package/templates/nextblock-template/app/api/ai/global-agent/route.ts +45 -0
  11. package/templates/nextblock-template/app/api/cron/reset-sandbox/sandboxResetSql.ts +457 -1
  12. package/templates/nextblock-template/app/api/mcp/route.ts +346 -0
  13. package/templates/nextblock-template/app/api/view/route.ts +114 -0
  14. package/templates/nextblock-template/app/article/[slug]/page.utils.ts +1 -2
  15. package/templates/nextblock-template/app/cms/components/DraftStatusActions.tsx +10 -0
  16. package/templates/nextblock-template/app/cms/components/VisibilityBadge.tsx +62 -0
  17. package/templates/nextblock-template/app/cms/components/VisibilityControl.tsx +528 -0
  18. package/templates/nextblock-template/app/cms/pages/[id]/edit/EditPageClient.tsx +33 -17
  19. package/templates/nextblock-template/app/cms/pages/[id]/edit/page.tsx +19 -7
  20. package/templates/nextblock-template/app/cms/pages/actions.ts +17 -10
  21. package/templates/nextblock-template/app/cms/pages/components/PageForm.tsx +7 -29
  22. package/templates/nextblock-template/app/cms/pages/page.tsx +6 -19
  23. package/templates/nextblock-template/app/cms/posts/[id]/edit/page.tsx +42 -18
  24. package/templates/nextblock-template/app/cms/posts/actions.ts +16 -27
  25. package/templates/nextblock-template/app/cms/posts/components/PostForm.tsx +3 -60
  26. package/templates/nextblock-template/app/cms/posts/page.tsx +6 -13
  27. package/templates/nextblock-template/app/cms/products/[id]/edit/page.tsx +63 -9
  28. package/templates/nextblock-template/app/cms/revisions/RevisionHistoryButton.tsx +66 -32
  29. package/templates/nextblock-template/app/cms/revisions/actions.ts +332 -285
  30. package/templates/nextblock-template/app/cms/revisions/service.test.ts +498 -0
  31. package/templates/nextblock-template/app/cms/revisions/service.ts +549 -471
  32. package/templates/nextblock-template/app/cms/revisions/utils.ts +304 -132
  33. package/templates/nextblock-template/app/cms/settings/cortex-ai/McpServerSettingsCard.tsx +584 -0
  34. package/templates/nextblock-template/app/cms/settings/cortex-ai/StoredCortexAiSettingsClient.tsx +6 -0
  35. package/templates/nextblock-template/app/cms/settings/cortex-ai/actions.ts +4 -20
  36. package/templates/nextblock-template/app/cms/settings/cortex-ai/mcp-actions.ts +205 -0
  37. package/templates/nextblock-template/app/cms/settings/cortex-ai/page.tsx +64 -1
  38. package/templates/nextblock-template/app/cms/settings/cortex-ai/require-admin.ts +34 -0
  39. package/templates/nextblock-template/app/lib/sitemap-utils.ts +6 -4
  40. package/templates/nextblock-template/app/lib/ucp/server.ts +4 -1
  41. package/templates/nextblock-template/app/page.tsx +6 -3
  42. package/templates/nextblock-template/app/product/[slug]/page.tsx +27 -3
  43. package/templates/nextblock-template/components/visual-editing/NextblockVisualEditing.tsx +4 -1
  44. package/templates/nextblock-template/docs/04-DATABASE-AND-AUTH.md +38 -3
  45. package/templates/nextblock-template/docs/08-NEXTBLOCK-CORTEX-AI-ARCHITECTURE.md +151 -0
  46. package/templates/nextblock-template/lib/cms-transfer/server.ts +13 -0
  47. package/templates/nextblock-template/lib/full-backup/server.ts +1 -0
  48. package/templates/nextblock-template/lib/publishing/viewUrl.ts +26 -0
  49. package/templates/nextblock-template/lib/search/server.ts +3 -0
  50. package/templates/nextblock-template/lib/setup/migrations-bundle.ts +15 -0
  51. package/templates/nextblock-template/lib/visual-editing/mutations.ts +4 -1
  52. package/templates/nextblock-template/lib/visual-editing/product-drafts.ts +46 -1
  53. package/templates/nextblock-template/next-env.d.ts +2 -2
  54. package/templates/nextblock-template/package.json +1 -1
  55. package/templates/nextblock-template/tsconfig.tsbuildinfo +1 -1
@@ -0,0 +1,205 @@
1
+ 'use server';
2
+
3
+ import { revalidatePath } from 'next/cache';
4
+
5
+ import {
6
+ CORTEX_AI_MCP_SETTINGS_KEY,
7
+ CORTEX_AI_MCP_TOKENS_TABLE,
8
+ mintCortexAiMcpToken,
9
+ normalizeCortexAiMcpSettings,
10
+ type CortexAiMcpScope,
11
+ type CortexAiMcpSettings,
12
+ } from '@nextblock-cms/cortex';
13
+
14
+ import { requireAdminSupabaseClient } from './require-admin';
15
+
16
+ /**
17
+ * Server actions for the "MCP server access" card.
18
+ *
19
+ * Unlike the rest of `actions.ts`, these return values instead of redirecting with a
20
+ * `?success=` message. A minted token is displayed exactly once and must never travel
21
+ * in a URL — it would land in browser history, the referrer header, and the server
22
+ * access log. Returning it to a client component that renders it in-place and forgets
23
+ * it keeps the secret out of every one of those.
24
+ */
25
+
26
+ const CORTEX_AI_SETTINGS_PATH = '/cms/settings/cortex-ai';
27
+
28
+ const MAX_TOKEN_NAME_LENGTH = 80;
29
+ const MAX_TOKENS = 20;
30
+
31
+ export type McpAccessTokenSummary = {
32
+ createdAt: string;
33
+ expiresAt: string | null;
34
+ id: string;
35
+ lastUsedAt: string | null;
36
+ name: string;
37
+ scopes: CortexAiMcpScope[];
38
+ tokenPrefix: string;
39
+ };
40
+
41
+ export type McpSettingsStatus = {
42
+ settings: CortexAiMcpSettings;
43
+ tokens: McpAccessTokenSummary[];
44
+ };
45
+
46
+ function toScopeList(raw: unknown): CortexAiMcpScope[] {
47
+ const list = Array.isArray(raw) ? raw : [];
48
+ const scopes = list.filter(
49
+ (entry): entry is CortexAiMcpScope => entry === 'read' || entry === 'write'
50
+ );
51
+
52
+ return scopes.length > 0 ? Array.from(new Set(scopes)) : ['read'];
53
+ }
54
+
55
+ /**
56
+ * Load MCP settings and the non-secret token list.
57
+ *
58
+ * Revoked tokens are filtered out rather than deleted at revoke time so the row keeps
59
+ * acting as a tombstone: the hash stays in the unique index, which makes an
60
+ * accidental re-mint of the same value impossible.
61
+ */
62
+ export async function getMcpSettingsStatus(): Promise<McpSettingsStatus> {
63
+ const { supabase } = await requireAdminSupabaseClient();
64
+
65
+ const [{ data: settingsRow }, { data: tokenRows }] = await Promise.all([
66
+ supabase.from('site_settings').select('value').eq('key', CORTEX_AI_MCP_SETTINGS_KEY).maybeSingle(),
67
+ supabase
68
+ .from(CORTEX_AI_MCP_TOKENS_TABLE)
69
+ .select('id, name, scopes, token_prefix, created_at, expires_at, last_used_at, revoked_at')
70
+ .is('revoked_at', null)
71
+ .order('created_at', { ascending: false })
72
+ .limit(MAX_TOKENS),
73
+ ]);
74
+
75
+ return {
76
+ settings: normalizeCortexAiMcpSettings(settingsRow?.value),
77
+ tokens: (tokenRows ?? []).map((row: Record<string, any>) => ({
78
+ createdAt: row.created_at,
79
+ expiresAt: row.expires_at,
80
+ id: row.id,
81
+ lastUsedAt: row.last_used_at,
82
+ name: row.name,
83
+ scopes: toScopeList(row.scopes),
84
+ tokenPrefix: row.token_prefix,
85
+ })),
86
+ };
87
+ }
88
+
89
+ export async function saveMcpSettingsAction(input: {
90
+ allowLocalhostWithoutToken: boolean;
91
+ enabled: boolean;
92
+ }): Promise<{ error?: string; success: boolean }> {
93
+ try {
94
+ const { supabase } = await requireAdminSupabaseClient();
95
+ const value = normalizeCortexAiMcpSettings(input);
96
+
97
+ const { error } = await supabase
98
+ .from('site_settings')
99
+ .upsert({ key: CORTEX_AI_MCP_SETTINGS_KEY, value });
100
+
101
+ if (error) {
102
+ throw new Error(error.message);
103
+ }
104
+ } catch (error) {
105
+ return {
106
+ error: error instanceof Error ? error.message : 'Failed to save MCP server settings.',
107
+ success: false,
108
+ };
109
+ }
110
+
111
+ revalidatePath(CORTEX_AI_SETTINGS_PATH);
112
+ return { success: true };
113
+ }
114
+
115
+ export async function createMcpAccessTokenAction(input: {
116
+ expiresInDays?: number | null;
117
+ name: string;
118
+ scopes: CortexAiMcpScope[];
119
+ }): Promise<{ error?: string; success: boolean; token?: string; tokenPrefix?: string }> {
120
+ try {
121
+ const { supabase, userId } = await requireAdminSupabaseClient();
122
+
123
+ const name = String(input.name || '').trim().slice(0, MAX_TOKEN_NAME_LENGTH);
124
+
125
+ if (!name) {
126
+ throw new Error('Give the token a name so you can tell your clients apart later.');
127
+ }
128
+
129
+ const scopes = toScopeList(input.scopes);
130
+
131
+ const { count } = await supabase
132
+ .from(CORTEX_AI_MCP_TOKENS_TABLE)
133
+ .select('id', { count: 'exact', head: true })
134
+ .is('revoked_at', null);
135
+
136
+ if ((count ?? 0) >= MAX_TOKENS) {
137
+ throw new Error(
138
+ `You already have ${MAX_TOKENS} active MCP tokens. Revoke one before creating another.`
139
+ );
140
+ }
141
+
142
+ const expiresInDays =
143
+ typeof input.expiresInDays === 'number' && Number.isFinite(input.expiresInDays)
144
+ ? Math.min(3650, Math.max(1, Math.round(input.expiresInDays)))
145
+ : null;
146
+
147
+ const expiresAt = expiresInDays
148
+ ? new Date(Date.now() + expiresInDays * 24 * 60 * 60 * 1000).toISOString()
149
+ : null;
150
+
151
+ const minted = mintCortexAiMcpToken();
152
+
153
+ const { error } = await supabase.from(CORTEX_AI_MCP_TOKENS_TABLE).insert({
154
+ created_by: userId,
155
+ expires_at: expiresAt,
156
+ name,
157
+ scopes,
158
+ token_hash: minted.tokenHash,
159
+ token_prefix: minted.tokenPrefix,
160
+ });
161
+
162
+ if (error) {
163
+ throw new Error(error.message);
164
+ }
165
+
166
+ revalidatePath(CORTEX_AI_SETTINGS_PATH);
167
+
168
+ return { success: true, token: minted.token, tokenPrefix: minted.tokenPrefix };
169
+ } catch (error) {
170
+ return {
171
+ error: error instanceof Error ? error.message : 'Failed to create the MCP access token.',
172
+ success: false,
173
+ };
174
+ }
175
+ }
176
+
177
+ export async function revokeMcpAccessTokenAction(input: {
178
+ id: string;
179
+ }): Promise<{ error?: string; success: boolean }> {
180
+ try {
181
+ const { supabase } = await requireAdminSupabaseClient();
182
+ const id = String(input.id || '').trim();
183
+
184
+ if (!id) {
185
+ throw new Error('Missing token id.');
186
+ }
187
+
188
+ const { error } = await supabase
189
+ .from(CORTEX_AI_MCP_TOKENS_TABLE)
190
+ .update({ revoked_at: new Date().toISOString() })
191
+ .eq('id', id);
192
+
193
+ if (error) {
194
+ throw new Error(error.message);
195
+ }
196
+ } catch (error) {
197
+ return {
198
+ error: error instanceof Error ? error.message : 'Failed to revoke the MCP access token.',
199
+ success: false,
200
+ };
201
+ }
202
+
203
+ revalidatePath(CORTEX_AI_SETTINGS_PATH);
204
+ return { success: true };
205
+ }
@@ -1,5 +1,9 @@
1
+ import { headers } from 'next/headers';
2
+
1
3
  import { listCortexAiCompatibleOpenRouterModels } from '@nextblock-cms/cortex';
2
4
  import { getCortexAiSettingsStatus } from './actions';
5
+ import { getMcpSettingsStatus } from './mcp-actions';
6
+ import { McpServerSettingsCard } from './McpServerSettingsCard';
3
7
  import { SandboxCortexAiSettingsClient } from './SandboxCortexAiSettingsClient';
4
8
  import { StoredCortexAiSettingsClient } from './StoredCortexAiSettingsClient';
5
9
  import { redirect } from 'next/navigation';
@@ -22,6 +26,51 @@ function formatDate(value: string | null) {
22
26
  }).format(new Date(value));
23
27
  }
24
28
 
29
+ /**
30
+ * The origin an external MCP client should dial.
31
+ *
32
+ * Prefers NEXT_PUBLIC_URL (the deployed canonical origin) and falls back to the
33
+ * request's own host, so the snippet is correct on a preview deployment or a custom
34
+ * domain that was never written into the env.
35
+ */
36
+ async function resolveSiteOrigin(): Promise<string> {
37
+ const configured = process.env.NEXT_PUBLIC_URL?.trim();
38
+
39
+ if (configured) {
40
+ return configured.replace(/\/+$/, '');
41
+ }
42
+
43
+ const headerList = await headers();
44
+ const host = headerList.get('host');
45
+
46
+ if (!host) {
47
+ return 'https://your-site.com';
48
+ }
49
+
50
+ const protocol = headerList.get('x-forwarded-proto') || (host.startsWith('localhost') ? 'http' : 'https');
51
+
52
+ return `${protocol}://${host}`;
53
+ }
54
+
55
+ /**
56
+ * The loopback origin to show in the "Localhost" client snippets.
57
+ *
58
+ * Ports differ per setup — `nx serve nextblock` uses Nx's default 4200, not Next's
59
+ * plain 3000 — and a snippet pointing at the wrong port fails with a bare connection
60
+ * error that gives the reader nothing to go on. When this page is itself being viewed
61
+ * over loopback, that request's own host is the authoritative answer.
62
+ */
63
+ async function resolveLocalOrigin(): Promise<string> {
64
+ const headerList = await headers();
65
+ const host = headerList.get('host')?.trim();
66
+
67
+ if (host && /^(localhost|127\.0\.0\.1|\[::1\])(:\d+)?$/i.test(host)) {
68
+ return `http://${host}`;
69
+ }
70
+
71
+ return 'http://localhost:4200';
72
+ }
73
+
25
74
  export default async function CortexAiSettingsPage({
26
75
  searchParams,
27
76
  }: CortexAiSettingsPageProps) {
@@ -70,6 +119,12 @@ export default async function CortexAiSettingsPage({
70
119
  );
71
120
  }
72
121
 
122
+ const [mcpStatus, siteOrigin, localOrigin] = await Promise.all([
123
+ getMcpSettingsStatus(),
124
+ resolveSiteOrigin(),
125
+ resolveLocalOrigin(),
126
+ ]);
127
+
73
128
  return (
74
129
  <StoredCortexAiSettingsClient
75
130
  compatibleModels={compatibleModels as any}
@@ -95,6 +150,14 @@ export default async function CortexAiSettingsPage({
95
150
  agentSettings={status.agentSettings}
96
151
  successMessage={params.success}
97
152
  errorMessage={params.error}
98
- />
153
+ >
154
+ <McpServerSettingsCard
155
+ allowLocalhostWithoutToken={mcpStatus.settings.allowLocalhostWithoutToken}
156
+ enabled={mcpStatus.settings.enabled}
157
+ localMcpUrl={`${localOrigin}/api/mcp`}
158
+ mcpUrl={`${siteOrigin}/api/mcp`}
159
+ tokens={mcpStatus.tokens}
160
+ />
161
+ </StoredCortexAiSettingsClient>
99
162
  );
100
163
  }
@@ -0,0 +1,34 @@
1
+ import { createClient } from '@nextblock-cms/db/server';
2
+
3
+ /**
4
+ * Admin gate shared by the Cortex AI settings server actions.
5
+ *
6
+ * Lives outside `actions.ts` because that file is `'use server'`, where every export
7
+ * must itself be a valid server action — a helper returning a Supabase client cannot
8
+ * be exported from there. Keeping one copy matters more than the file count: this is
9
+ * the check standing between a WRITER and the OpenRouter key, the stock-photo keys,
10
+ * and the MCP access tokens.
11
+ */
12
+ export async function requireAdminSupabaseClient() {
13
+ const supabase = createClient();
14
+ const {
15
+ data: { user },
16
+ error: userError,
17
+ } = await supabase.auth.getUser();
18
+
19
+ if (userError || !user) {
20
+ throw new Error('You must be logged in to manage Cortex AI settings.');
21
+ }
22
+
23
+ const { data: profile, error: profileError } = await supabase
24
+ .from('profiles')
25
+ .select('role')
26
+ .eq('id', user.id)
27
+ .single();
28
+
29
+ if (profileError || !profile || profile.role !== 'ADMIN') {
30
+ throw new Error('You do not have permission to manage Cortex AI settings.');
31
+ }
32
+
33
+ return { supabase, userId: user.id };
34
+ }
@@ -1,4 +1,5 @@
1
1
  import { getSsgSupabaseClient } from '@nextblock-cms/db/server';
2
+ import { buildPublishedAtOrFilter } from '@nextblock-cms/utils';
2
3
  import { getHomepageTranslationGroupId } from './homepage';
3
4
 
4
5
  /**
@@ -164,7 +165,8 @@ export async function fetchAllPublishedPages(): Promise<SitemapEntry[]> {
164
165
  supabase
165
166
  .from('pages')
166
167
  .select('slug, updated_at, language_id, translation_group_id')
167
- .eq('status', 'published'),
168
+ .eq('status', 'published')
169
+ .or(buildPublishedAtOrFilter()),
168
170
  fetchLanguageMap(supabase),
169
171
  getHomepageTranslationGroupId(supabase),
170
172
  ]);
@@ -200,13 +202,12 @@ export async function fetchAllPublishedPages(): Promise<SitemapEntry[]> {
200
202
  export async function fetchAllPublishedPosts(): Promise<SitemapEntry[]> {
201
203
  const supabase = getSsgSupabaseClient();
202
204
  try {
203
- const nowIso = new Date().toISOString();
204
205
  const [{ data: posts, error }, languageMap] = await Promise.all([
205
206
  supabase
206
207
  .from('posts')
207
208
  .select('slug, updated_at, language_id, translation_group_id')
208
209
  .eq('status', 'published')
209
- .or(`published_at.is.null,published_at.lte.${nowIso}`),
210
+ .or(buildPublishedAtOrFilter()),
210
211
  fetchLanguageMap(supabase),
211
212
  ]);
212
213
 
@@ -238,7 +239,8 @@ export async function fetchAllActiveProducts(): Promise<SitemapEntry[]> {
238
239
  supabase
239
240
  .from('products')
240
241
  .select('slug, updated_at, created_at, language_id, translation_group_id')
241
- .eq('status', 'active'),
242
+ .eq('status', 'active')
243
+ .or(buildPublishedAtOrFilter()),
242
244
  fetchLanguageMap(supabase),
243
245
  ]);
244
246
 
@@ -1,6 +1,7 @@
1
1
  import 'server-only';
2
2
 
3
3
  import { getServiceRoleSupabaseClient, verifyPackageOnline } from '@nextblock-cms/db/server';
4
+ import { buildPublishedAtOrFilter } from '@nextblock-cms/utils';
4
5
  import {
5
6
  getDefaultCurrency,
6
7
  inferCurrencyCodeFromLocale,
@@ -1099,6 +1100,7 @@ export async function searchCatalogProducts(body: unknown, request: Request) {
1099
1100
  .from('products')
1100
1101
  .select(PRODUCT_SELECT, { count: 'exact' })
1101
1102
  .eq('status', 'active')
1103
+ .or(buildPublishedAtOrFilter())
1102
1104
  .order('created_at', { ascending: false })
1103
1105
  .range(pagination.offset, pagination.offset + pagination.limit - 1);
1104
1106
 
@@ -1192,7 +1194,7 @@ async function selectRowsByField(
1192
1194
 
1193
1195
  let query = client.from(table).select(select).in(field, values);
1194
1196
  if (table === 'products') {
1195
- query = query.eq('status', 'active');
1197
+ query = query.eq('status', 'active').or(buildPublishedAtOrFilter());
1196
1198
  }
1197
1199
 
1198
1200
  const { data } = await query;
@@ -1251,6 +1253,7 @@ async function resolveProductRowsByIdentifiers(ids: string[]): Promise<{
1251
1253
  .from('products')
1252
1254
  .select(PRODUCT_SELECT)
1253
1255
  .eq('status', 'active')
1256
+ .or(buildPublishedAtOrFilter())
1254
1257
  .in('id', productIds);
1255
1258
 
1256
1259
  if (error) {
@@ -3,6 +3,7 @@ import { cookies, draftMode, headers } from 'next/headers';
3
3
  import { notFound } from 'next/navigation';
4
4
  import type { Metadata } from 'next';
5
5
  import { createClient, getSsgSupabaseClient } from '@nextblock-cms/db/server';
6
+ import { buildPublishedAtOrFilter } from '@nextblock-cms/utils';
6
7
  import PageClientContent from './[slug]/PageClientContent';
7
8
  import { getPageDataBySlug } from './[slug]/page.utils';
8
9
  import BlockRenderer from '../components/BlockRenderer';
@@ -56,7 +57,7 @@ async function resolveHomepageData(preferredLocale: string) {
56
57
  .limit(1);
57
58
 
58
59
  if (!draft.isEnabled) {
59
- siblingQuery = siblingQuery.eq('status', 'published');
60
+ siblingQuery = siblingQuery.eq('status', 'published').or(buildPublishedAtOrFilter());
60
61
  }
61
62
 
62
63
  const { data: sibling } = await siblingQuery.maybeSingle();
@@ -131,7 +132,8 @@ export async function generateMetadata(): Promise<Metadata> {
131
132
  .from('pages')
132
133
  .select('language_id, slug')
133
134
  .eq('translation_group_id', pageData.translation_group_id)
134
- .eq('status', 'published'),
135
+ .eq('status', 'published')
136
+ .or(buildPublishedAtOrFilter()),
135
137
  ]);
136
138
 
137
139
  const { data: languages } = languagesResult;
@@ -188,7 +190,8 @@ export default async function RootPage() {
188
190
  .from('pages')
189
191
  .select('slug, languages!inner(code)')
190
192
  .eq('translation_group_id', pageData.translation_group_id)
191
- .eq('status', 'published');
193
+ .eq('status', 'published')
194
+ .or(buildPublishedAtOrFilter());
192
195
 
193
196
  if (translations) {
194
197
  translations.forEach((translation: PageTranslation) => {
@@ -8,6 +8,7 @@ import {
8
8
  resolveTranslatedText,
9
9
  } from '@nextblock-cms/ecommerce';
10
10
  import { getSsgSupabaseClient, verifyPackageOnline } from '@nextblock-cms/db/server';
11
+ import { LIVE_STATUS, buildPublishedAtOrFilter, isPubliclyVisible } from '@nextblock-cms/utils';
11
12
  import { notFound } from 'next/navigation';
12
13
  import { Metadata } from 'next';
13
14
  import { draftMode, cookies, headers } from 'next/headers';
@@ -124,7 +125,16 @@ export async function generateMetadata({ params }: ProductPageProps): Promise<Me
124
125
  const { data: product } = await getProductBySlug(supabase, slug, preferredLocale);
125
126
  const productRecord = product as any;
126
127
 
127
- if (!productRecord || productRecord.status !== 'active') return { title: 'Product Not Found' };
128
+ if (
129
+ !productRecord ||
130
+ !isPubliclyVisible({
131
+ status: productRecord.status,
132
+ publishedAt: productRecord.published_at,
133
+ liveStatus: LIVE_STATUS.product,
134
+ })
135
+ ) {
136
+ return { title: 'Product Not Found' };
137
+ }
128
138
 
129
139
  // Resolve image URL for OG Image
130
140
  let imageUrl = undefined;
@@ -147,6 +157,8 @@ export async function generateMetadata({ params }: ProductPageProps): Promise<Me
147
157
  .select('language_id, slug')
148
158
  .eq('translation_group_id', productRecord.translation_group_id)
149
159
  .eq('status', 'active')
160
+ // Never advertise a scheduled translation via hreflang.
161
+ .or(buildPublishedAtOrFilter())
150
162
  ]);
151
163
 
152
164
  const { data: languages } = languagesResult;
@@ -225,11 +237,23 @@ export default async function ProductPage({ params }: ProductPageProps) {
225
237
  const { data: product } = await getProductBySlug(supabase, slug, preferredLocale);
226
238
  let productRecord = product as any;
227
239
 
228
- if (!productRecord || productRecord.status !== 'active') {
240
+ const draft = await draftMode();
241
+
242
+ // Draft mode is how the CMS previews a product before it is public, so it must
243
+ // reach draft and scheduled rows — everyone else only sees active products whose
244
+ // go-live moment has passed.
245
+ if (
246
+ !productRecord ||
247
+ (!draft.isEnabled &&
248
+ !isPubliclyVisible({
249
+ status: productRecord.status,
250
+ publishedAt: productRecord.published_at,
251
+ liveStatus: LIVE_STATUS.product,
252
+ }))
253
+ ) {
229
254
  notFound();
230
255
  }
231
256
 
232
- const draft = await draftMode();
233
257
  const visualEditingEnabled =
234
258
  draft.isEnabled || process.env.NEXTBLOCK_VISUAL_EDITING_ENABLED === 'true';
235
259
 
@@ -1018,7 +1018,10 @@ function VisualEditingToolbar() {
1018
1018
  return;
1019
1019
  }
1020
1020
 
1021
- setMessage("Draft published.");
1021
+ // A warning means the content is live but the revision didn't record — still a
1022
+ // publish, so the editor closes, but don't report it as a clean one.
1023
+ const warning = result && "success" in result ? result.warning : undefined;
1024
+ setMessage(warning ?? "Draft published.");
1022
1025
  hasSavedSinceOpenRef.current = false;
1023
1026
  closeVisualEditor();
1024
1027
  router.refresh();
@@ -100,6 +100,7 @@ Defined primarily in `00000000000002_setup_content_tables.sql`:
100
100
  - `navigation_items`
101
101
  - `page_revisions`
102
102
  - `post_revisions`
103
+ - `product_revisions` (added in `00000000000016`, alongside `products.version`)
103
104
 
104
105
  ### Commerce tables
105
106
 
@@ -179,8 +180,12 @@ tree. The current sequence is:
179
180
 
180
181
  Every file is fully idempotent. Existing databases already have versions
181
182
  `000`–`003` recorded, so both appliers skip the baseline — it only runs on a
182
- fresh/empty database. **The next new migration is `00000000000004`**, appended
183
- forward-only.
183
+ fresh/empty database.
184
+
185
+ `00000000000004` was the first migration appended after that re-baseline, not the
186
+ one still to be written — the folder has grown well past it. **To find the next
187
+ number, list `libs/db/src/supabase/migrations` and take the one after the highest
188
+ file on disk.** Never copy a hardcoded "next is N" out of a doc.
184
189
 
185
190
  ### Production migration policy
186
191
 
@@ -193,7 +198,13 @@ production or shared database change.
193
198
  `libs/db/src/supabase/migrations` for each new schema/data change.
194
199
  - Keep migrations non-destructive by default. Avoid dropping or rewriting data
195
200
  that may include orders, users, payments, or customer records.
196
- - Run `npm run db:migrate:check` before `npm run db:migrate`.
201
+ - Run `npm run db:migrate:check` before `npm run db:migrate`. **Read its pending
202
+ list** — do not just look for a success line. If you added a migration and the
203
+ check reports `Pending: 0`, that file will never run (see below).
204
+ - **Supabase matches migration history by version only, never by content.** A file
205
+ whose 14-digit version is already recorded remotely is skipped in silence — no
206
+ error, no output. That is why the check prints the pending list and warns when a
207
+ version is recorded remotely with no local file behind it.
197
208
  - If an existing database lists old baseline files such as
198
209
  `00000000000000_baseline_schema.sql` as pending, do not replay them. Use
199
210
  `npm run db:migrate:repair-history:check`, then
@@ -202,6 +213,30 @@ production or shared database change.
202
213
  rerun `npm run db:migrate:check`.
203
214
  - Use `npm run db:migrate:fresh` only for a brand-new empty database.
204
215
 
216
+ #### Why `db:migrate:check` is read-only by construction
217
+
218
+ On 2026-08-10 the check applied migration `00000000000017` to the production
219
+ project while printing `DRY RUN: migrations will *not* be pushed` and `Dry run
220
+ complete. No database changes were applied.` The `--check` path then ran
221
+ `supabase link --yes` followed by `supabase db push --dry-run` (Supabase CLI
222
+ v2.107); which of the two executed the SQL was never established, and the decisive
223
+ probe would have written a row to the production migration history.
224
+
225
+ `tools/scripts/push-db-migrations.js` no longer runs either on the check path. It
226
+ now runs only `supabase migration list` — a pure read — and derives the pending set
227
+ by diffing local files against remote history. Consequences worth keeping:
228
+
229
+ - The check links nothing. An unlinked repo is told to run `supabase link` itself
230
+ rather than having project state written underneath a command called "check".
231
+ - The check needs no `SUPABASE_ACCESS_TOKEN`, because only linking did.
232
+ - The apply path derives its baseline-replay guard from the same read instead of
233
+ regex-scraping `db push --dry-run` output, and returns early when nothing is
234
+ pending, so `db push` is never invoked without work to do.
235
+ - `parseMigrationList` is unit-tested in `tools/scripts/push-db-migrations.test.ts`.
236
+
237
+ If a future CLI upgrade tempts you back toward `db push --dry-run` for previewing:
238
+ don't. A command named `check` must not be able to write.
239
+
205
240
  ### Category map
206
241
 
207
242
  | Migration file | Domain | What it covers |