Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .changeset/theme-scripts-csp.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
"@cloudhal/astro-renderer": patch
"@cloudhal/mcp-server": patch
"@cloudhal/cli": patch
---

Theme scripts work on deployed sites, and `render_page` says when they would not.

- The `cloudhal()` integration no longer lets Astro inline a component's `<script>` (under 4 KB) into the page, where the Site Worker's `script-src 'self'` blocked it: script chunks always ship as files (`build.assetsInlineLimit`), other assets keep the project's own limit. A theme that set `vite.build.assetsInlineLimit` itself for this can drop it.
- `cloudhal dev` sends the Site Worker's policy as `Content-Security-Policy-Report-Only` on pages without their own, so the browser console shows what a deploy would block without breaking Vite's dev client.
- `render_page` and `cloudhal render` gain two checks: `surface-broken` (a body ```` ```surface ```` fence the preview cannot draw, or a component with an unknown block type or props that do not fit, named with the problem) and `script-blocked` (a script the page's CSP refused, inline or by URL; under `cloudhal dev`'s report-only policy, what a deploy would block).
2 changes: 2 additions & 0 deletions docs/rfcs/0021-design-md-and-the-agent-design-loop.md
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,8 @@ The site's Markdown reaches agents as **owner-written data**, between `<site-des
| `empty-section` | A `[data-section-id]` renders under 8 px high |
| `tap-target` | At 390 px, a link or button is smaller than 24×24 px (WCAG 2.5.8) |
| `long-line` | Body text lines are longer than 90 characters at 1440 px |
| `surface-broken` | A body ```` ```surface ```` fence the preview cannot draw, or a component in one with an unknown block type or props that do not fit (added later) |
| `script-blocked` | The page's Content-Security-Policy refused a script, an inline `<script>` under the Site Worker's `script-src 'self'` say; `cloudhal dev` sends that policy report-only, so the check names what a deploy would block (added later) |

**Runtimes:**
- **Cloudflare.**
Expand Down
5 changes: 5 additions & 0 deletions packages/astro-renderer/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@
"./comments": "./src/comments-view.ts",
"./comments-middleware": "./src/comments-middleware.ts",
"./embed-middleware": "./src/embed-middleware.ts",
"./csp-middleware": "./src/csp-middleware.ts",
"./embeds": "./src/embed-client.ts",
"./theme-settings": "./src/theme-settings.ts",
"./kit": "./src/kit.ts",
Expand Down Expand Up @@ -244,6 +245,10 @@
"types": "./dist/src/embed-middleware.d.ts",
"default": "./dist/src/embed-middleware.js"
},
"./csp-middleware": {
"types": "./dist/src/csp-middleware.d.ts",
"default": "./dist/src/csp-middleware.js"
},
"./embeds": {
"types": "./dist/src/embed-client.d.ts",
"default": "./dist/src/embed-client.js"
Expand Down
33 changes: 33 additions & 0 deletions packages/astro-renderer/src/csp-middleware.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
/**
* `cloudhal dev` sends the deployed site's policy as `Content-Security-Policy-Report-Only` on
* HTML pages that set none of their own: the browser (and `render_page`) reports what the Site
* Worker's `SITE_CSP` would block, an inline theme script say, without breaking Vite's dev client.
*/
import { SITE_CSP } from "./csp.ts";

interface MiddlewareContext {
request: Request;
}

export async function onRequest(
_context: MiddlewareContext,
next: () => Promise<Response>,
): Promise<Response> {
const response = await next();
if (!(response.headers.get("content-type") ?? "").includes("text/html")) return response;
if (response.headers.has("content-security-policy")) return response;
if (response.headers.has("content-security-policy-report-only")) return response;
try {
response.headers.set("content-security-policy-report-only", SITE_CSP);
return response;
} catch {
// Immutable headers (a fetched response): copy them.
const headers = new Headers(response.headers);
headers.set("content-security-policy-report-only", SITE_CSP);
return new Response(response.body, {
status: response.status,
statusText: response.statusText,
headers,
});
}
}
15 changes: 15 additions & 0 deletions packages/astro-renderer/src/csp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,3 +26,18 @@ export function turnstileCsp(): string {
`frame-src ${TURNSTILE_ORIGIN}; object-src 'none'`,
);
}

/**
* Vite's `build.assetsInlineLimit` for a site build: never inline a script chunk. Astro inlines a
* component's `<script>` under the limit (4 KB by default) into the page, where `SITE_CSP`'s
* `script-src 'self'` blocks it on the deployed site. Other assets keep the project's own limit.
*/
export function scriptsNotInlined<C extends Uint8Array>(
limit: number | ((file: string, content: C) => boolean | undefined) | undefined,
): (file: string, content: C) => boolean | undefined {
return (file, content) => {
if (/\.[cm]?js$/.test(file)) return false;
if (typeof limit === "function") return limit(file, content);
return limit === undefined ? undefined : content.byteLength < limit;
};
}
26 changes: 17 additions & 9 deletions packages/astro-renderer/src/integration.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type { AstroIntegration } from "astro";
import type { AnalyticsClient } from "./analytics-client.ts";
import type { CommentsClient } from "./comments-client.ts";
import { scriptsNotInlined } from "./csp.ts";

/** Options for the `cloudhal()` integration; made available to pages via `virtual:cloudhal/config`. */
export interface CloudhalRendererConfig {
Expand Down Expand Up @@ -81,6 +82,17 @@ function configPlugin(modules: { config: unknown; client: unknown; comments: unk
};
}

/**
* What only `cloudhal dev` runs as middleware (the Site Worker's entry and builds do these jobs):
* the analytics beacon (RFC-0006 §5.1); the click-to-load video player (RFC-0015 §4) and the
* interaction runtime (RFC-0020 §4), which builds write as files; and the Site Worker's CSP sent
* report-only, so the console shows what a deploy would block.
*/
const DEV_MIDDLEWARE = ["analytics-middleware", "embed-middleware", "csp-middleware"].map((m) => ({
entrypoint: `@cloudhal/astro-renderer/${m}`,
order: "pre" as const,
}));

export function cloudhal(config: CloudhalRendererConfig): AstroIntegration {
let resolved: CloudhalRendererConfig = config;
let client: AnalyticsClient | undefined;
Expand Down Expand Up @@ -115,15 +127,7 @@ export function cloudhal(config: CloudhalRendererConfig): AstroIntegration {
client = await buildAnalyticsClient();
resolved = { ...resolved, analyticsScript: client.script.path };
}
if (!cloudflare && command === "dev")
addMiddleware({
entrypoint: "@cloudhal/astro-renderer/analytics-middleware",
order: "pre",
});
// The click-to-load video player (RFC-0015 §4) and the interaction runtime (RFC-0020 §4):
// served here in dev, written by builds.
if (!cloudflare && command === "dev")
addMiddleware({ entrypoint: "@cloudhal/astro-renderer/embed-middleware", order: "pre" });
if (!cloudflare && command === "dev") for (const m of DEV_MIDDLEWARE) addMiddleware(m);
// Reader comments (RFC-0007 §8.2, §15.2), on the same targets: the Site Worker build ships
// the module and its proof-of-work worker as hashed static assets and its entry answers the
// endpoints; `cloudhal dev` serves both and answers them from a middleware on SQLite.
Expand Down Expand Up @@ -154,6 +158,10 @@ export function cloudhal(config: CloudhalRendererConfig): AstroIntegration {
comments: cloudflare ? null : (comments ?? null),
}),
],
// `SITE_CSP` allows no inline script, so theme `<script>`s ship as files (any size).
build: {
assetsInlineLimit: scriptsNotInlined(astroConfig.vite?.build?.assetsInlineLimit),
},
// Native module must stay external in Node SSR; workerd never loads it.
...(cloudflare ? {} : { ssr: { external: ["better-sqlite3"] } }),
},
Expand Down
69 changes: 69 additions & 0 deletions packages/astro-renderer/test/csp.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
/**
* Theme scripts under `SITE_CSP` (`script-src 'self'`): the `cloudhal()` integration keeps Astro
* from inlining a component's `<script>` into the page, and `cloudhal dev` sends the policy
* report-only so a blocked script shows before a deploy.
*/
import { describe, expect, it } from "vitest";
import { SITE_CSP, scriptsNotInlined } from "../src/csp.ts";
import { onRequest } from "../src/csp-middleware.ts";
import { cloudhal } from "../src/integration.ts";

const bytes = (n: number) => new Uint8Array(n);

describe("scriptsNotInlined", () => {
it("never inlines a script chunk; other assets keep the project's limit", () => {
const vite = scriptsNotInlined(undefined);
expect(vite("_astro/Hero.astro_astro_type_script_index_0_lang.Bx1.js", bytes(10))).toBe(false);
expect(vite("_astro/chunk.mjs", bytes(10))).toBe(false);
// Undefined: Vite's (and Astro's) own 4 KB default decides.
expect(vite("_astro/logo.svg", bytes(10))).toBeUndefined();
expect(scriptsNotInlined(100)("a.png", bytes(99))).toBe(true);
expect(scriptsNotInlined(100)("a.png", bytes(100))).toBe(false);
expect(scriptsNotInlined(100)("a.js", bytes(1))).toBe(false);
const own = scriptsNotInlined((file) => file.endsWith(".woff2"));
expect(own("a.woff2", bytes(1))).toBe(true);
expect(own("a.js", bytes(1))).toBe(false);
});

it("is what the integration gives Vite, on top of the project's own setting", async () => {
let vite: { build?: { assetsInlineLimit?: unknown } } = {};
const middleware: string[] = [];
const setup = cloudhal({ dataDir: "/tmp/data", siteId: "site_1" }).hooks["astro:config:setup"];
await setup?.({
updateConfig: (c: { vite: typeof vite }) => {
vite = c.vite;
},
addMiddleware: (m: { entrypoint: string }) => middleware.push(m.entrypoint),
config: { root: new URL("file:///site/"), vite: { build: { assetsInlineLimit: 0 } } },
command: "dev",
} as never);
const limit = vite.build?.assetsInlineLimit as (f: string, c: Uint8Array) => unknown;
expect(limit("_astro/Theme.js", bytes(1))).toBe(false);
expect(limit("_astro/icon.svg", bytes(1))).toBe(false);
expect(middleware).toContain("@cloudhal/astro-renderer/csp-middleware");
});
});

describe("cloudhal dev's report-only CSP", () => {
const run = (response: Response) =>
onRequest({ request: new Request("http://localhost/") }, async () => response);

it("reports what the Site Worker would block on HTML pages", async () => {
const page = await run(
new Response("<p>hi</p>", { headers: { "content-type": "text/html; charset=utf-8" } }),
);
expect(page.headers.get("content-security-policy-report-only")).toBe(SITE_CSP);
expect(page.headers.has("content-security-policy")).toBe(false);
});

it("leaves other responses and a page's own policy alone", async () => {
const js = await run(new Response("1", { headers: { "content-type": "text/javascript" } }));
expect(js.headers.has("content-security-policy-report-only")).toBe(false);
const own = await run(
new Response("<p>hi</p>", {
headers: { "content-type": "text/html", "content-security-policy": "default-src 'none'" },
}),
);
expect(own.headers.has("content-security-policy-report-only")).toBe(false);
});
});
59 changes: 59 additions & 0 deletions packages/cli/test/render-chrome.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@
* Skipped where this machine has no Chrome (set CHROME_PATH).
*/
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { createServer } from "node:http";
import type { AddressInfo } from "node:net";
import os from "node:os";
import path from "node:path";
import { pathToFileURL } from "node:url";
Expand Down Expand Up @@ -273,6 +275,63 @@ header{position:sticky;top:0;height:80px;color:#fff;margin-bottom:-80px}header a
expect.stringMatching(/p "Jumps" — "bare" has no metric-matched fallback/),
]);
}, 90_000);

it("names a body surface that cannot be shown and a script the CSP blocked", async () => {
dir ??= await mkdtemp(path.join(os.tmpdir(), "cloudhal-render-test-"));
const render = async (name: string, html: string) => {
const file = path.join(dir, name);
await writeFile(file, html);
const r = await renderPage(
chromeRenderer(chrome as string, { idleMs: 10 }),
pathToFileURL(file).href,
{ widths: [1440], fullPage: false, timeoutMs: 50_000 },
);
return (id: string) => r.checks.find((c) => c.id === id);
};
await writeFile(path.join(dir, "theme.js"), "document.body.dataset.ran = '1';");
// The Site Worker's policy, as a meta tag (a file:// page has no headers); the markup is what
// a preview draws for a broken ```surface fence and for an unknown component in one.
const head = (csp: string) =>
`<!doctype html><html lang="en"><head><meta name="viewport" content="width=device-width"><meta http-equiv="${csp}" content="script-src 'self'"></head><body><main><h1>S</h1>`;
const page = (csp: string) =>
`${head(csp)}<section data-section-id="sec_body"><div class="ch-surface-problem" role="note"><p><strong>This interactive block cannot be shown.</strong> Fix it in Markdown mode:</p><ul><li><code>srf_cards/components/c1 unknown component</code></li></ul></div>
<div class="ch-block ch-block--missing" data-component-id="c2"><p>link-cards: unknown block type</p></div>
<div class="ch-block ch-block--missing" data-component-id="c3"><p>lens-hero: shown by the theme on the site</p></div></section>
<script>document.body.dataset.inline = "1";</script><script src="theme.js"></script></main></body></html>`;
const blocked = await render("csp.html", page("content-security-policy"));
expect(blocked("surface-broken")?.examples).toEqual([
expect.stringMatching(
/section sec_body div\.ch-surface-problem .* — srf_cards\/components\/c1 unknown component$/,
),
expect.stringMatching(/component c2 .* — link-cards: unknown block type$/),
]);
expect(blocked("script-blocked")?.examples).toEqual([
expect.stringMatching(
/^script .* — an inline script .*was blocked by script-src-elem; ship it as a file/,
),
]);
// `cloudhal dev` sends the policy report-only, a header only (a meta tag cannot): the same
// script, named as what a deploy blocks.
const server = createServer((_req, res) => {
res.setHeader("content-type", "text/html");
res.setHeader("content-security-policy-report-only", "script-src 'self'");
res.end(page("x-none").replace('<script src="theme.js"></script>', ""));
});
await new Promise<void>((r) => server.listen(0, "127.0.0.1", r));
try {
const { port } = server.address() as AddressInfo;
const r = await renderPage(
chromeRenderer(chrome as string, { idleMs: 10 }),
`http://127.0.0.1:${port}/`,
{ widths: [1440], fullPage: false, timeoutMs: 50_000 },
);
expect(r.checks.find((c) => c.id === "script-blocked")?.examples).toEqual([
expect.stringMatching(/an inline script .*would be blocked on the site by script-src-elem/),
]);
} finally {
server.close();
}
}, 90_000);
});

