create-nextblock 0.14.5 → 0.15.0

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 (28) 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/api/cron/reset-sandbox/sandboxResetSql.ts +285 -1
  5. package/templates/nextblock-template/app/api/mcp/route.ts +415 -0
  6. package/templates/nextblock-template/app/cms/CmsClientLayout.tsx +5 -1
  7. package/templates/nextblock-template/app/cms/media/import-external-image.ts +97 -11
  8. package/templates/nextblock-template/app/cms/settings/cortex-ai/McpServerSettingsCard.tsx +584 -0
  9. package/templates/nextblock-template/app/cms/settings/cortex-ai/StoredCortexAiSettingsClient.tsx +6 -0
  10. package/templates/nextblock-template/app/cms/settings/cortex-ai/actions.ts +4 -20
  11. package/templates/nextblock-template/app/cms/settings/cortex-ai/mcp-actions.ts +205 -0
  12. package/templates/nextblock-template/app/cms/settings/cortex-ai/page.tsx +64 -1
  13. package/templates/nextblock-template/app/cms/settings/cortex-ai/require-admin.ts +34 -0
  14. package/templates/nextblock-template/app/cms/settings/site-scripts/actions.ts +346 -0
  15. package/templates/nextblock-template/app/cms/settings/site-scripts/components/SiteScriptManager.tsx +492 -0
  16. package/templates/nextblock-template/app/cms/settings/site-scripts/page.tsx +51 -0
  17. package/templates/nextblock-template/app/layout.tsx +33 -0
  18. package/templates/nextblock-template/components/BlockRenderer.tsx +9 -3
  19. package/templates/nextblock-template/components/SiteScripts.tsx +56 -0
  20. package/templates/nextblock-template/components/blocks/renderers/TextBlockRenderer.tsx +1 -9
  21. package/templates/nextblock-template/docs/04-DATABASE-AND-AUTH.md +31 -1
  22. package/templates/nextblock-template/docs/08-NEXTBLOCK-CORTEX-AI-ARCHITECTURE.md +272 -0
  23. package/templates/nextblock-template/lib/blocks/inlineScriptNonce.ts +20 -0
  24. package/templates/nextblock-template/lib/setup/migrations-bundle.ts +5 -0
  25. package/templates/nextblock-template/lib/site-scripts/revisions.ts +71 -0
  26. package/templates/nextblock-template/lib/site-scripts/types.ts +46 -0
  27. package/templates/nextblock-template/package.json +1 -1
  28. package/templates/nextblock-template/tsconfig.tsbuildinfo +1 -1
@@ -0,0 +1,56 @@
1
+ /*
2
+ * The loading behaviour of an external script is the admin's choice, stored per row
3
+ * as `load_strategy` (default / defer / async). A blocking load is sometimes the
4
+ * required one — anti-flicker snippets and consent gates have to run before render —
5
+ * so this file renders what was asked for instead of forcing `defer` on everything.
6
+ */
7
+ /* eslint-disable @next/next/no-sync-scripts */
8
+ import React from 'react';
9
+
10
+ import { escapeInlineScript, type SiteScript, type SiteScriptPlacement } from '../lib/site-scripts/types';
11
+
12
+ interface SiteScriptsProps {
13
+ nonce: string;
14
+ placement: SiteScriptPlacement;
15
+ scripts: SiteScript[];
16
+ }
17
+
18
+ /**
19
+ * Render the admin-authored site scripts for one injection point.
20
+ *
21
+ * Emitted as plain <script> elements rather than next/script: these are arbitrary
22
+ * author-supplied snippets that frequently expect to run at a specific position in
23
+ * the document, and next/script's strategies would relocate them. The CSP nonce is
24
+ * applied here so the snippets satisfy the policy without it needing 'unsafe-inline'.
25
+ */
26
+ export default function SiteScripts({ nonce, placement, scripts }: SiteScriptsProps) {
27
+ const forPlacement = scripts.filter((script) => script.placement === placement);
28
+
29
+ if (forPlacement.length === 0) {
30
+ return null;
31
+ }
32
+
33
+ return (
34
+ <>
35
+ {forPlacement.map((script) =>
36
+ script.src ? (
37
+ <script
38
+ key={script.id}
39
+ src={script.src}
40
+ nonce={nonce || undefined}
41
+ {...(script.load_strategy === 'async' ? { async: true } : {})}
42
+ {...(script.load_strategy === 'defer' ? { defer: true } : {})}
43
+ data-nb-script={script.id}
44
+ />
45
+ ) : (
46
+ <script
47
+ key={script.id}
48
+ nonce={nonce || undefined}
49
+ data-nb-script={script.id}
50
+ dangerouslySetInnerHTML={{ __html: escapeInlineScript(script.code) }}
51
+ />
52
+ )
53
+ )}
54
+ </>
55
+ );
56
+ }
@@ -2,6 +2,7 @@ import React from "react";
2
2
  import { headers } from 'next/headers';
