create-nextblock 0.14.6 → 0.15.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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,
@@ -1556,3 +1556,139 @@ When modifying Cortex AI, keep these invariants:
1556
1556
  11. If a side-effecting tool succeeds and the model fails afterward, report the tool result instead of retrying blindly.
1557
1557
  12. Free OpenRouter models are useful but unstable; guard against 429s, malformed tool-call text, invalid HTML fragments, and no-output generation.
1558
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 not mutate
1565
+ server-rendered DOM at all — `load` is not a post-hydration signal.
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 and React hydration
1642
+
1643
+ Public pages are React-hydrated, and this is the single most common way an author
1644
+ script goes wrong. **Do not change the text, classes, or attributes of
1645
+ server-rendered markup.** React reconciles after the script runs and either reverts
1646
+ the change or logs a hydration mismatch — a counter visibly animates and then snaps
1647
+ back to its server value.
1648
+
1649
+ Waiting for the `load` event is **not** a fix. With streaming and selective
1650
+ hydration, hydration can still be in flight when `load` fires; this was tried and
1651
+ still produced mismatches on `<section className=…>`.
1652
+
1653
+ Patterns that are actually safe:
1654
+
1655
+ - **Web Animations API.** `el.animate([...], {fill: 'both'})` creates an Animation
1656
+ object and writes neither `class` nor `style`, so React has nothing to diff. A
1657
+ paused animation held at `currentTime = 0` hides an element without a class.
1658
+ - **Append your own elements.** React does not own what the script creates, so a
1659
+ progress bar or overlay appended to `<body>` is unconditionally safe.
1660
+ - **CSS.** Anything expressible in CSS carries no hydration risk at all.
1661
+ - **If text must change**, render the FINAL value server-side and animate toward it
1662
+ once the element scrolls into view, so any reconciliation lands on the correct
1663
+ value rather than resetting the animation. Format numbers with a fixed formatter,
1664
+ not `toLocaleString()`, so the client string matches the server byte for byte.
1665
+
1666
+ ### SSRF
1667
+
1668
+ `fetch_url_content` and the media importer perform server-side HTTP on a
1669
+ caller-supplied URL, and `fetch_url_content` is a **read**-scoped tool — so its
1670
+ blocklist is what stops a read-only token from reaching internal services. The
1671
+ blocklist is duplicated (`isBlockedFetchHost` in cortex, `isBlockedImportHost` in the
1672
+ app) because a published lib cannot import from the app: **fix both together.**
1673
+ Regression tests live in `ai-global-agent-ssrf.test.ts`; IPv4-mapped IPv6
1674
+ (`::ffff:127.0.0.1`) previously bypassed both.
1675
+
1676
+ ### Building a whole site in one pass
1677
+
1678
+ The tools below exist specifically so a site can be built without a human clicking
1679
+ through the dashboard. Rough order for a from-scratch build:
1680
+
1681
+ | Step | Tools |
1682
+ | --- | --- |
1683
+ | Ground yourself | `get_database_schema`, `list_media`, `list_site_themes`, `list_product_categories` |
1684
+ | Brand it | `manage_site_theme`, `update_global_css` |
1685
+ | Assets | `search_stock_media`, `upload_media` |
1686
+ | Catalogue | `manage_product_category`, `create_cms_product`, `manage_product_variants` |
1687
+ | Pages | `generate_jsonb_layout` then `publish_content_draft` |
1688
+ | Navigation | `update_site_navigation`, `update_footer` |
1689
+ | Locales | `manage_language` then `translate_content_bulk` |
1690
+ | Motion | `update_global_css` plus `manage_site_script` |
1691
+
1692
+ `manage_language` must run before any translation: `translate_page` and
1693
+ `translate_content_bulk` can only target a language that already exists and is
1694
+ 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
+ }
@@ -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,7 +1,7 @@
1
1
  /// <reference types="next" />
2
2
  /// <reference types="next/image-types/global" />
3
- import "./.next/types/routes.d.ts";
4
- import "./.next/types/root-params.d.ts";
3
+ import "./.next/dev/types/routes.d.ts";
4
+ import "./.next/dev/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.6",
3
+ "version": "0.15.1",
4
4
  "private": true,
5
5
  "scripts": {
6
6
  "dev": "next dev",