describe("localRender", () => {
Expand Down
4 changes: 3 additions & 1 deletion packages/mcp-server/src/render-tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,8 @@ const WHAT: Record<(typeof RENDER_CHECK_IDS)[number], string> = {
"carousel-autoplay": "carousel autoplay",
"image-fit": "image crops",
"font-fallback": "font swap shift",
"surface-broken": "interactive blocks that cannot be shown",
"script-blocked": "scripts blocked by the CSP",
};

function summary(r: RenderPageResult): string {
Expand Down Expand Up @@ -130,7 +132,7 @@ export function renderTools(previewDoc: PreviewDoc): ToolDefinition[] {
name: "render_page",
title: "Render a page in a browser",
description:
"Look at a page's draft as people will see it: renders it in a real browser at desktop (1440), tablet (768) and phone (390) widths and returns screenshots plus automatic checks (sideways scrolling, alt text, broken images, heading order, text contrast, empty sections, tap targets, line length, motion under reduced motion, layout shift) that name the section and component behind each problem. Use it after building or changing a page and before asking a person to publish; fix what it finds and render again. Read-only. Rate-limited (about 10 a minute); when no browser is available, use preview_page.",
"Look at a page's draft as people will see it: renders it in a real browser at desktop (1440), tablet (768) and phone (390) widths and returns screenshots plus automatic checks (sideways scrolling, alt text, broken images, heading order, text contrast, empty sections, tap targets, line length, motion under reduced motion, layout shift, interactive blocks that cannot be shown, scripts the site's Content-Security-Policy blocks) that name the section and component behind each problem. Use it after building or changing a page and before asking a person to publish; fix what it finds and render again. Read-only. Rate-limited (about 10 a minute); when no browser is available, use preview_page.",
inputSchema: obj(
{
siteId: id,
Expand Down
7 changes: 4 additions & 3 deletions packages/mcp-server/src/render.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
/**
* Sight for agents (RFC-0021 §4): renders a page in a real browser at a few widths and returns
* screenshots plus automatic checks (overflow, alt text, broken images, heading order, contrast,
* empty sections, tap targets, line length). The browser is a DevTools socket: local Chrome under
* Node (`@cloudhal/cli`), Cloudflare Browser Rendering on Workers (`@cloudhal/cloudflare`). One
* small DevTools client serves both, so there is no browser library to bundle.
* empty sections, tap targets, line length, broken surfaces, blocked scripts, …). The browser is a
* DevTools socket: local Chrome under Node (`@cloudhal/cli`), Cloudflare Browser Rendering on
* Workers (`@cloudhal/cloudflare`). One small DevTools client serves both, so there is no browser
* library to bundle.
*
* Worker-safe: no Node imports.
*/
Expand Down
3 changes: 3 additions & 0 deletions packages/mcp-server/src/render/cdp.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
/** A minimal DevTools client over a browser-level socket (flat sessions), and base64 helpers. */
import { CSP_LISTENER } from "./checks/scripts.ts";
import { type CdpSocket, RenderError, type RenderSession } from "./types.ts";

/**
Expand Down Expand Up @@ -123,6 +124,8 @@ export async function cdpSession(
},
],
});
// Before the page's own scripts: keep the CSP violations it raises (`script-blocked`).
await s("Page.addScriptToEvaluateOnNewDocument", { source: CSP_LISTENER });
const loaded = cdp.waitFor("Page.loadEventFired", sessionId, options.loadTimeoutMs ?? 12_000);
const nav = await s("Page.navigate", { url });
if (typeof nav.errorText === "string" && nav.errorText)
Expand Down
2 changes: 2 additions & 0 deletions packages/mcp-server/src/render/checks/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import { LAYOUT_CHECKS } from "./layout.ts";
import { MOTION_CHECKS } from "./motion.ts";
import { NAVIGATION_CHECKS } from "./navigation.ts";
import { PRELUDE } from "./prelude.ts";
import { SCRIPT_CHECKS } from "./scripts.ts";

export const RENDER_CHECK_SCRIPT = [
"(async () => {",
Expand All @@ -23,6 +24,7 @@ export const RENDER_CHECK_SCRIPT = [
CONTRAST_CHECKS,
CONTENT_CHECKS,
FONT_CHECKS,
SCRIPT_CHECKS,
MOTION_CHECKS,
NAVIGATION_CHECKS,
COMPONENT_CHECKS,
Expand Down
Loading
Loading