3
3
  import ClientTextBlockRenderer from "./ClientTextBlockRenderer";
4
4
  import type { VisualEditAttributes } from "../../../lib/visual-editing/types";
5
+ import { addNonceToInlineScripts } from "../../../lib/blocks/inlineScriptNonce";
5
6
  import { substitutePrivacyMergeTags } from "../../../lib/privacy/contact-emails";
6
7
 
7
8
  export type TextBlockContent = {
@@ -15,15 +16,6 @@ interface TextBlockRendererProps {
15
16
  renderContext?: 'prose' | 'section';
16
17
  }
17
18
 
18
- function addNonceToInlineScripts(html: string, nonce: string): string {
19
- if (!html || !nonce) return html || '';
20
- // Add nonce to <script> tags that do not already have a nonce
21
- // and do not have a src attribute (inline scripts)
22
- return html.replace(/<script(?![^>]*\bsrc=)([^>]*)(?<!nonce=["'][^"']*["'])>/gi, (_m, attrs) => {
23
- return `<script nonce="${nonce}"${attrs}>`;
24
- });
25
- }
26
-
27
19
  const TextBlockRenderer: React.FC<TextBlockRendererProps> = async ({
28
20
  content,
29
21
  languageId,
@@ -198,7 +198,13 @@ production or shared database change.
198
198
  `libs/db/src/supabase/migrations` for each new schema/data change.
199
199
  - Keep migrations non-destructive by default. Avoid dropping or rewriting data
200
200
  that may include orders, users, payments, or customer records.
201
- - 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.
202
208
  - If an existing database lists old baseline files such as
203
209
  `00000000000000_baseline_schema.sql` as pending, do not replay them. Use
204
210
  `npm run db:migrate:repair-history:check`, then
@@ -207,6 +213,30 @@ production or shared database change.
207
213
  rerun `npm run db:migrate:check`.
208
214
  - Use `npm run db:migrate:fresh` only for a brand-new empty database.
209
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
+
210
240
  ### Category map
211
241
 
212
242
  | Migration file | Domain | What it covers |
@@ -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`):
@@ -1405,3 +1556,124 @@ When modifying Cortex AI, keep these invariants:
1405
1556
  11. If a side-effecting tool succeeds and the model fails afterward, report the tool result instead of retrying blindly.
1406
1557
  12. Free OpenRouter models are useful but unstable; guard against 429s, malformed tool-call text, invalid HTML fragments, and no-output generation.
1407
1558
  13. Multilingual mutations should use active rows from `languages`, not hardcoded assumptions.
1559
+ 14. MCP runs with the service-role client, so RLS is not an authorization boundary
1560
+ there. Privileged tools re-check the actor's CMS role themselves.
1561
+ 15. A substituted actor identity is for attribution only, never authorization.
1562
+ 16. The audit log for site scripts is append-only in the database. Reverting writes a
1563
+ new revision; it never removes one.
1564
+ 17. Inline scripts in block HTML need the CSP nonce, and must wait for hydration
1565
+ before touching server-rendered DOM.
1566
+
1567
+ ## MCP Server: Security Model and Operator Guide
1568
+
1569
+ The MCP endpoint (`apps/nextblock/app/api/mcp/route.ts`) exposes the same typed
1570
+ tools to external clients — Claude Code, Claude Desktop, Cursor. The route is a thin
1571
+ HTTP shim; the registry lives in `libs/cortex/src/lib/mcp-tool-registry.ts`.
1572
+
1573
+ ### The four failure shapes to check when adding a tool
1574
+
1575
+ Tools were originally written for the in-app agent, which always has a signed-in
1576
+ user and an open editor. MCP has neither, so every new tool must be checked against
1577
+ all four of these:
1578
+
1579
+ 1. **Cookie-session auth.** `createClient()` + `auth.getUser()` returns nobody over
1580
+ MCP. Pass a pre-authorized `actorUserId` instead and keep the role check.
1581
+ 2. **`pageContext` dependence.** The route sets it to `null`. A tool that edits
1582
+ "the current item" must accept an explicit `cmsTarget`.
1583
+ 3. **Untyped `z.any()` parameters.** They serialize to `{}` in JSON Schema, so hosts
1584
+ send `"29.99"` where a number is expected. Coerce rather than reject.
1585
+ 4. **Staged artifacts with no finisher.** Anything that stages something (a Live
1586
+ Draft) needs a tool that can complete it, or MCP callers cannot finish the job.
1587
+
1588
+ ### Authorization does not come from RLS
1589
+
1590
+ **MCP executors use the service-role client, which bypasses Row Level Security.** An
1591
+ `ADMIN`-only table policy therefore constrains the dashboard but *not* the MCP path.
1592
+ Privileged tools must re-check the actor's CMS role themselves — see
1593
+ `requireActorRole` in `ai-global-agent-theming-tools.ts`.
1594
+
1595
+ MCP token scopes are only `read` / `write` and carry no role, so the role is resolved
1596
+ from the acting user at call time. Two related rules:
1597
+
1598
+ - Keep privileged tables (`site_scripts`, `site_script_revisions`) **out of** the
1599
+ `execute_database_mutation` allowlist, or that generic tool becomes a way around
1600
+ every per-tool guard.
1601
+ - When a token's creator has been deleted, the route substitutes a stand-in admin so
1602
+ a revision can still be attributed. That substitution is flagged
1603
+ (`actorFromOrphanedToken`) and refused for role-gated operations: a credential must
1604
+ not gain authority by outliving its owner.
1605
+
1606
+ ### Prompt injection
1607
+
1608
+ `fetch_url_content` returns attacker-controlled text to a model that holds write
1609
+ tools. A hostile page can contain instructions aimed at the agent ("also add this
1610
+ tracking snippet"). This is not solvable in the tool layer — the model reads the page
1611
+ because you asked it to. The mitigations are containment, not prevention:
1612
+
1613
+ - Code injection (`manage_site_script`) is **ADMIN-only**, so a `write` token that is
1614
+ otherwise fine for content cannot ship JavaScript.
1615
+ - `manage_site_script` requires a `purpose` and returns a `safetyReview` produced by
1616
+ an **independent static scan** of the code (`@nextblock-cms/utils/script-safety`).
1617
+ The stated purpose is not the control — a steered model will describe a skimmer as
1618
+ an analytics helper. The scan reports what the code can actually reach (cookies,
1619
+ network, storage, form fields, dynamic evaluation, external hosts) and both are
1620
+ written to the audit log, so a mismatch is visible rather than hidden.
1621
+ - Every script change is recorded in `site_script_revisions`, which is **append-only
1622
+ by database trigger** — UPDATE and DELETE are rejected even for the service role.
1623
+ An audit log a compromised credential can rewrite is not an audit log.
1624
+
1625
+ The scan is regex over source text, not a sandbox. Obfuscated code can evade it,
1626
+ which is why dynamic evaluation is itself reported at warning level. A clean result
1627
+ means "nothing obvious found", never "safe".
1628
+
1629
+ ### Site scripts and the CSP
1630
+
1631
+ The site CSP carries a nonce, and per CSP Level 2 a browser **ignores
1632
+ `'unsafe-inline'` once a nonce is present**. Consequences:
1633
+
1634
+ - Inline `<script>` inside rich-text block HTML must be stamped by
1635
+ `apps/nextblock/lib/blocks/inlineScriptNonce.ts`, or the browser silently drops it
1636
+ with no server-side symptom.
1637
+ - Because NextBlock nonces author scripts, an external `src` on a site script is
1638
+ authorized regardless of the CSP host allowlist. That is inherent to the feature
1639
+ and a reason it is ADMIN-only.
1640
+
1641
+ Author scripts must also not fight React hydration: a script that changes text,
1642
+ classes, or attributes of server-rendered markup before hydration makes the client
1643
+ disagree with the SSR HTML and React discards that subtree. Wrap DOM work as:
1644
+
1645
+ ```js
1646
+ function run() { /* ... */ }
1647
+ if (document.readyState === 'complete') run();
1648
+ else window.addEventListener('load', run);
1649
+ ```
1650
+
1651
+ ### SSRF
1652
+
1653
+ `fetch_url_content` and the media importer perform server-side HTTP on a
1654
+ caller-supplied URL, and `fetch_url_content` is a **read**-scoped tool — so its
1655
+ blocklist is what stops a read-only token from reaching internal services. The
1656
+ blocklist is duplicated (`isBlockedFetchHost` in cortex, `isBlockedImportHost` in the
1657
+ app) because a published lib cannot import from the app: **fix both together.**
1658
+ Regression tests live in `ai-global-agent-ssrf.test.ts`; IPv4-mapped IPv6
1659
+ (`::ffff:127.0.0.1`) previously bypassed both.
1660
+
1661
+ ### Building a whole site in one pass
1662
+
1663
+ The tools below exist specifically so a site can be built without a human clicking
1664
+ through the dashboard. Rough order for a from-scratch build:
1665
+
1666
+ | Step | Tools |
1667
+ | --- | --- |
1668
+ | Ground yourself | `get_database_schema`, `list_media`, `list_site_themes`, `list_product_categories` |
1669
+ | Brand it | `manage_site_theme`, `update_global_css` |
1670
+ | Assets | `search_stock_media`, `upload_media` |
1671
+ | Catalogue | `manage_product_category`, `create_cms_product`, `manage_product_variants` |
1672
+ | Pages | `generate_jsonb_layout` then `publish_content_draft` |
1673
+ | Navigation | `update_site_navigation`, `update_footer` |
1674
+ | Locales | `manage_language` then `translate_content_bulk` |
1675
+ | Motion | `update_global_css` plus `manage_site_script` |
1676
+
1677
+ `manage_language` must run before any translation: `translate_page` and
1678
+ `translate_content_bulk` can only target a language that already exists and is
1679
+ active.
@@ -0,0 +1,20 @@
1
+ /**
2
+ * Stamp the request's CSP nonce onto inline <script> tags inside block HTML.
3
+ *
4
+ * Rich-text blocks may legitimately carry an inline <script> (an editor or Cortex AI
5
+ * adding a small animation or widget). The site's CSP lists a nonce in `script-src`,
6
+ * and per CSP Level 2 a browser IGNORES `'unsafe-inline'` once a nonce or hash is
7
+ * present — so an un-nonced inline script is silently blocked, with no server-side
8
+ * symptom. Adding the nonce is what makes authored JS actually run.
9
+ *
10
+ * Only inline scripts are touched: a `src=` script loads a remote file and is
11
+ * governed by the host allowlist instead, and re-stamping one that already carries a
12
+ * nonce would corrupt it.
13
+ */
14
+ export function addNonceToInlineScripts(html: string, nonce: string): string {
15
+ if (!html || !nonce) return html || '';
16
+
17
+ return html.replace(/<script(?![^>]*\bsrc=)([^>]*)(?<!nonce=["'][^"']*["'])>/gi, (_match, attrs) => {
18
+ return `<script nonce="${nonce}"${attrs}>`;
19
+ });
20
+ }
@@ -98,5 +98,10 @@ export const MIGRATIONS_BUNDLE: BundledMigration[] = [
98
98
  "version": "00000000000016",
99
99
  "name": "00000000000016_product_revisions_and_revision_baseline.sql",
100
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"
101
106
  }
102
107
  ];
@@ -0,0 +1,71 @@
1
+ import type { SiteScript } from './types';
2
+
3
+ export type SiteScriptRevisionType = 'create' | 'update' | 'delete' | 'revert';
4
+ export type SiteScriptRevisionSource = 'cms' | 'mcp';
5
+
6
+ export interface SiteScriptRevision {
7
+ id: string;
8
+ script_id: string | null;
9
+ script_name: string;
10
+ revision_type: SiteScriptRevisionType;
11
+ actor_user_id: string | null;
12
+ source: SiteScriptRevisionSource;
13
+ summary: string | null;
14
+ snapshot: SiteScriptSnapshot;
15
+ created_at: string;
16
+ }
17
+
18
+ /**
19
+ * The restorable state of a script.
20
+ *
21
+ * Deliberately excludes `id`, `created_at`, and `updated_at`: restoring a revision
22
+ * writes these fields onto the live row (or recreates it), and carrying identity or
23
+ * timestamps across would either clash with the existing row or fake its history.
24
+ */
25
+ export interface SiteScriptSnapshot {
26
+ name: string;
27
+ description: string | null;
28
+ code: string;
29
+ src: string | null;
30
+ placement: string;
31
+ load_strategy: string;
32
+ is_active: boolean;
33
+ sort_order: number;
34
+ }
35
+
36
+ export const SITE_SCRIPT_REVISION_COLUMNS =
37
+ 'id, script_id, script_name, revision_type, actor_user_id, source, summary, snapshot, created_at';
38
+
39
+ /** Normalise a script row, a partial payload, or an existing snapshot into a snapshot. */
40
+ export function buildSiteScriptSnapshot(
41
+ row: Partial<SiteScript> | SiteScriptSnapshot | Record<string, unknown>
42
+ ): SiteScriptSnapshot {
43
+ const value = row as Record<string, unknown>;
44
+
45
+ return {
46
+ code: typeof value['code'] === 'string' ? value['code'] : '',
47
+ description: typeof value['description'] === 'string' ? value['description'] : null,
48
+ is_active: Boolean(value['is_active']),
49
+ load_strategy: typeof value['load_strategy'] === 'string' ? value['load_strategy'] : 'default',
50
+ name: typeof value['name'] === 'string' ? value['name'] : '',
51
+ placement: typeof value['placement'] === 'string' ? value['placement'] : 'body_end',
52
+ sort_order: Number.isFinite(value['sort_order']) ? Number(value['sort_order']) : 0,
53
+ src: typeof value['src'] === 'string' && value['src'] ? (value['src'] as string) : null,
54
+ };
55
+ }
56
+
57
+ /** One-line human description of what a revision did, shown in the history list. */
58
+ export function describeSiteScriptRevision(revision: SiteScriptRevision): string {
59
+ if (revision.summary) return revision.summary;
60
+
61
+ switch (revision.revision_type) {
62
+ case 'create':
63
+ return `Created “${revision.script_name}”`;
64
+ case 'delete':
65
+ return `Deleted “${revision.script_name}”`;
66
+ case 'revert':
67
+ return `Restored “${revision.script_name}”`;
68
+ default:
69
+ return `Updated “${revision.script_name}”`;
70
+ }
71
+ }
@@ -0,0 +1,46 @@
1
+ /** Where a site script's tag is emitted in the document. */
2
+ export type SiteScriptPlacement = 'head' | 'body_start' | 'body_end';
3
+
4
+ /** Loading hint for external (`src`) scripts. Inline code ignores it. */
5
+ export type SiteScriptLoadStrategy = 'default' | 'defer' | 'async';
6
+
7
+ export interface SiteScript {
8
+ id: string;
9
+ name: string;
10
+ description: string | null;
11
+ /** Raw JS without the surrounding <script> tag. Ignored when `src` is set. */
12
+ code: string;
13
+ /** External script URL. Takes precedence over `code`. */
14
+ src: string | null;
15
+ placement: SiteScriptPlacement;
16
+ load_strategy: SiteScriptLoadStrategy;
17
+ is_active: boolean;
18
+ sort_order: number;
19
+ }
20
+
21
+ export const SITE_SCRIPT_PLACEMENTS: SiteScriptPlacement[] = ['head', 'body_start', 'body_end'];
22
+ export const SITE_SCRIPT_LOAD_STRATEGIES: SiteScriptLoadStrategy[] = ['default', 'defer', 'async'];
23
+
24
+ export const SITE_SCRIPT_COLUMNS =
25
+ 'id, name, description, code, src, placement, load_strategy, is_active, sort_order';
26
+
27
+ export function isSiteScriptPlacement(value: unknown): value is SiteScriptPlacement {
28
+ return typeof value === 'string' && (SITE_SCRIPT_PLACEMENTS as string[]).includes(value);
29
+ }
30
+
31
+ export function isSiteScriptLoadStrategy(value: unknown): value is SiteScriptLoadStrategy {
32
+ return typeof value === 'string' && (SITE_SCRIPT_LOAD_STRATEGIES as string[]).includes(value);
33
+ }
34
+
35
+ /**
36
+ * Guard against a script closing its own tag and escaping into markup.
37
+ *
38
+ * The code is emitted inside a <script> element, where the HTML parser ends the
39
+ * element at the first literal `</script`, regardless of JavaScript syntax — so a
40
+ * string containing it would terminate the script early and let whatever follows be
41
+ * parsed as HTML. Escaping the slash keeps the sequence inert to the parser while
42
+ * remaining the same string to JavaScript.
43
+ */
44
+ export function escapeInlineScript(code: string): string {
45
+ return code.replace(/<\/(script)/gi, '<\\/$1');
46
+ }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nextblock-cms/template",
3
- "version": "0.14.5",
3
+ "version": "0.15.0",
4
4
  "private": true,
5
5
  "scripts": {
6
6
  "dev": "next dev",