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
@@ -1060,6 +1060,157 @@ Unsplash has strict usage rules; Pexels' license is permissive (attribution opti
1060
1060
  - `importExternalImageToMedia` (`apps/nextblock/app/cms/media/import-external-image.ts`, ADMIN/WRITER) downloads an external image (SSRF-guarded, 15MB/15s caps), measures it with `sharp`, generates a blur placeholder, uploads to R2/Supabase Storage via the shared storage provider, and records it with `recordMediaUpload`. Returns `{ media_id, object_key, width, height, url, blur_data_url }`.
1061
1061
  - Editor UX: `ImageBlockEditor` and `BackgroundSelector` accept a pasted image URL and show a **Save to media library** action that swaps the external URL for a permanent optimized media reference (or the author can replace it with their own uploaded asset).
1062
1062
 
1063
+ ## MCP Server (external client access)
1064
+
1065
+ Cortex AI is dual-access. Alongside the in-app BYOK path (dashboard chat + inline
1066
+ editor), the same tool registry is exposed over the **Model Context Protocol** at
1067
+ `/api/mcp`, so Claude Code, Claude Desktop, Cursor, and VS Code can operate the CMS
1068
+ from inside the editor.
1069
+
1070
+ ### Files
1071
+
1072
+ | File | Purpose |
1073
+ | --- | --- |
1074
+ | `libs/cortex/src/lib/mcp-server.ts` | Transport-agnostic JSON-RPC 2.0 engine. No `next` imports, so it is unit-testable. |
1075
+ | `libs/cortex/src/lib/mcp-tool-registry.ts` | Zod→JSON Schema conversion, read/write scope table, MCP-contract aliases, tool dispatch, resources, prompts. |
1076
+ | `libs/cortex/src/lib/mcp-tokens.ts` | Token mint/hash/verify, MCP settings resolver, localhost-trust rules. |
1077
+ | `libs/cortex/src/lib/mcp-server.test.ts` | 33 tests across tokens, registry, and protocol. |
1078
+ | `apps/nextblock/app/api/mcp/route.ts` | Streamable HTTP shim + hybrid auth + tool-context construction. |
1079
+ | `apps/nextblock/app/cms/settings/cortex-ai/mcp-actions.ts` | Admin server actions: settings, mint, revoke. |
1080
+ | `apps/nextblock/app/cms/settings/cortex-ai/McpServerSettingsCard.tsx` | Settings UI + copy-paste client config. |
1081
+ | `apps/nextblock/app/cms/settings/cortex-ai/require-admin.ts` | Shared admin gate (also used by `actions.ts`). |
1082
+ | `libs/db/src/supabase/migrations/00000000000017_cortex_ai_mcp_server.sql` | `mcp_access_tokens` table + `cortex_ai_mcp_settings` RLS. |
1083
+
1084
+ ### Protocol decisions
1085
+
1086
+ **Hand-rolled, not `@modelcontextprotocol/sdk`.** The needed surface (initialize,
1087
+ tools/list, tools/call, resources/*, prompts/*, ping) is small and declarative. The v1
1088
+ SDK pulls in `express`, `cors`, `hono`, and `@hono/node-server` — heavy transitive
1089
+ weight for a publishable lib whose only peer dependency is `next` — and its default
1090
+ `StreamableHTTPServerTransport` is built on Node `IncomingMessage`/`ServerResponse`
1091
+ rather than the Web `Request`/`Response` an App Router handler receives.
1092
+
1093
+ **Dual-era.** The spec forked: `2026-07-28` is stateless (no `initialize`, no session
1094
+ id, protocol metadata in a per-request `_meta` envelope), while everything through
1095
+ `2025-11-25` is handshake-based. As of 2026-08 every shipping client is legacy-era, so
1096
+ that path must work; the modern path is detected and served too. Because the server is
1097
+ stateless either way, supporting both costs nothing.
1098
+
1099
+ Deliberate behaviours, each of which breaks a real client if changed:
1100
+
1101
+ - **Notifications get `202 Accepted` with an empty body.** Returning a JSON-RPC
1102
+ envelope for a message with no `id` desyncs strict clients.
1103
+ - **GET returns `405`.** The server never initiates requests or pushes unsolicited
1104
+ notifications, so there is no stream to open. The spec explicitly allows 405 here.
1105
+ - **401 carries a bare `WWW-Authenticate: Bearer`.** Adding a `resource_metadata`
1106
+ parameter (or serving `/.well-known/oauth-protected-resource`) advertises RFC 9728
1107
+ OAuth discovery, and Claude Code responds by starting an OAuth flow that dead-ends
1108
+ against a static-token server.
1109
+ - **Tool failures are `isError: true` on a *successful* result**, not JSON-RPC errors.
1110
+ Only unknown-tool and scope denial use the error channel, because those are the
1111
+ faults a model cannot fix by retrying with different arguments.
1112
+ - **`inputSchema` is always a JSON Schema object** with `$schema` stripped (MCP defines
1113
+ the dialect; some clients reject the extra key). Converted with `io: 'input'` so
1114
+ `.default()` fields stay optional.
1115
+ - **Array bodies are rejected.** JSON-RPC batching was removed in `2025-06-18`.
1116
+ - **`Origin` is validated when present** (DNS-rebinding defence, a spec MUST) and
1117
+ answered with 403. Native clients send no Origin, so absence is allowed.
1118
+
1119
+ ### Authentication
1120
+
1121
+ Three accepted paths, in priority order, all gated behind
1122
+ `verifyPackageOnline('cortex-ai')` and the `enabled` setting:
1123
+
1124
+ 1. **Bearer token** from `public.mcp_access_tokens` — what every external client uses.
1125
+ 2. **Authenticated ADMIN cookie session** — lets the dashboard reach the endpoint
1126
+ without minting a token.
1127
+ 3. **Loopback in development** — only when `allowLocalhostWithoutToken` is on *and*
1128
+ `NODE_ENV !== 'production'`. Behind a proxy the `Host` header is attacker-
1129
+ controllable, so localhost trust is a development affordance only.
1130
+
1131
+ Tokens are stored as **SHA-256 hashes**; the plaintext (`nbmcp_` + 256 bits base64url)
1132
+ is shown once at mint time and is unrecoverable. This differs from the OpenRouter BYOK
1133
+ key on purpose: that key must be handed back to OpenRouter, so it needs a reversible
1134
+ envelope, whereas an MCP token only ever needs to be *compared*. `token_prefix` is a
1135
+ non-secret display fragment. Revocation is a tombstone (`revoked_at`), which keeps the
1136
+ hash in the unique index so the same value can never be re-minted.
1137
+
1138
+ The minted token is returned through a **server action return value**, never a redirect
1139
+ query string — a `?success=<token>` would land in browser history, the referrer header,
1140
+ and the server access log.
1141
+
1142
+ ### Scopes
1143
+
1144
+ `CORTEX_MCP_TOOL_KINDS` classifies all 29 registry tools as `read` or `write`. A
1145
+ read-only token does not merely get refused on a write — the mutating tools are absent
1146
+ from its `tools/list` entirely, aliases included.
1147
+
1148
+ The table is **exhaustive by construction**: `assertCortexMcpToolCoverage` compares its
1149
+ keys against the live factory output, and a unit test fails if they diverge. An
1150
+ unclassified tool is *withheld*, never defaulted to `read`, so adding a tool to the
1151
+ agent without classifying it is a loud failure rather than a silent hole.
1152
+
1153
+ ### Confirmation is skipped over MCP
1154
+
1155
+ The in-app two-phase confirm matches a phrase in the user's *next chat message*, which
1156
+ has no analogue in MCP — there is no channel to carry a human phrase back between a
1157
+ tool call and its result. Every MCP host already gates tool calls behind its own
1158
+ approval UI, so leaving it on would just make every mutating tool return a preview
1159
+ forever. The real control is the token scope. `ToolExecutionContext.skipConfirmation`
1160
+ is therefore `true` for all MCP calls.
1161
+
1162
+ ### MCP-contract tool names
1163
+
1164
+ Five names are exposed as aliases forwarding to existing executors, so external clients
1165
+ get the documented contract without forking tested code. The canonical names remain
1166
+ listed too, and each alias description begins with "Alias of `<canonical>`" so a model
1167
+ does not call both.
1168
+
1169
+ | MCP name | Forwards to |
1170
+ | --- | --- |
1171
+ | `get_database_schema` | `describe_database_schema` |
1172
+ | `generate_jsonb_layout` | `rewrite_page_draft` (stages a Live Draft; nothing goes live unpublished) |
1173
+ | `query_site_analytics` | `fetch_ecommerce_stats` |
1174
+ | `update_site_navigation` | `update_navigation_bar` |
1175
+ | `search_stock_media` | `search_stock_photos` |
1176
+
1177
+ ### Resources and prompts
1178
+
1179
+ Resources: `cortex://schema/database`, `cortex://schema/blocks`,
1180
+ `cortex://schema/custom-blocks`. Prompts: `build-page`, `clone-from-url`,
1181
+ `translate-content`.
1182
+
1183
+ ### Settings and client configuration
1184
+
1185
+ `/cms/settings/cortex-ai` gains an "MCP server access" card: enable/disable, localhost
1186
+ trust, token mint/revoke, and copy-paste config for all four clients. **The server is
1187
+ disabled by default** — it is a remote write surface onto live content, so it must be
1188
+ an explicit opt-in.
1189
+
1190
+ Client config differs in ways that silently no-op if copied wrong, which is why the UI
1191
+ generates each one rather than documenting a single snippet:
1192
+
1193
+ - **Claude Code** — `mcpServers`, and `"type": "http"` is *required* (a `url` with no
1194
+ `type` is a hard error that skips the server).
1195
+ - **Cursor** — `mcpServers`, infers transport from `url`, no `type` needed.
1196
+ - **VS Code** — top-level `servers`, **not** `mcpServers`, and prompts for the token
1197
+ via `inputs` rather than storing it.
1198
+ - **Claude Desktop** — `claude_desktop_config.json` is stdio-only, so a remote server
1199
+ needs either the Connectors UI (which dials out from Anthropic's cloud, so localhost
1200
+ and firewalled sites will not connect) or the `mcp-remote` stdio bridge.
1201
+
1202
+ ### Related hardening
1203
+
1204
+ `read_database_records` previously filtered only `cortex_ai_openrouter_api_key` from
1205
+ `site_settings`. The `isSensitiveKey` heuristic inspects *column names*, and a
1206
+ site_settings row is `{ key, value }` — neither name trips it, so the stock-photo and
1207
+ payment/email secret rows passed through. That was low-risk while the tool was
1208
+ dashboard-only; exposing it to remote MCP clients widened it. `ai-global-agent-db-tools.ts`
1209
+ now carries `PROTECTED_SITE_SETTING_KEYS`, redacted on read and refused on write.
1210
+
1211
+ `mcp_access_tokens` is deliberately **absent** from `tableConfigs`, so the generic DB
1212
+ tools cannot read token hashes or insert rows.
1213
+
1063
1214
  ## Advanced Agent Settings
1064
1215
 
1065
1216
  The global agent's model limits are admin-tunable from `/cms/settings/cortex-ai` (collapsible "Advanced settings"), stored as a non-secret JSON `site_settings` row `cortex_ai_agent_settings` and read by the route via `resolveCortexAiAgentSettings(supabase)` (defaults + clamping in `normalizeCortexAiAgentSettings`, `libs/cortex/src/lib/ai-config.ts`):
@@ -22,10 +22,12 @@ import {
22
22
  import {
23
23
  createPageRevision,
24
24
  createPostRevision,
25
+ createProductRevision,
25
26
  } from "../../app/cms/revisions/service";
26
27
  import {
27
28
  getFullPageContent,
28
29
  getFullPostContent,
30
+ getFullProductContent,
29
31
  type FullPageContent,
30
32
  type FullPostContent,
31
33
  } from "../../app/cms/revisions/utils";
@@ -1385,6 +1387,12 @@ async function applyProductImport(params: {
1385
1387
  throw new Error(`Failed to save product draft from row ${item.rowNumber}: ${error.message}`);
1386
1388
  }
1387
1389
  } else {
1390
+ // Live mode writes the product row and its blocks directly, so it has to record a
1391
+ // revision itself — exactly as applyContentImport does for pages and posts. Without
1392
+ // this the content changes underneath the revision chain, and the next diff taken
1393
+ // against it would replay onto a document that no longer matches.
1394
+ const previousContent = await getFullProductContent(productId);
1395
+
1388
1396
  const { error } = await auth.supabase
1389
1397
  .from("products")
1390
1398
  .update(toLiveProductPayload(item.meta) as any)
@@ -1407,6 +1415,11 @@ async function applyProductImport(params: {
1407
1415
  await syncCategoriesForTranslationGroup(auth.supabase as any, productId, item.categoryIds);
1408
1416
  }
1409
1417
  await (auth.supabase as any).from("product_drafts").delete().eq("product_id", productId);
1418
+
1419
+ const nextContent = await getFullProductContent(productId);
1420
+ if (previousContent && nextContent) {
1421
+ await createProductRevision(productId, auth.userId, previousContent, nextContent);
1422
+ }
1410
1423
  }
1411
1424
 
1412
1425
  revalidatePath("/cms/products");
@@ -638,6 +638,7 @@ async function rewriteMediaBaseUrls(params: {
638
638
  { table: "products", idColumn: "id", columns: ["description_json", "metadata"] },
639
639
  { table: "page_revisions", idColumn: "id", columns: ["content"] },
640
640
  { table: "post_revisions", idColumn: "id", columns: ["content"] },
641
+ { table: "product_revisions", idColumn: "id", columns: ["content"] },
641
642
  { table: "site_settings", idColumn: "key", columns: ["value"] },
642
643
  { table: "translations", idColumn: "key", columns: ["translations"] },
643
644
  ];
@@ -0,0 +1,26 @@
1
+ /**
2
+ * Build a link to the `/api/view` entry point used by the CMS "Preview" and
3
+ * "View Live" buttons.
4
+ *
5
+ * Always pass the content's own language. The public site resolves language from
6
+ * a cookie, not the URL, so a link without `lang` renders in whatever language
7
+ * the editor happens to be browsing in — which is how opening a French page
8
+ * landed you on the English one. See `app/api/view/route.ts`.
9
+ */
10
+ export function buildViewUrl(options: {
11
+ /** Root-relative public path, e.g. "/about" or "/article/hello". */
12
+ path: string;
13
+ /** The content row's language code, e.g. "fr". */
14
+ languageCode?: string | null;
15
+ /** True to enter Live Draft Mode (preview unpublished content). */
16
+ draft?: boolean;
17
+ }): string {
18
+ const params = new URLSearchParams({ path: options.path });
19
+ if (options.languageCode) {
20
+ params.set("lang", options.languageCode);
21
+ }
22
+ if (options.draft) {
23
+ params.set("draft", "1");
24
+ }
25
+ return `/api/view?${params.toString()}`;
26
+ }
@@ -1,6 +1,7 @@
1
1
  import 'server-only';
2
2
 
3
3
  import { getSsgSupabaseClient, verifyPackageOnline } from '@nextblock-cms/db/server';
4
+ import { buildPublishedAtOrFilter } from '@nextblock-cms/utils';
4
5
  import { resolveMediaUrl } from '../media/resolveMediaUrl';
5
6
  import { getHomepageTranslationGroupId } from '../../app/lib/homepage';
6
7
  import type {
@@ -348,6 +349,7 @@ async function fetchPages(languageId: number | null): Promise<SearchCandidate[]>
348
349
  `
349
350
  )
350
351
  .eq('status', 'published')
352
+ .or(buildPublishedAtOrFilter())
351
353
  .order('updated_at', { ascending: false })
352
354
  .limit(CANDIDATE_LIMIT);
353
355
 
@@ -487,6 +489,7 @@ async function fetchProducts(languageId: number | null): Promise<SearchCandidate
487
489
  `
488
490
  )
489
491
  .eq('status', 'active')
492
+ .or(buildPublishedAtOrFilter())
490
493
  .order('updated_at', { ascending: false })
491
494
  .limit(CANDIDATE_LIMIT);
492
495
 
@@ -88,5 +88,20 @@ export const MIGRATIONS_BUNDLE: BundledMigration[] = [
88
88
  "version": "00000000000014",
89
89
  "name": "00000000000014_site_themes.sql",
90
90
  "sql": "-- Editable site themes.\n--\n-- Themes used to be hardcoded CSS classes in libs/ui/src/styles/theme.css\n-- (:root / .dark / .vibrant) with the switcher list duplicated in\n-- apps/nextblock/app/providers.tsx and components/theme-switcher.tsx. This moves\n-- the palette into the database so an ADMIN can retint the site, add themes and\n-- remove them from /cms/settings/global-css without a redeploy.\n--\n-- Rendering: apps/nextblock/lib/themes/buildThemeCss.ts turns each row into a\n-- `:root.<slug> { --token: value; ... }` rule injected into <head> by\n-- app/layout.tsx. The `:root.x` form is two-class specificity, so generated\n-- themes always beat the (0,1,0) fallback rules still shipped in theme.css for\n-- consumers of the published @nextblock-cms/ui package.\n--\n-- Forward-only and idempotent.\n\nCREATE TABLE IF NOT EXISTS public.site_themes (\n id uuid DEFAULT gen_random_uuid() NOT NULL,\n slug text NOT NULL,\n name text NOT NULL,\n description text,\n -- lucide-react icon name rendered by the theme switcher.\n icon text NOT NULL DEFAULT 'Palette',\n -- Drives the CSS `color-scheme` property and decides whether the theme also\n -- carries Tailwind's `.dark` class so `dark:` variants resolve correctly.\n color_scheme text NOT NULL DEFAULT 'light',\n -- Flat map of design token -> raw CSS value, keys WITHOUT the leading `--`,\n -- e.g. {\"background\": \"0 0% 100%\", \"radius\": \"0.75rem\"}.\n tokens jsonb NOT NULL DEFAULT '{}'::jsonb,\n -- Optional per-theme CSS, emitted nested inside the theme rule so it is\n -- automatically scoped. Authors use the `&` nesting selector,\n -- e.g. `& h1 { text-shadow: 0 0 5px hsl(var(--primary)); }`.\n extra_css text,\n -- System themes cannot be deleted: next-themes' `enableSystem` resolves to\n -- 'light' or 'dark', so those two slugs must always exist.\n is_system boolean DEFAULT false NOT NULL,\n is_default boolean DEFAULT false NOT NULL,\n is_active boolean DEFAULT true NOT NULL,\n sort_order integer DEFAULT 0 NOT NULL,\n created_at timestamp with time zone DEFAULT now() NOT NULL,\n updated_at timestamp with time zone DEFAULT now() NOT NULL,\n CONSTRAINT site_themes_pkey PRIMARY KEY (id),\n CONSTRAINT site_themes_slug_key UNIQUE (slug),\n CONSTRAINT site_themes_color_scheme_check CHECK ((color_scheme = ANY (ARRAY['light'::text, 'dark'::text]))),\n -- The slug becomes a CSS class and a next-themes value; keep it safe for both.\n CONSTRAINT site_themes_slug_format_check CHECK ((slug ~ '^[a-z][a-z0-9-]{0,38}[a-z0-9]$')),\n CONSTRAINT site_themes_tokens_is_object_check CHECK ((jsonb_typeof(tokens) = 'object'))\n);\n\nCOMMENT ON TABLE public.site_themes IS 'Editable colour themes. Each row renders to a `:root.<slug>` CSS rule injected by the root layout. Publicly readable (anonymous visitors need the palette); only ADMIN may write.';\n\nCREATE INDEX IF NOT EXISTS site_themes_active_sort_idx ON public.site_themes USING btree (is_active, sort_order);\n\n-- Exactly one default theme.\nCREATE UNIQUE INDEX IF NOT EXISTS site_themes_single_default_idx ON public.site_themes USING btree (is_default) WHERE (is_default = true);\n\nDROP TRIGGER IF EXISTS set_site_themes_updated_at ON public.site_themes;\nCREATE TRIGGER set_site_themes_updated_at\n BEFORE UPDATE ON public.site_themes\n FOR EACH ROW EXECUTE FUNCTION public.set_current_timestamp_updated_at();\n\n-- A system theme must never be deleted, whoever asks.\nCREATE OR REPLACE FUNCTION public.prevent_system_theme_delete() RETURNS trigger\n LANGUAGE plpgsql\n SET search_path = ''\n AS $$\nBEGIN\n IF OLD.is_system THEN\n RAISE EXCEPTION 'Theme \"%\" is a system theme and cannot be deleted', OLD.slug\n USING ERRCODE = 'restrict_violation';\n END IF;\n RETURN OLD;\nEND;\n$$;\n\nDROP TRIGGER IF EXISTS trg_prevent_system_theme_delete ON public.site_themes;\nCREATE TRIGGER trg_prevent_system_theme_delete\n BEFORE DELETE ON public.site_themes\n FOR EACH ROW EXECUTE FUNCTION public.prevent_system_theme_delete();\n\n-- Promoting a theme to default demotes the previous one, so the unique partial\n-- index above can never trip on a normal \"make this the default\" write.\nCREATE OR REPLACE FUNCTION public.handle_default_theme_change() RETURNS trigger\n LANGUAGE plpgsql\n SET search_path = ''\n AS $$\nBEGIN\n IF NEW.is_default THEN\n UPDATE public.site_themes\n SET is_default = false\n WHERE id <> NEW.id AND is_default;\n END IF;\n RETURN NEW;\nEND;\n$$;\n\nDROP TRIGGER IF EXISTS trg_handle_default_theme_change ON public.site_themes;\nCREATE TRIGGER trg_handle_default_theme_change\n AFTER INSERT OR UPDATE OF is_default ON public.site_themes\n FOR EACH ROW WHEN (NEW.is_default) EXECUTE FUNCTION public.handle_default_theme_change();\n\nALTER TABLE public.site_themes ENABLE ROW LEVEL SECURITY;\n\nGRANT ALL ON TABLE public.site_themes TO anon;\nGRANT ALL ON TABLE public.site_themes TO authenticated;\nGRANT ALL ON TABLE public.site_themes TO service_role;\n\nDROP POLICY IF EXISTS \"Public read active themes\" ON public.site_themes;\nCREATE POLICY \"Public read active themes\" ON public.site_themes\n FOR SELECT TO authenticated, anon USING (true);\n\nDROP POLICY IF EXISTS \"Admins insert themes\" ON public.site_themes;\nCREATE POLICY \"Admins insert themes\" ON public.site_themes\n FOR INSERT TO authenticated\n WITH CHECK (((SELECT public.get_current_user_role()) = 'ADMIN'::public.user_role));\n\nDROP POLICY IF EXISTS \"Admins update themes\" ON public.site_themes;\nCREATE POLICY \"Admins update themes\" ON public.site_themes\n FOR UPDATE TO authenticated\n USING (((SELECT public.get_current_user_role()) = 'ADMIN'::public.user_role))\n WITH CHECK (((SELECT public.get_current_user_role()) = 'ADMIN'::public.user_role));\n\nDROP POLICY IF EXISTS \"Admins delete themes\" ON public.site_themes;\nCREATE POLICY \"Admins delete themes\" ON public.site_themes\n FOR DELETE TO authenticated\n USING (((SELECT public.get_current_user_role()) = 'ADMIN'::public.user_role));\n\nDROP POLICY IF EXISTS \"Service role manages themes\" ON public.site_themes;\nCREATE POLICY \"Service role manages themes\" ON public.site_themes\n TO service_role USING (true) WITH CHECK (true);\n\n-- Seed the three shipped themes from libs/ui/src/styles/theme.css.\n-- `--warning` / `--warning-foreground` are declared in the Tailwind theme\n-- (libs/ui/tailwind.config.js) but were never defined in CSS, so bg-warning and\n-- text-warning resolved to an invalid colour. They are given real values here.\nINSERT INTO public.site_themes (slug, name, description, icon, color_scheme, is_system, is_default, sort_order, tokens, extra_css)\nVALUES\n (\n 'light', 'Light', 'Clean, technical, stark.', 'Sun', 'light', true, true, 10,\n '{\n \"background\": \"0 0% 100%\",\n \"foreground\": \"222 47% 11%\",\n \"card\": \"0 0% 100%\",\n \"card-foreground\": \"222 47% 11%\",\n \"popover\": \"0 0% 100%\",\n \"popover-foreground\": \"222 47% 11%\",\n \"primary\": \"211.55 50.26% 37.84%\",\n \"primary-foreground\": \"210 40% 98%\",\n \"secondary\": \"210 40% 96.1%\",\n \"secondary-foreground\": \"222 47% 11%\",\n \"muted\": \"210 40% 96.1%\",\n \"muted-foreground\": \"215 16% 47%\",\n \"accent\": \"210 40% 96.1%\",\n \"accent-foreground\": \"222 47% 11%\",\n \"destructive\": \"0 84.2% 60.2%\",\n \"destructive-foreground\": \"210 40% 98%\",\n \"warning\": \"38 92% 50%\",\n \"warning-foreground\": \"222 47% 11%\",\n \"border\": \"214.3 31.8% 91.4%\",\n \"input\": \"214.3 31.8% 91.4%\",\n \"ring\": \"211.55 50.26% 37.84%\",\n \"radius\": \"0.75rem\",\n \"chart-1\": \"211.55 50.26% 37.84%\",\n \"chart-2\": \"215 16% 47%\",\n \"chart-3\": \"215 25% 27%\",\n \"chart-4\": \"210 40% 96%\",\n \"chart-5\": \"214 32% 91%\"\n }'::jsonb,\n NULL\n ),\n (\n 'dark', 'Dark', 'Midnight / neon tech.', 'Moon', 'dark', true, false, 20,\n '{\n \"background\": \"222 47% 2%\",\n \"foreground\": \"210 40% 98%\",\n \"card\": \"222 47% 11%\",\n \"card-foreground\": \"210 40% 98%\",\n \"popover\": \"222 47% 11%\",\n \"popover-foreground\": \"210 40% 98%\",\n \"primary\": \"217 91% 60%\",\n \"primary-foreground\": \"222 47% 11%\",\n \"secondary\": \"217.2 32.6% 17.5%\",\n \"secondary-foreground\": \"210 40% 98%\",\n \"muted\": \"217.2 32.6% 17.5%\",\n \"muted-foreground\": \"215 20.2% 65.1%\",\n \"accent\": \"217.2 32.6% 17.5%\",\n \"accent-foreground\": \"210 40% 98%\",\n \"destructive\": \"0 62.8% 30.6%\",\n \"destructive-foreground\": \"210 40% 98%\",\n \"warning\": \"38 92% 50%\",\n \"warning-foreground\": \"222 47% 11%\",\n \"border\": \"217.2 32.6% 17.5%\",\n \"input\": \"217.2 32.6% 17.5%\",\n \"ring\": \"224 76% 48%\",\n \"radius\": \"0.75rem\",\n \"chart-1\": \"220 70% 50%\",\n \"chart-2\": \"160 60% 45%\",\n \"chart-3\": \"30 80% 55%\",\n \"chart-4\": \"280 65% 60%\",\n \"chart-5\": \"340 75% 55%\"\n }'::jsonb,\n NULL\n ),\n (\n 'vibrant', 'Vibrant', 'Cyberpunk neon.', 'Zap', 'dark', false, false, 30,\n '{\n \"background\": \"260 50% 5%\",\n \"foreground\": \"180 100% 90%\",\n \"card\": \"260 50% 8%\",\n \"card-foreground\": \"180 100% 90%\",\n \"popover\": \"260 50% 8%\",\n \"popover-foreground\": \"180 100% 90%\",\n \"primary\": \"320 100% 55%\",\n \"primary-foreground\": \"0 0% 100%\",\n \"secondary\": \"180 100% 50%\",\n \"secondary-foreground\": \"260 50% 5%\",\n \"muted\": \"260 30% 15%\",\n \"muted-foreground\": \"260 20% 65%\",\n \"accent\": \"280 100% 50%\",\n \"accent-foreground\": \"0 0% 100%\",\n \"destructive\": \"0 100% 50%\",\n \"destructive-foreground\": \"0 0% 100%\",\n \"warning\": \"60 100% 50%\",\n \"warning-foreground\": \"260 50% 5%\",\n \"border\": \"320 100% 55%\",\n \"input\": \"260 30% 15%\",\n \"ring\": \"320 100% 55%\",\n \"radius\": \"0px\",\n \"chart-1\": \"320 100% 55%\",\n \"chart-2\": \"180 100% 50%\",\n \"chart-3\": \"280 100% 50%\",\n \"chart-4\": \"60 100% 50%\",\n \"chart-5\": \"120 100% 50%\"\n }'::jsonb,\n '& h1, & h2, & h3, & h4, & h5, & h6 {\n text-shadow: 0 0 5px hsl(var(--primary)), 0 0 10px hsl(var(--secondary));\n}\n& button, & [role=\"button\"] {\n box-shadow: 0 0 5px hsl(var(--primary) / 0.5);\n transition: box-shadow 0.3s ease;\n}\n& button:hover, & [role=\"button\"]:hover {\n box-shadow: 0 0 15px hsl(var(--primary));\n}\n& .card, & [class*=\"card\"] {\n border: 1px solid hsl(var(--primary));\n box-shadow: 0 0 10px hsl(var(--primary) / 0.2);\n}\n& .border {\n border-color: hsl(var(--border));\n box-shadow: 0 0 5px hsl(var(--border) / 0.3);\n}'\n )\nON CONFLICT (slug) DO NOTHING;\n"
91
+ },
92
+ {
93
+ "version": "00000000000015",
94
+ "name": "00000000000015_scheduled_publishing.sql",
95
+ "sql": "-- Scheduled publishing for pages and products.\n--\n-- Posts already support scheduling: `posts.published_at` exists and every public\n-- read gates on `published_at IS NULL OR published_at <= now()`, so a row with\n-- status='published' and a future date is withheld until the date passes. Pages\n-- and products had no equivalent column, so \"go live on Tuesday\" was impossible\n-- for them. This adds the same column with the same semantics.\n--\n-- Visibility is derived from the (status, published_at) PAIR — no new enum value:\n--\n-- status = draft/archived -> not public, whatever the date\n-- status = published|active, published_at NULL -> public now\n-- status = published|active, date <= now() -> public now\n-- status = published|active, date > now() -> SCHEDULED (withheld)\n--\n-- Deriving \"scheduled\" instead of storing it keeps `page_status` unchanged (adding\n-- an enum value can't be done inside a transaction with other DDL in Postgres) and\n-- matches what posts have always done, so one code path covers all three types.\n--\n-- NULL is the safe default: every existing published row keeps rendering exactly as\n-- before, so this migration needs no backfill and changes no current behavior.\n--\n-- Forward-only and idempotent.\n\nALTER TABLE public.pages\n ADD COLUMN IF NOT EXISTS published_at timestamp with time zone;\n\nCOMMENT ON COLUMN public.pages.published_at IS\n 'Optional go-live moment. NULL = live as soon as status is published. A future value withholds the page from public reads until it passes (status stays \"published\"; the CMS renders that pair as \"Scheduled\").';\n\nALTER TABLE public.products\n ADD COLUMN IF NOT EXISTS published_at timestamp with time zone;\n\nCOMMENT ON COLUMN public.products.published_at IS\n 'Optional go-live moment. NULL = live as soon as status is active. A future value withholds the product from public reads until it passes (status stays \"active\"; the CMS renders that pair as \"Scheduled\").';\n\n-- Public listing/index queries filter on the (status, published_at) pair together\n-- (catalog, sitemap, page lookups), so a composite index serves them in one pass.\nCREATE INDEX IF NOT EXISTS pages_status_published_at_idx\n ON public.pages (status, published_at);\n\nCREATE INDEX IF NOT EXISTS products_status_published_at_idx\n ON public.products (status, published_at);\n"
96
+ },
97
+ {
98
+ "version": "00000000000016",
99
+ "name": "00000000000016_product_revisions_and_revision_baseline.sql",
100
+ "sql": "-- 00000000000016_product_revisions_and_revision_baseline.sql\n--\n-- Revision History, part 1 of 2 (schema). The application-side rewrite lives in\n-- apps/nextblock/app/cms/revisions/**.\n--\n-- Three things happen here:\n--\n-- 1. products.version — the monotonic counter the hybrid revision engine drives,\n-- mirroring pages.version / posts.version.\n--\n-- 2. product_revisions — a structural mirror of page_revisions / post_revisions.\n-- product_id is uuid (products.id is uuid, not bigint), and\n-- writes are gated on is_admin() to match products_*_policy\n-- rather than the ADMIN|WRITER pattern the page/post revision\n-- tables use. A WRITER who could insert a revision but not\n-- apply a restore would get a silent no-op restore, because\n-- PostgREST returns no error for an UPDATE matching zero rows.\n--\n-- 3. Revision baseline — every page, post and product gets a real `snapshot` row to\n-- restore to. Until now the CMS synthesised a fake \"Initial\n-- Version\" entry in the UI whose Restore button resolved to\n-- \"current metadata + zero blocks\" and wiped the content.\n-- There is now an actual stored baseline instead.\n--\n-- Case A (version = 1, no revisions at all): the live row IS\n-- version 1. This covers seeded content — 00000000000003\n-- inserts every page and post at version 1 and writes no\n-- revision rows — and everything authored since the CMS save\n-- path stopped recording revisions. Snapshotting it at\n-- version 1 is what makes \"restore the original seeded page\"\n-- real for the first time.\n--\n-- Case B (version > 1 but no snapshot at or below it): the\n-- true v1 is unrecoverable and is NOT fabricated. A snapshot\n-- of the current state is stored at the current version so the\n-- diff chain has a valid base and future restores resolve.\n--\n-- Forward-only, idempotent, and it modifies no existing row: every backfill is an\n-- INSERT ... WHERE NOT EXISTS ... ON CONFLICT DO NOTHING.\n\n-- ---------------------------------------------------------------------------\n-- 1. products.version\n-- ---------------------------------------------------------------------------\n\nALTER TABLE public.products\n ADD COLUMN IF NOT EXISTS version integer DEFAULT 1 NOT NULL;\n\nCOMMENT ON COLUMN public.products.version IS 'Monotonic version number for hybrid revisions.';\n\n-- ---------------------------------------------------------------------------\n-- 2. product_revisions\n-- ---------------------------------------------------------------------------\n\nCREATE TABLE IF NOT EXISTS public.product_revisions (\n id bigint NOT NULL,\n product_id uuid NOT NULL,\n author_id uuid,\n version integer NOT NULL,\n revision_type public.revision_type NOT NULL,\n content jsonb NOT NULL,\n created_at timestamp with time zone DEFAULT now() NOT NULL\n);\n\nCOMMENT ON TABLE public.product_revisions IS 'Hybrid (snapshot/diff) revisions for products.';\nCOMMENT ON COLUMN public.product_revisions.content IS 'If snapshot: full content; if diff: JSON Patch array.';\n\nDO $rb$\nBEGIN\n IF NOT EXISTS (\n SELECT 1 FROM pg_attribute\n WHERE attrelid = 'public.product_revisions'::regclass\n AND attname = 'id'\n AND attidentity <> ''\n ) THEN\n ALTER TABLE public.product_revisions\n ALTER COLUMN id ADD GENERATED BY DEFAULT AS IDENTITY (\n SEQUENCE NAME public.product_revisions_id_seq\n START WITH 1\n INCREMENT BY 1\n NO MINVALUE\n NO MAXVALUE\n CACHE 1\n );\n END IF;\nEND $rb$;\n\nDO $rb$\nBEGIN\n IF NOT EXISTS (SELECT 1 FROM pg_constraint\n WHERE conname = 'product_revisions_pkey'\n AND conrelid = 'public.product_revisions'::regclass) THEN\n ALTER TABLE ONLY public.product_revisions\n ADD CONSTRAINT product_revisions_pkey PRIMARY KEY (id);\n END IF;\nEND $rb$;\n\nDO $rb$\nBEGIN\n IF NOT EXISTS (SELECT 1 FROM pg_constraint\n WHERE conname = 'product_revisions_product_version_key'\n AND conrelid = 'public.product_revisions'::regclass) THEN\n ALTER TABLE ONLY public.product_revisions\n ADD CONSTRAINT product_revisions_product_version_key UNIQUE (product_id, version);\n END IF;\nEND $rb$;\n\nDO $rb$\nBEGIN\n IF NOT EXISTS (SELECT 1 FROM pg_constraint\n WHERE conname = 'product_revisions_author_id_fkey'\n AND conrelid = 'public.product_revisions'::regclass) THEN\n ALTER TABLE ONLY public.product_revisions\n ADD CONSTRAINT product_revisions_author_id_fkey\n FOREIGN KEY (author_id) REFERENCES public.profiles(id) ON DELETE SET NULL;\n END IF;\nEND $rb$;\n\nDO $rb$\nBEGIN\n IF NOT EXISTS (SELECT 1 FROM pg_constraint\n WHERE conname = 'product_revisions_product_id_fkey'\n AND conrelid = 'public.product_revisions'::regclass) THEN\n ALTER TABLE ONLY public.product_revisions\n ADD CONSTRAINT product_revisions_product_id_fkey\n FOREIGN KEY (product_id) REFERENCES public.products(id) ON DELETE CASCADE;\n END IF;\nEND $rb$;\n\nCREATE INDEX IF NOT EXISTS idx_product_revisions_author_id\n ON public.product_revisions USING btree (author_id);\n\nCREATE INDEX IF NOT EXISTS idx_product_revisions_product_id_version\n ON public.product_revisions USING btree (product_id, version);\n\nALTER TABLE public.product_revisions ENABLE ROW LEVEL SECURITY;\n\nDROP POLICY IF EXISTS product_revisions_read_policy ON public.product_revisions;\nCREATE POLICY product_revisions_read_policy ON public.product_revisions\n FOR SELECT TO authenticated USING (true);\n\nDROP POLICY IF EXISTS product_revisions_insert_policy ON public.product_revisions;\nCREATE POLICY product_revisions_insert_policy ON public.product_revisions\n FOR INSERT TO authenticated\n WITH CHECK (((SELECT public.is_admin() AS is_admin) IS TRUE));\n\nDROP POLICY IF EXISTS product_revisions_update_policy ON public.product_revisions;\nCREATE POLICY product_revisions_update_policy ON public.product_revisions\n FOR UPDATE TO authenticated\n USING (((SELECT public.is_admin() AS is_admin) IS TRUE))\n WITH CHECK (((SELECT public.is_admin() AS is_admin) IS TRUE));\n\nDROP POLICY IF EXISTS product_revisions_delete_policy ON public.product_revisions;\nCREATE POLICY product_revisions_delete_policy ON public.product_revisions\n FOR DELETE TO authenticated\n USING (((SELECT public.is_admin() AS is_admin) IS TRUE));\n\nGRANT ALL ON TABLE public.product_revisions TO anon;\nGRANT ALL ON TABLE public.product_revisions TO authenticated;\nGRANT ALL ON TABLE public.product_revisions TO service_role;\nGRANT ALL ON SEQUENCE public.product_revisions_id_seq TO anon;\nGRANT ALL ON SEQUENCE public.product_revisions_id_seq TO authenticated;\nGRANT ALL ON SEQUENCE public.product_revisions_id_seq TO service_role;\n\n-- ---------------------------------------------------------------------------\n-- 3. Revision baseline backfill\n--\n-- The JSON shape must match FullPageContent / FullPostContent / FullProductContent\n-- in apps/nextblock/app/cms/revisions/utils.ts exactly, or the first diff taken\n-- against a baseline row will be full of phantom operations.\n--\n-- Timestamps are rendered with an explicit millisecond-precision UTC format so they\n-- match JavaScript's Date#toISOString() (\"2026-07-03T17:52:15.643Z\"). Postgres'\n-- default jsonb rendering of timestamptz (\"2026-07-03T17:52:15.643901+00:00\") would\n-- differ from the value the application writes and produce a spurious diff on the\n-- very next save.\n-- ---------------------------------------------------------------------------\n\n-- 3a. Pages\nINSERT INTO public.page_revisions (page_id, author_id, version, revision_type, content)\nSELECT\n p.id,\n NULL::uuid,\n p.version,\n 'snapshot'::public.revision_type,\n jsonb_build_object(\n 'meta', jsonb_build_object(\n 'title', p.title,\n 'slug', p.slug,\n 'language_id', p.language_id,\n 'status', p.status,\n 'meta_title', p.meta_title,\n 'meta_description', p.meta_description,\n 'custom_canonical', p.custom_canonical,\n 'published_at', to_char(p.published_at AT TIME ZONE 'UTC', 'YYYY-MM-DD\"T\"HH24:MI:SS.MS\"Z\"'),\n 'feature_image_id', p.feature_image_id\n ),\n 'blocks', COALESCE((\n SELECT jsonb_agg(\n jsonb_build_object(\n 'language_id', b.language_id,\n 'block_type', b.block_type,\n 'content', b.content,\n 'order', b.\"order\"\n ) ORDER BY b.\"order\" ASC, b.id ASC\n )\n FROM public.blocks b\n WHERE b.page_id = p.id\n ), '[]'::jsonb)\n )\n FROM public.pages p\n WHERE NOT EXISTS (\n SELECT 1 FROM public.page_revisions r\n WHERE r.page_id = p.id\n AND r.revision_type = 'snapshot'\n AND r.version <= p.version\n )\nON CONFLICT (page_id, version) DO NOTHING;\n\n-- 3b. Posts\nINSERT INTO public.post_revisions (post_id, author_id, version, revision_type, content)\nSELECT\n po.id,\n NULL::uuid,\n po.version,\n 'snapshot'::public.revision_type,\n jsonb_build_object(\n 'meta', jsonb_build_object(\n 'title', po.title,\n 'slug', po.slug,\n 'language_id', po.language_id,\n 'status', po.status,\n 'meta_title', po.meta_title,\n 'meta_description', po.meta_description,\n 'custom_canonical', po.custom_canonical,\n 'published_at', to_char(po.published_at AT TIME ZONE 'UTC', 'YYYY-MM-DD\"T\"HH24:MI:SS.MS\"Z\"'),\n 'feature_image_id', po.feature_image_id,\n 'label', po.label,\n 'excerpt', po.excerpt,\n 'subtitle', po.subtitle\n ),\n 'blocks', COALESCE((\n SELECT jsonb_agg(\n jsonb_build_object(\n 'language_id', b.language_id,\n 'block_type', b.block_type,\n 'content', b.content,\n 'order', b.\"order\"\n ) ORDER BY b.\"order\" ASC, b.id ASC\n )\n FROM public.blocks b\n WHERE b.post_id = po.id\n ), '[]'::jsonb)\n )\n FROM public.posts po\n WHERE NOT EXISTS (\n SELECT 1 FROM public.post_revisions r\n WHERE r.post_id = po.id\n AND r.revision_type = 'snapshot'\n AND r.version <= po.version\n )\nON CONFLICT (post_id, version) DO NOTHING;\n\n-- 3c. Products.\n--\n-- Content only. price/prices/sale_*/scheduled_*/stock/sku/average_rating/total_reviews\n-- are deliberately excluded from the snapshot: pricing and inventory are mutated from\n-- outside the editor (promotions, Freemius sync, order fulfilment), ratings are derived\n-- aggregates, and inventory_items is keyed by bare SKU text with no FK to products — so\n-- replaying commerce state on restore would reach rows the editor never touched.\n-- Restoring a product restores its content, not its commerce state.\nINSERT INTO public.product_revisions (product_id, author_id, version, revision_type, content)\nSELECT\n pr.id,\n NULL::uuid,\n pr.version,\n 'snapshot'::public.revision_type,\n jsonb_build_object(\n 'meta', jsonb_build_object(\n 'title', pr.title,\n 'slug', pr.slug,\n 'language_id', pr.language_id,\n 'status', pr.status,\n 'meta_title', pr.meta_title,\n 'meta_description', pr.meta_description,\n 'custom_canonical', pr.custom_canonical,\n 'published_at', to_char(pr.published_at AT TIME ZONE 'UTC', 'YYYY-MM-DD\"T\"HH24:MI:SS.MS\"Z\"'),\n 'short_description', pr.short_description,\n 'description_json', pr.description_json\n ),\n 'blocks', COALESCE((\n SELECT jsonb_agg(\n jsonb_build_object(\n 'language_id', b.language_id,\n 'block_type', b.block_type,\n 'content', b.content,\n 'order', b.\"order\"\n ) ORDER BY b.\"order\" ASC, b.id ASC\n )\n FROM public.blocks b\n WHERE b.product_id = pr.id\n ), '[]'::jsonb)\n )\n FROM public.products pr\n WHERE NOT EXISTS (\n SELECT 1 FROM public.product_revisions r\n WHERE r.product_id = pr.id\n AND r.revision_type = 'snapshot'\n AND r.version <= pr.version\n )\nON CONFLICT (product_id, version) DO NOTHING;\n"
101
+ },
102
+ {
103
+ "version": "00000000000017",
104
+ "name": "00000000000017_cortex_ai_mcp_server.sql",
105
+ "sql": "-- Cortex AI MCP (Model Context Protocol) server access.\n--\n-- Adds the bearer-token store that gates /api/mcp, the endpoint that exposes the\n-- Cortex AI tool registry to external MCP clients (Claude Code, Claude Desktop,\n-- Cursor, VS Code). Two pieces:\n--\n-- 1. public.mcp_access_tokens — one row per issued token. We store ONLY the\n-- SHA-256 hash of the token, never the token itself: the plaintext is shown\n-- to the admin exactly once at mint time and is unrecoverable afterwards, so\n-- a database leak cannot be replayed against the MCP endpoint. `token_prefix`\n-- is the non-secret leading fragment kept purely so the UI can tell two tokens\n-- apart in a list.\n--\n-- 2. cortex_ai_mcp_settings — a non-secret JSON site_settings row holding the\n-- server on/off switch and the localhost-trust flag. It is added to all four\n-- site_settings policies so only authenticated ADMINs can read or write it;\n-- the MCP route itself reads it through the service-role client, which\n-- bypasses RLS.\n--\n-- Forward-only. Recreates the four site_settings policies idempotently, preserving\n-- every key already in each policy's sensitive array (note that\n-- language_detection_settings stays anon-READABLE and so is absent from the SELECT\n-- policy, exactly as migration 00000000000012 left it).\n\nCREATE TABLE IF NOT EXISTS public.mcp_access_tokens (\n id uuid PRIMARY KEY DEFAULT gen_random_uuid(),\n name text NOT NULL,\n -- Lowercase hex SHA-256 of the plaintext token. Unique so a lookup is a single\n -- indexed equality probe and duplicate mints are impossible.\n token_hash text NOT NULL UNIQUE,\n -- Non-secret display fragment, e.g. \"nbmcp_a1b2c3d4\". Never enough to authenticate.\n token_prefix text NOT NULL,\n -- 'read' grants the read-only tools; 'write' additionally grants the mutating ones.\n scopes text[] NOT NULL DEFAULT ARRAY['read', 'write']::text[],\n created_by uuid REFERENCES auth.users (id) ON DELETE SET NULL,\n created_at timestamptz NOT NULL DEFAULT now(),\n last_used_at timestamptz,\n expires_at timestamptz,\n revoked_at timestamptz\n);\n\nCOMMENT ON TABLE public.mcp_access_tokens IS\n 'Bearer tokens for the Cortex AI MCP server at /api/mcp. Stores SHA-256 hashes only; plaintext is displayed once at mint time.';\n\nCREATE INDEX IF NOT EXISTS mcp_access_tokens_token_hash_idx\n ON public.mcp_access_tokens (token_hash);\n\n-- Orders the admin token list newest-first without a sort.\nCREATE INDEX IF NOT EXISTS mcp_access_tokens_created_at_idx\n ON public.mcp_access_tokens (created_at DESC);\n\nALTER TABLE public.mcp_access_tokens ENABLE ROW LEVEL SECURITY;\n\n-- Tokens are credentials: admin-only, with no anon or WRITER access at all. The\n-- MCP route verifies them with the service-role client, which bypasses RLS.\nDROP POLICY IF EXISTS mcp_access_tokens_admin_select ON public.mcp_access_tokens;\nCREATE POLICY mcp_access_tokens_admin_select ON public.mcp_access_tokens\n FOR SELECT TO authenticated\n USING ((SELECT public.get_current_user_role()) = 'ADMIN'::public.user_role);\n\nDROP POLICY IF EXISTS mcp_access_tokens_admin_insert ON public.mcp_access_tokens;\nCREATE POLICY mcp_access_tokens_admin_insert ON public.mcp_access_tokens\n FOR INSERT TO authenticated\n WITH CHECK ((SELECT public.get_current_user_role()) = 'ADMIN'::public.user_role);\n\nDROP POLICY IF EXISTS mcp_access_tokens_admin_update ON public.mcp_access_tokens;\nCREATE POLICY mcp_access_tokens_admin_update ON public.mcp_access_tokens\n FOR UPDATE TO authenticated\n USING ((SELECT public.get_current_user_role()) = 'ADMIN'::public.user_role)\n WITH CHECK ((SELECT public.get_current_user_role()) = 'ADMIN'::public.user_role);\n\nDROP POLICY IF EXISTS mcp_access_tokens_admin_delete ON public.mcp_access_tokens;\nCREATE POLICY mcp_access_tokens_admin_delete ON public.mcp_access_tokens\n FOR DELETE TO authenticated\n USING ((SELECT public.get_current_user_role()) = 'ADMIN'::public.user_role);\n\nGRANT SELECT, INSERT, UPDATE, DELETE ON public.mcp_access_tokens TO authenticated;\nGRANT ALL ON public.mcp_access_tokens TO service_role;\n\n-- Add cortex_ai_mcp_settings to the admin-only site_settings group (all four policies).\nDROP POLICY IF EXISTS site_settings_read_policy ON public.site_settings;\nCREATE POLICY site_settings_read_policy ON public.site_settings FOR SELECT USING (((key <> ALL (ARRAY['cortex_ai_openrouter_api_key'::text, 'bot_protection_secret'::text, 'email_secret'::text, 'payment_secret'::text, 'cortex_ai_pexels_api_key'::text, 'cortex_ai_unsplash_access_key'::text, 'cortex_ai_mcp_settings'::text])) OR ((key = ANY (ARRAY['cortex_ai_openrouter_api_key'::text, 'bot_protection_secret'::text, 'email_secret'::text, 'payment_secret'::text, 'cortex_ai_pexels_api_key'::text, 'cortex_ai_unsplash_access_key'::text, 'cortex_ai_mcp_settings'::text])) AND (( SELECT auth.role() AS role) = 'authenticated'::text) AND (( SELECT public.get_current_user_role() AS get_current_user_role) = 'ADMIN'::public.user_role))));\n\nDROP POLICY IF EXISTS site_settings_insert_policy ON public.site_settings;\nCREATE 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])) 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])) AND (( SELECT public.get_current_user_role() AS get_current_user_role) = 'ADMIN'::public.user_role))));\n\nDROP POLICY IF EXISTS site_settings_update_policy ON public.site_settings;\nCREATE 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])) 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])) 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])) 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])) AND (( SELECT public.get_current_user_role() AS get_current_user_role) = 'ADMIN'::public.user_role))));\n\nDROP POLICY IF EXISTS site_settings_delete_policy ON public.site_settings;\nCREATE 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])) 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])) AND (( SELECT public.get_current_user_role() AS get_current_user_role) = 'ADMIN'::public.user_role))));\n"
91
106
  }
92
107
  ];
@@ -16,7 +16,10 @@ import type {
16
16
  } from "./types";
17
17
 
18
18
  export type VisualEditingMutationResult =
19
- | { success: true }
19
+ // `warning` marks a partial success: the content is live, but something non-blocking
20
+ // afterwards failed (today, recording the revision). Callers should surface it without
21
+ // treating the publish as failed.
22
+ | { success: true; warning?: string }
20
23
  | { error: string };
21
24
 
22
25
  export function isValidParentType(value: string): value is NextblockVisualDocumentType {
@@ -4,6 +4,8 @@ import type { Json } from "@nextblock-cms/db";
4
4
  import { createClient, getServiceRoleSupabaseClient } from "@nextblock-cms/db/server";
5
5
  import { updateProduct, syncProductSaleCouponToFreemius } from "@nextblock-cms/ecommerce/server";
6
6
  import { getCurrentUserCanEdit, normalizeDraftBlocks } from "./draft-content";
7
+ import { getFullProductContent } from "../../app/cms/revisions/utils";
8
+ import { createProductRevision } from "../../app/cms/revisions/service";
7
9
  import {
8
10
  formatVisualEditingError,
9
11
  requireVisualEditingEditableUser,
@@ -377,12 +379,32 @@ export async function publishProductVisualEditingDraft(
377
379
  }
378
380
 
379
381
  const draft = normalizeProductDraftRow(data);
382
+
383
+ // Captured before any write, so the revision records the real before/after pair.
384
+ const previousContent = await getFullProductContent(productId);
385
+
380
386
  const hasFullProductFormValues =
381
387
  draft.meta &&
382
388
  (typeof draft.meta.sku === "string" || typeof draft.meta.price === "number");
383
389
 
384
390
  if (hasFullProductFormValues) {
385
- await updateProduct(auth.supabase as any, productId, draft.meta as any);
391
+ // Visibility belongs to the row, not the draft: `upsert_product_with_variants`
392
+ // takes `status` as a required field, so publishing a draft that still carries
393
+ // an old status would move the product in or out of the storefront behind the
394
+ // editor's back. Pin it to whatever is live right now. (`published_at` is not
395
+ // part of the RPC payload, so the schedule survives untouched.)
396
+ const { data: liveProduct } = await (auth.supabase as any)
397
+ .from("products")
398
+ .select("status")
399
+ .eq("id", productId)
400
+ .maybeSingle();
401
+
402
+ const metaWithLiveVisibility = {
403
+ ...(draft.meta as any),
404
+ status: liveProduct?.status ?? (draft.meta as any)?.status,
405
+ };
406
+
407
+ await updateProduct(auth.supabase as any, productId, metaWithLiveVisibility);
386
408
  if ((draft.meta as any)?.payment_provider === "freemius") {
387
409
  try {
388
410
  await syncProductSaleCouponToFreemius({
@@ -445,6 +467,25 @@ export async function publishProductVisualEditingDraft(
445
467
  }
446
468
  }
447
469
 
470
+ // The product row and its blocks are already live at this point, so a failed revision
471
+ // is reported as a warning rather than aborting: returning early here would leave the
472
+ // draft row undeleted and the storefront un-revalidated.
473
+ let revisionWarning: string | null = null;
474
+ const nextContent = await getFullProductContent(productId);
475
+ if (previousContent && nextContent) {
476
+ const revision = await createProductRevision(
477
+ productId,
478
+ auth.user.id,
479
+ previousContent,
480
+ nextContent
481
+ );
482
+ if ("error" in revision) {
483
+ revisionWarning = revision.error;
484
+ }
485
+ } else {
486
+ revisionWarning = "the product content could not be read back";
487
+ }
488
+
448
489
  const { error: deleteError } = await (auth.supabase as any)
449
490
  .from("product_drafts")
450
491
  .delete()
@@ -460,6 +501,10 @@ export async function publishProductVisualEditingDraft(
460
501
  }
461
502
  revalidateVisualEditingPath(`/cms/products/${productId}/edit`);
462
503
 
504
+ if (revisionWarning) {
505
+ return { success: true, warning: `Published, but history was not recorded: ${revisionWarning}` };
506
+ }
507
+
463
508
  return { success: true };
464
509
  } catch (error) {
465
510
  return {
@@ -1,7 +1,7 @@
1
1
  /// <reference types="next" />
2
2
  /// <reference types="next/image-types/global" />
3
- import "./.next/dev/types/routes.d.ts";
4
- import "./.next/dev/types/root-params.d.ts";
3
+ import "./.next/types/routes.d.ts";
4
+ import "./.next/types/root-params.d.ts";
5
5
 
6
6
  // NOTE: This file should not be edited
7
7
  // see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nextblock-cms/template",
3
- "version": "0.14.4",
3
+ "version": "0.14.6",
4
4
  "private": true,
5
5
  "scripts": {
6
6
  "dev": "next dev",