diff --git a/.changeset/design-whiteboard.md b/.changeset/design-whiteboard.md new file mode 100644 index 000000000000..3233d7551068 --- /dev/null +++ b/.changeset/design-whiteboard.md @@ -0,0 +1,7 @@ +--- +"@reddb-io/redcode": minor +--- + +Design mode: Mermaid diagrams open as whiteboards + +Every rendered diagram in a `.mermaid` (or `data-redcode-mermaid`) container gets an Excalidraw whiteboard beside it, and a Fullscreen action that opens the same one over the page: converted from the Mermaid source, drawn on and rearranged, autosaved beside the review's own state, and queued as one ordinary note carrying a summary of what changed (added, removed, moved, relabeled, drawn) plus the edited scene and a PNG preview on disk. The agent edits the Mermaid source in response; nothing is ever converted back. A scene saved for an older version of a diagram is never merged silently: the person chooses between re-converting and keeping their edits. The frames run sandboxed with no origin and no server access; the review page does every read and write, and only for a frame that proved a channel token minted for this prototype and its descent from the prototype frame. The bundle (Excalidraw, the converter with its exactly pinned Mermaid, React) is not in the binary: a release ships it as `redcode-whiteboard-.tar.gz`, fetched into the data directory the first time a review needs it (`REDCODE_DISABLE_WHITEBOARD_DOWNLOAD=1` to never fetch, `REDCODE_WHITEBOARD_DIR` to point at a build); a source checkout builds it with `bun run build:whiteboard`. Until it is there, diagrams stay as they are. diff --git a/packages/redcode/package.json b/packages/redcode/package.json index cfe7ea146f75..11efdcdfa1b1 100644 --- a/packages/redcode/package.json +++ b/packages/redcode/package.json @@ -12,6 +12,7 @@ "bench:test": "bun run script/bench-test-suite.ts", "profile:test": "bun run script/profile-test-files.ts", "build": "bun run script/build.ts", + "build:whiteboard": "bun run script/whiteboard-bundle.ts", "dev": "bun run ./src/index.ts", "dev:temporary": "bun run ./src/temporary.ts" }, diff --git a/packages/redcode/script/build.ts b/packages/redcode/script/build.ts index b25a69eb495f..821b50de09fb 100755 --- a/packages/redcode/script/build.ts +++ b/packages/redcode/script/build.ts @@ -262,6 +262,10 @@ if (Script.release) { archives.push(`dist/${key}.zip`) } } + // The whiteboard bundle rides on the release as its own tarball: not in any binary, fetched by + // the server the first time a diagram is opened as a whiteboard. + await $`bun script/whiteboard-bundle.ts --archive`.env({ ...process.env, REDCODE_VERSION: Script.version }) + archives.push(`dist/redcode-whiteboard-${Script.version}.tar.gz`) const checksums: string[] = [] for (const archive of archives.sort()) { const hasher = new Bun.CryptoHasher("sha256") diff --git a/packages/redcode/script/whiteboard-bundle.ts b/packages/redcode/script/whiteboard-bundle.ts new file mode 100644 index 000000000000..fe6387800ffa --- /dev/null +++ b/packages/redcode/script/whiteboard-bundle.ts @@ -0,0 +1,88 @@ +#!/usr/bin/env bun +/** + * The whiteboard bundle: Excalidraw, the Mermaid→Excalidraw converter with its exactly pinned + * Mermaid, React, and the frame page's own code, as one script and one stylesheet, plus the fonts + * Excalidraw fetches on demand. It is not part of the binary — several megabytes nobody needs + * until a diagram is opened as a whiteboard — so a release ships it as a tarball the server + * downloads on first use. `bun run build:whiteboard` produces dist/whiteboard/ for a checkout. + * + * Built in its own little project under node_modules/.cache, with its own install: React and Excalidraw are + * not dependencies of redcode, and pulling them into the workspace would tangle them with the + * React the terminal UI's dependencies expect. The pins are exact; the converter reaches into + * Mermaid's internals and newer Mermaid versions silently degrade class, ER and state diagrams + * to image fallbacks (mermaid-to-excalidraw#108), so a bump must be a deliberate re-probe. + */ +import { $ } from "bun" +import path from "path" +import { promises as fs } from "node:fs" + +export const PINS = { + "@excalidraw/excalidraw": "0.18.1", + "@excalidraw/mermaid-to-excalidraw": "2.2.2", + mermaid: "11.12.1", + react: "18.2.0", + "react-dom": "18.2.0", +} as const + +const root = path.resolve(import.meta.dirname, "..") +const out = path.join(root, "dist", "whiteboard") +// Under node_modules/.cache, not dist/: dist/*/package.json is what the publish script reads as +// the binary packages, and this project is not one of them. +const work = path.join(root, "node_modules", ".cache", "redcode-whiteboard-build") +const version = process.env["REDCODE_VERSION"] ?? (await import("../package.json")).default.version + +// The frame's sources, copied beside the install so bare imports resolve there and nowhere else. +await fs.mkdir(work, { recursive: true }) +await Bun.write( + path.join(work, "package.json"), + JSON.stringify({ name: "redcode-whiteboard-build", private: true, type: "module", dependencies: PINS }, null, 2), +) +const frame = await fs.readFile(path.join(root, "src/design/whiteboard-frame/frame.js"), "utf8") +await Bun.write(path.join(work, "frame.js"), frame.replace('"../vendor/whiteboard-core.js"', '"./vendor/whiteboard-core.js"')) +await fs.copyFile(path.join(root, "src/design/whiteboard-frame/frame.css"), path.join(work, "frame.css")) +await fs.mkdir(path.join(work, "vendor"), { recursive: true }) +await fs.copyFile(path.join(root, "src/design/vendor/whiteboard-core.js"), path.join(work, "vendor", "whiteboard-core.js")) +await $`bun install --silent`.cwd(work) + +for (const [name, pinned] of Object.entries(PINS)) { + const installed = (await import(path.join(work, "node_modules", name, "package.json"))).default.version + if (installed !== pinned) throw new Error(`${name} resolved to ${installed}, not the pinned ${pinned}`) +} + +await fs.rm(out, { recursive: true, force: true }) +await fs.mkdir(out, { recursive: true }) + +const result = await Bun.build({ + entrypoints: [path.join(work, "frame.js")], + outdir: out, + naming: "whiteboard.[ext]", + target: "browser", + format: "iife", + minify: true, + conditions: ["production", "browser"], + define: { + "process.env.NODE_ENV": '"production"', + "process.env.IS_PREACT": '"false"', + }, +}) +if (!result.success) { + for (const log of result.logs) console.error(log) + process.exit(1) +} + +// Excalidraw lazily fetches canvas fonts from `EXCALIDRAW_ASSET_PATH/fonts/`. Every family but +// Xiaolai (12 MB of CJK glyphs) ships; that one falls back to the system font when missing. +const families = ["Assistant", "Cascadia", "ComicShanns", "Excalifont", "Liberation", "Lilita", "Nunito", "Virgil"] +const fonts = path.join(work, "node_modules/@excalidraw/excalidraw/dist/prod/fonts") +await fs.mkdir(path.join(out, "fonts"), { recursive: true }) +for (const family of families) { + await fs.cp(path.join(fonts, family), path.join(out, "fonts", family), { recursive: true }) +} +await Bun.write(path.join(out, "VERSION"), `${version}\n`) + +if (process.argv.includes("--archive")) { + const archive = path.join(root, "dist", `redcode-whiteboard-${version}.tar.gz`) + await $`tar -czf ${archive} -C ${out} .` + console.log(`wrote ${archive}`) +} +console.log(`built ${out}`) diff --git a/packages/redcode/src/design/client/artifact.ts b/packages/redcode/src/design/client/artifact.ts index ef3bcd0068ef..07750ce6c7f5 100644 --- a/packages/redcode/src/design/client/artifact.ts +++ b/packages/redcode/src/design/client/artifact.ts @@ -22,6 +22,8 @@ export interface ArtifactConfig { readonly load: number /** The image limits the server enforces, so the card refuses early and says why. */ readonly attachments?: { readonly maxCount: number; readonly maxBytes: number; readonly accepted: readonly string[] } + /** Where a diagram's whiteboard frame lives, when the bundle is on this machine. */ + readonly whiteboard?: { readonly frame: string } } export type HelperTable = { @@ -871,6 +873,18 @@ export function artifactMain(config: ArtifactConfig, h: HelperTable) { // race): run again and publish even if nothing changed. audit.schedule(true) return + case "suspendWhiteboard": { + // The shell is editing this diagram full screen: park the inline frame so two editors + // never autosave one scene. Resume reboots it from the latest saved scene. + const entry = whiteboardByIndex(payload.diagramIndex) + if (entry) entry.iframe.src = "about:blank" + return + } + case "resumeWhiteboard": { + const entry = whiteboardByIndex(payload.diagramIndex) + if (entry) entry.iframe.src = whiteboardSrc(entry) + return + } case "attachmentResult": // Only from the shell, and only for this document: a result for a chip of the previous // document must not mark a new chip ready with the wrong image. @@ -903,6 +917,78 @@ export function artifactMain(config: ArtifactConfig, h: HelperTable) { scheduleReviewStateReport() }) + // --- whiteboards ------------------------------------------------------------------------------ + // Each rendered diagram in a `.mermaid` (or `data-redcode-mermaid`) container is joined, at view + // time only, by a sibling frame hosting the Excalidraw whiteboard; the file keeps its Mermaid + // source and still renders plain when opened standalone or exported. The container's index + // among containers in document order is the diagram's identity; the server recovers the + // matching source from the file. + const CONTAINER = ".mermaid,[data-redcode-mermaid],[data-lavish-mermaid]" + const whiteboards = new Map() + let enhanceTimer = 0 + const containerIndex = (container: any) => Array.from(document.querySelectorAll(CONTAINER)).indexOf(container) + const whiteboardSrc = (entry: { index: number; diagramId: string }) => + config.whiteboard!.frame + "?" + new URLSearchParams({ index: String(entry.index), diagramId: entry.diagramId }).toString() + const whiteboardByIndex = (index: unknown) => + Array.from(whiteboards.values()).find((entry) => entry.iframe.isConnected && entry.index === Number(index)) || null + const whiteboardHeight = (rect: DOMRect) => { + const min = 360 + const max = Math.max(min, Math.round((window.innerHeight || 800) * 0.8)) + return Math.max(min, Math.min(Math.round(rect.height) + 96, max)) + } + const scheduleEnhance = () => { + if (enhanceTimer) return + enhanceTimer = window.setTimeout(() => { + enhanceTimer = 0 + enhance() + }, 100) + } + const embedWhiteboard = (svg: any) => { + const container = svg.closest(CONTAINER) + if (!container) return + const existing = whiteboards.get(container) + if (existing && existing.iframe.isConnected) { + existing.index = containerIndex(container) + return + } + const index = containerIndex(container) + if (index < 0) return + const rect = svg.getBoundingClientRect() + // Mermaid renders asynchronously; a flat rect means this svg has no layout yet. Ask again + // shortly, because finishing layout does not necessarily mutate the DOM. + if (rect.height < 40) { + window.setTimeout(scheduleEnhance, 150) + return + } + const entry = { iframe: document.createElement("iframe"), index, diagramId: String(svg.id || "") } + entry.iframe.setAttribute("data-redcode-ui", "whiteboard-inline") + entry.iframe.setAttribute("title", "Whiteboard") + // Stricter than, and independent of, this document's own sandbox. + entry.iframe.setAttribute("sandbox", "allow-scripts allow-popups") + entry.iframe.src = whiteboardSrc(entry) + entry.iframe.style.cssText = + "display:block;width:100%;height:" + + whiteboardHeight(rect) + + "px;border:1px solid rgba(128,128,128,.35);border-radius:12px;background:transparent" + // The page may re-render Mermaid inside the container on a theme change, so the frame is a + // sibling: a re-render stays harmless inside the hidden container instead of killing the editor. + container.style.display = "none" + container.insertAdjacentElement("afterend", entry.iframe) + whiteboards.set(container, entry) + } + const enhance = () => { + for (const svg of Array.from(document.querySelectorAll("svg"))) { + if (isUi(svg) || !h.isMermaidSvg(svg)) continue + embedWhiteboard(svg) + } + } + if (config.whiteboard && config.whiteboard.frame) { + scheduleEnhance() + window.addEventListener("load", scheduleEnhance, { once: true }) + if (typeof MutationObserver !== "undefined") + new MutationObserver(scheduleEnhance).observe(document.documentElement, { childList: true, subtree: true }) + } + // --- the passive layout audit, and the one fatal path ----------------------------------------- const audit = h.artifactAudit({ h, post, isUi, selector, load: config.load }) // A local subresource the prototype declares but the server cannot serve makes the review diff --git a/packages/redcode/src/design/feedback.ts b/packages/redcode/src/design/feedback.ts index ed02e530ef2d..b7345c60aa00 100644 --- a/packages/redcode/src/design/feedback.ts +++ b/packages/redcode/src/design/feedback.ts @@ -13,6 +13,7 @@ */ import { DesignLayoutWarnings } from "./layout-warnings" +import { normalizeExcalidrawSceneTarget, type ExcalidrawSceneTarget } from "./vendor/whiteboard-core.js" /** Long enough for a real remark, short enough that a page cannot flood a turn. */ export const LIMITS = { @@ -70,7 +71,10 @@ export interface MermaidNodeTarget { /** A batch of layout warnings the person queued for repair, as the inbox described them. */ export type LayoutWarningsTarget = DesignLayoutWarnings.PromptTarget -export type Target = TextRangeTarget | TableCellTarget | MermaidNodeTarget | LayoutWarningsTarget +/** A whiteboard's edits: where the scene and its preview are on disk, and what changed, counted. */ +export type WhiteboardTarget = ExcalidrawSceneTarget + +export type Target = TextRangeTarget | TableCellTarget | MermaidNodeTarget | LayoutWarningsTarget | WhiteboardTarget export interface Annotation { /** Where in the prototype, as a CSS path. */ @@ -193,6 +197,16 @@ export function target(raw: unknown): Target | undefined { const normalized = DesignLayoutWarnings.normalizeTarget(r) return normalized.warnings.length ? normalized : undefined } + case "excalidraw-scene": { + const normalized = normalizeExcalidrawSceneTarget(r) + return { + ...normalized, + diagramId: clamp(normalized.diagramId, LIMITS.label), + sourceHash: clamp(normalized.sourceHash, 32), + scenePath: clamp(normalized.scenePath, LIMITS.selector), + previewPath: clamp(normalized.previewPath, LIMITS.selector), + } + } default: return undefined } @@ -256,6 +270,7 @@ export function where(item: Annotation): string { return `node ${t.label ? `"${t.label}"` : ""}${ids ? ` ${ids}` : ""}`.replace(/\s+/g, " ").trim() } if (t?.type === "layout-warnings") return `layout issues: ${t.warnings.length} queued for repair` + if (t?.type === "excalidraw-scene") return `whiteboard: diagram ${t.diagramIndex + 1}${t.diagramId ? ` (${t.diagramId})` : ""}` if (item.tag === "message") return "" return item.label || item.selector || "" } diff --git a/packages/redcode/src/design/registry.ts b/packages/redcode/src/design/registry.ts index f741089c5b3e..5ee860127e9a 100644 --- a/packages/redcode/src/design/registry.ts +++ b/packages/redcode/src/design/registry.ts @@ -7,6 +7,7 @@ import { EventV2Bridge } from "@/event-v2-bridge" import { Session } from "@/session/session" import { Config } from "@/config/config" import { Context, Effect, Fiber, Layer, PubSub, Semaphore, Stream } from "effect" +import { RuntimeFlags } from "@/effect/runtime-flags" import { createHash, randomBytes } from "node:crypto" import { promises as nodeFs } from "node:fs" import path from "path" @@ -17,6 +18,7 @@ import { DesignLayoutWarnings } from "./layout-warnings" import { DesignManifest } from "./manifest" import { DesignState } from "./state" import { DesignWatch } from "./watch" +import { DesignWhiteboard } from "./whiteboard" /** * Which directories are currently reachable as prototypes, and by whom. @@ -79,6 +81,13 @@ export interface Load { export type LoadBegun = { readonly revision: number; readonly token: string; readonly stale?: "out-of-order" } +/** The whiteboard bundle on this machine, if it is here, and the secret its channels are signed with. */ +export interface Whiteboard { + readonly dir?: string + readonly secret: Buffer + readonly status: "ready" | "fetching" | "unavailable" +} + /** What a review page was configured to do about layout. */ export interface Settings { readonly viewports: readonly DesignLayoutWarnings.ViewportClass[] @@ -142,6 +151,8 @@ export interface Interface { readonly settings: () => Effect.Effect /** The export's size caps, from config. */ readonly exportCaps: () => Effect.Effect + /** Where the whiteboard bundle is. The first ask on a machine without it starts the download. */ + readonly whiteboard: () => Effect.Effect /** A shell is about to load the frame: mint the token that ties that document's passes to it. */ readonly beginLoad: (id: string, input: { client: string; sequence: number }) => Effect.Effect /** Is this the token of a load still current for this prototype? */ @@ -240,8 +251,46 @@ const layer = Layer.effect( const fs = yield* FSUtil.Service const sessions = yield* Session.Service const config = yield* Config.Service + const flags = yield* RuntimeFlags.Service const lock = yield* Semaphore.make(1) + // One secret per process: a channel token outlives nothing, and a restart mints fresh ones. + const wb: { dir?: string; attempted: boolean; failed: boolean; readonly secret: Buffer } = { + attempted: false, + failed: false, + secret: randomBytes(32), + } + const whiteboard = Effect.fn("DesignRegistry.whiteboard")(function* () { + if (wb.dir) return { dir: wb.dir, secret: wb.secret, status: "ready" as const } + const located = yield* Effect.promise(() => DesignWhiteboard.locate()) + if (located) { + wb.dir = located + return { dir: located, secret: wb.secret, status: "ready" as const } + } + if (!wb.attempted && !flags.disableWhiteboardDownload) { + wb.attempted = true + yield* Effect.forkDetach( + Effect.promise(() => DesignWhiteboard.download()).pipe( + Effect.tap((result) => + Effect.sync(() => { + if (result !== "ready") wb.failed = true + }), + ), + Effect.flatMap((result) => + result === "ready" + ? Effect.promise(() => DesignWhiteboard.locate()).pipe( + Effect.tap((dir) => Effect.sync(() => (wb.dir = dir))), + ) + : Effect.logInfo("design: whiteboard bundle not fetched", { result }), + ), + Effect.catchCause((cause) => Effect.logWarning("design: whiteboard bundle download failed", { cause })), + ), + ) + } + const status = wb.attempted && !wb.failed && !flags.disableWhiteboardDownload ? ("fetching" as const) : ("unavailable" as const) + return { secret: wb.secret, status } + }) + interface State { readonly data: Map readonly hubs: Map> @@ -800,6 +849,7 @@ const layer = Layer.effect( exclusive, settings, exportCaps, + whiteboard, beginLoad, verifyLoad, diagnostics, @@ -814,7 +864,7 @@ const layer = Layer.effect( export const node = LayerNode.make({ service: Service, layer, - deps: [EventV2Bridge.node, FSUtil.node, Session.node, Config.node], + deps: [EventV2Bridge.node, FSUtil.node, Session.node, Config.node, RuntimeFlags.node], }) export * as DesignRegistry from "./registry" diff --git a/packages/redcode/src/design/route-path.ts b/packages/redcode/src/design/route-path.ts index 6785c249b4aa..7b8cb25810a0 100644 --- a/packages/redcode/src/design/route-path.ts +++ b/packages/redcode/src/design/route-path.ts @@ -28,6 +28,18 @@ export type Target = | { readonly kind: "failures"; readonly id: string } /** The prototype as one self-contained file. */ | { readonly kind: "export"; readonly id: string } + /** The whiteboard frame page for one diagram. */ + | { readonly kind: "whiteboard-frame"; readonly id: string } + /** Every diagram's Mermaid source, from the file on disk. */ + | { readonly kind: "mermaid-sources"; readonly id: string } + /** One diagram's saved scene: read, or write. */ + | { readonly kind: "whiteboard"; readonly id: string; readonly index: number } + /** A frame proving it was minted for this prototype. */ + | { readonly kind: "whiteboard-channel"; readonly id: string } + /** The agent-facing files for one diagram's scene. */ + | { readonly kind: "whiteboard-files"; readonly id: string; readonly index: number } + /** A file of the whiteboard bundle: the script, the stylesheet, a font. */ + | { readonly kind: "whiteboard-asset"; readonly path: string } const ID = /^[A-Za-z0-9_-]{1,64}$/ @@ -35,6 +47,10 @@ const VENDOR = /^\/design\/vendor\/([A-Za-z0-9._-]{1,64})$/ export function parse(pathname: string): Target | undefined { if (!pathname.startsWith("/design/")) return undefined + if (pathname.startsWith("/design/vendor/whiteboard/")) { + const rest = pathname.slice("/design/vendor/whiteboard/".length) + return rest ? { kind: "whiteboard-asset", path: rest } : undefined + } const vendor = VENDOR.exec(pathname) if (vendor) return { kind: "vendor", name: vendor[1]! } const rest = pathname.slice("/design/".length) @@ -55,6 +71,14 @@ export function parse(pathname: string): Target | undefined { if (tail === "/layout-warnings/dismiss") return { kind: "warnings", id, action: "dismiss" } if (tail === "/artifact-failures") return { kind: "failures", id } if (tail === "/export") return { kind: "export", id } + if (tail === "/whiteboard") return { kind: "whiteboard-frame", id } + if (tail === "/mermaid-sources") return { kind: "mermaid-sources", id } + if (tail === "/whiteboard-channel") return { kind: "whiteboard-channel", id } + const scene = /^\/whiteboard\/(\d{1,3})(\/feedback-files)?$/.exec(tail) + if (scene) { + const index = Number(scene[1]) + return scene[2] ? { kind: "whiteboard-files", id, index } : { kind: "whiteboard", id, index } + } const attachment = /^\/attachments\/([0-9a-f]{64}\.(?:png|jpg|webp))$/.exec(tail) if (attachment) return { kind: "attachment", id, aid: attachment[1]! } // The `/files/` prefix is what makes relative asset paths inside a prototype resolve back into diff --git a/packages/redcode/src/design/serve.ts b/packages/redcode/src/design/serve.ts index bdf5e7e9fc43..a1170f20211b 100644 --- a/packages/redcode/src/design/serve.ts +++ b/packages/redcode/src/design/serve.ts @@ -74,7 +74,7 @@ export function resolve(root: string, requestPath: string): string | undefined { * nothing, so inline is the only script mode that can work — which suits model-generated HTML, * and is harmless precisely because `connect-src 'none'` means the code it runs can reach nothing. */ -export function prototypeCSP(input: { assets: string; vendor?: string }) { +export function prototypeCSP(input: { assets: string; vendor?: string; frame?: string }) { return [ "sandbox allow-scripts allow-forms allow-modals allow-popups", "default-src 'none'", @@ -84,6 +84,8 @@ export function prototypeCSP(input: { assets: string; vendor?: string }) { `font-src data: ${input.assets}${input.vendor ? ` ${input.vendor}` : ""}`, `media-src data: blob: ${input.assets}`, "connect-src 'none'", + // The one thing a prototype may frame: its own diagrams' whiteboards, served by us. + ...(input.frame ? [`frame-src ${input.frame}`] : []), "form-action 'none'", "base-uri 'none'", "frame-ancestors 'self'", diff --git a/packages/redcode/src/design/shell.ts b/packages/redcode/src/design/shell.ts index f3a9686dc381..0c8e2df827b6 100644 --- a/packages/redcode/src/design/shell.ts +++ b/packages/redcode/src/design/shell.ts @@ -34,8 +34,13 @@ export interface ShellInput { readonly gateTimeoutMs?: number /** The same review as another device on the network reaches it, when the server listens beyond loopback. */ readonly networkUrl?: string + /** The whiteboard bundle is on this machine: diagrams open as whiteboards, and the page hosts them full screen. */ + readonly whiteboard?: boolean } +/** How long a reload waits for open whiteboards to save before the frame is replaced. */ +export const WHITEBOARD_FLUSH_MS = 1500 + /** Everything the prototype may say; anything else is ignored rather than interpreted. */ export const ARTIFACT_MESSAGES = [ "ready", @@ -109,6 +114,7 @@ export function shellHTML(input: ShellInput) { gate: input.gate !== false && !input.ended, gateTimeoutMs: input.gateTimeoutMs && input.gateTimeoutMs > 0 ? input.gateTimeoutMs : GATE_TIMEOUT_MS, networkUrl: input.networkUrl ?? "", + whiteboard: input.whiteboard === true, }).replace(/ @@ -165,6 +171,12 @@ export function shellHTML(input: ShellInput) { .warning .acts { display: flex; gap: .4rem } .warning .acts button { padding: .15rem .5rem; font-size: 12px } .overlay.gate { z-index: 48; background: color-mix(in oklab, Canvas 92%, transparent) } + .wb { position: absolute; inset: 0; z-index: 60; background: Canvas } + .wb[hidden] { display: none } + .wb iframe { width: 100%; height: 100%; border: 0; display: block; background: transparent } + .wb .close { position: absolute; top: .45rem; right: .6rem; z-index: 61; width: 32px; height: 32px; padding: 0; border-radius: 999px; font-size: 18px; line-height: 1; background: Canvas } + .wb .wb-error { position: absolute; left: 1rem; right: 1rem; bottom: 1rem; padding: .6rem .75rem; border-radius: .5rem; background: color-mix(in oklab, #d0432b 14%, Canvas); color: #d0432b } + .wb .wb-error[hidden] { display: none } .overlay .row { display: flex; gap: .5rem; justify-content: center; margin-top: .75rem } .overlay .row .secondary { opacity: .75 } .menu { position: absolute; right: .5rem; top: calc(var(--bar-h) + .25rem); z-index: 40; min-width: 240px; @@ -326,6 +338,9 @@ export function shellHTML(input: ShellInput) {

Review ended

+ + + + +` +} + +/** + * The frame runs at an opaque origin (the iframe's sandbox), talks to nothing but the vendor + * route (fonts, and the font bytes Excalidraw embeds in a PNG), and can be framed by the + * prototype and by the shell. + */ +export function frameCSP(vendor: string): string { + return [ + "default-src 'none'", + `script-src 'unsafe-inline' ${vendor}`, + `style-src 'unsafe-inline' ${vendor}`, + "img-src data: blob:", + `font-src data: ${vendor}`, + `connect-src ${vendor}`, + "worker-src blob:", + "form-action 'none'", + "base-uri 'none'", + ].join("; ") +} + +// --- the bundle ------------------------------------------------------------------------------- + +const MIME: Record = { + ".js": "text/javascript; charset=utf-8", + ".css": "text/css; charset=utf-8", + ".woff2": "font/woff2", + ".woff": "font/woff", + ".ttf": "font/ttf", + ".otf": "font/otf", + ".json": "application/json; charset=utf-8", + ".svg": "image/svg+xml", + ".png": "image/png", + ".txt": "text/plain; charset=utf-8", +} + +export function assetMime(file: string): string | undefined { + return MIME[path.extname(file).toLowerCase()] +} + +/** A path inside the bundle, or nothing: lexically, then by real path, so a symlink cannot lead out. */ +export async function resolveAsset(bundle: string, assetPath: string): Promise { + let decoded: string + try { + decoded = decodeURIComponent(assetPath) + } catch { + return undefined + } + if (decoded.includes("\0") || path.isAbsolute(decoded)) return undefined + const base = path.resolve(bundle) + const target = path.resolve(base, decoded) + if (target === base || !target.startsWith(base + path.sep)) return undefined + if (!assetMime(target)) return undefined + let real: string + try { + real = await fs.realpath(target) + } catch { + return undefined + } + let realBase: string + try { + realBase = await fs.realpath(base) + } catch { + realBase = base + } + if (!real.startsWith(realBase + path.sep)) return undefined + return real +} + +export const VERSION = InstallationVersion + +/** Where a release's bundle is kept once fetched: one directory per version, never mixed. */ +export function bundleDir(version = VERSION): string { + return path.join(Global.Path.data, "designs", "whiteboard", version) +} + +export function releaseURL(version = VERSION): string { + return `https://github.com/${REPO}/releases/download/v${version}/redcode-whiteboard-${version}.tar.gz` +} + +const ready = async (candidate: string) => + (await fs.stat(path.join(candidate, "whiteboard.js")).catch(() => undefined))?.isFile() ? candidate : undefined + +/** + * The bundle on this machine: a directory the person pointed at, a source checkout's own build, + * or the release's download. Nothing here fetches. + */ +export async function locate(): Promise { + const override = process.env["REDCODE_WHITEBOARD_DIR"] + if (override) return ready(override) + const checkout = path.resolve(import.meta.dir, "..", "..", "dist", "whiteboard") + return (await ready(checkout)) ?? (await ready(bundleDir())) +} + +export type DownloadResult = "ready" | "unavailable" | "no-release" + +/** + * Fetch the release's tarball into the version's directory. A source build has no release + * ("local"), so it says so instead of asking GitHub for a tag that does not exist. + */ +export async function download(version = VERSION, into = bundleDir(version)): Promise { + if (version === "local" || !/^\d+\.\d+\.\d+/.test(version)) return "no-release" + const response = await fetch(releaseURL(version)).catch(() => undefined) + if (!response || !response.ok || !response.body) return "unavailable" + const parent = path.dirname(into) + await fs.mkdir(parent, { recursive: true }) + const archive = path.join(parent, `${version}.${process.pid}.tar.gz`) + const staging = `${into}.${process.pid}.tmp` + try { + await Bun.write(archive, response) + await fs.rm(staging, { recursive: true, force: true }) + await fs.mkdir(staging, { recursive: true }) + const untar = Bun.spawn(["tar", "-xzf", archive, "-C", staging], { stdout: "ignore", stderr: "ignore" }) + if ((await untar.exited) !== 0) return "unavailable" + if (!(await ready(staging))) return "unavailable" + await fs.rm(into, { recursive: true, force: true }) + await fs.rename(staging, into) + return "ready" + } catch { + return "unavailable" + } finally { + await fs.rm(archive, { force: true }).catch(() => undefined) + await fs.rm(staging, { recursive: true, force: true }).catch(() => undefined) + } +} + +export * as DesignWhiteboard from "./whiteboard" diff --git a/packages/redcode/src/effect/runtime-flags.ts b/packages/redcode/src/effect/runtime-flags.ts index be8515f00d12..cc8b1292ff13 100644 --- a/packages/redcode/src/effect/runtime-flags.ts +++ b/packages/redcode/src/effect/runtime-flags.ts @@ -20,6 +20,7 @@ export class Service extends ConfigService.Service()("@redcode/RuntimeF disableEmbeddedWebUi: bool("REDCODE_DISABLE_EMBEDDED_WEB_UI"), disableExternalSkills: bool("REDCODE_DISABLE_EXTERNAL_SKILLS"), disableLspDownload: bool("REDCODE_DISABLE_LSP_DOWNLOAD"), + disableWhiteboardDownload: bool("REDCODE_DISABLE_WHITEBOARD_DOWNLOAD"), disableClaudeCodePrompt: Config.all({ broad: bool("REDCODE_DISABLE_CLAUDE_CODE"), direct: bool("REDCODE_DISABLE_CLAUDE_CODE_PROMPT"), diff --git a/packages/redcode/src/server/shared/design.ts b/packages/redcode/src/server/shared/design.ts index 86e7177d5071..17a291a1d3f0 100644 --- a/packages/redcode/src/server/shared/design.ts +++ b/packages/redcode/src/server/shared/design.ts @@ -14,6 +14,7 @@ import { DesignRoutePath } from "@/design/route-path" import { DesignSDK } from "@/design/sdk" import { DesignServe } from "@/design/serve" import { DesignShell } from "@/design/shell" +import { DesignWhiteboard } from "@/design/whiteboard" import { SessionPrompt } from "@/session/prompt" /** @@ -47,11 +48,11 @@ const heartbeat = () => ({ _tag: "Event" as const, event: "heartbeat", id: undef /** A small JSON body, or nothing: the caller decides what nothing means. */ const MAX_JSON_BYTES = 256 * 1024 -const readJSON = (request: HttpServerRequest.HttpServerRequest) => +const readJSON = (request: HttpServerRequest.HttpServerRequest, max = MAX_JSON_BYTES) => request.text.pipe( - Effect.provideService(HttpIncomingMessage.MaxBodySize, FileSystem.Size(MAX_JSON_BYTES)), + Effect.provideService(HttpIncomingMessage.MaxBodySize, FileSystem.Size(max)), Effect.map((raw) => { - if (raw.length > MAX_JSON_BYTES) return undefined + if (raw.length > max) return undefined try { const parsed = JSON.parse(raw) as unknown return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? (parsed as Record) : {} @@ -100,6 +101,32 @@ export function serveDesignEffect(request: HttpServerRequest.HttpServerRequest) const target = DesignRoutePath.parse(url.pathname) if (!target) return notFound() + // The whiteboard bundle, fetched from an opaque origin: the frame runs sandboxed, and a font + // fetched from an opaque origin is CORS-gated, so this public content answers every origin. + if (target.kind === "whiteboard-asset") { + const registry = yield* DesignRegistry.Service + const bundle = yield* registry.whiteboard() + if (!bundle.dir) return HttpServerResponse.jsonUnsafe({ error: "whiteboard bundle not available" }, { status: 404 }) + const file = yield* Effect.promise(() => DesignWhiteboard.resolveAsset(bundle.dir!, target.path)) + if (!file) return notFound() + const stat = yield* Effect.promise(() => Bun.file(file).stat()).pipe(Effect.orElseSucceed(() => undefined)) + if (!stat) return notFound() + // Revalidated on every use: the URL is unversioned, and a stale bundle after an upgrade is + // worse than a cheap loopback round trip. + const etag = `"${DesignWhiteboard.VERSION}-${stat.size}-${Math.floor(stat.mtimeMs)}"` + const headers = { + "access-control-allow-origin": "*", + "cache-control": "no-cache", + etag, + "x-content-type-options": "nosniff", + } + if (request.headers["if-none-match"] === etag) return HttpServerResponse.empty({ status: 304, headers }) + const bytes = yield* Effect.promise(() => Bun.file(file).arrayBuffer()) + return HttpServerResponse.uint8Array(new Uint8Array(bytes), { + headers: { ...headers, "content-type": DesignWhiteboard.assetMime(file)! }, + }) + } + // Not a prototype's: the assets every prototype may use. Public, immutable per release. if (target.kind === "vendor") { const asset = DesignVendor.FILES[target.name] @@ -134,6 +161,7 @@ export function serveDesignEffect(request: HttpServerRequest.HttpServerRequest) if (target.kind === "shell") { const caps = yield* registry.attachments() const network = DesignHost.networkURL(Server.url) + const whiteboard = yield* registry.whiteboard() return HttpServerResponse.text( DesignShell.shellHTML({ id: prototype.id, @@ -150,6 +178,7 @@ export function serveDesignEffect(request: HttpServerRequest.HttpServerRequest) gate: settings.gate && url.searchParams.get("gate") !== "0", gateTimeoutMs: settings.gateTimeoutMs, ...(network ? { networkUrl: `${network}/design/${prototype.id}` } : {}), + whiteboard: whiteboard.status === "ready", ...(prototype.ended ? { ended: prototype.ended.by } : {}), embed: url.searchParams.get("embed") === "1", }), @@ -214,6 +243,95 @@ export function serveDesignEffect(request: HttpServerRequest.HttpServerRequest) return HttpServerResponse.jsonUnsafe({ ended: ended?.ended?.by ?? "user" }) } + // --- whiteboards --------------------------------------------------------------------------- + // The frame page carries a token minted for this prototype; the shell accepts a frame only + // after the server confirms the token and the frame proves descent from the prototype frame. + if (target.kind === "whiteboard-frame") { + if (request.method !== "GET") return notFound() + const bundle = yield* registry.whiteboard() + if (!bundle.dir) { + const copy = + bundle.status === "fetching" + ? "The whiteboard bundle is being fetched. Reload the review in a moment." + : "The whiteboard bundle is not available on this machine. Diagrams stay as they are." + return HttpServerResponse.text(`

${copy}

`, { + status: 503, + headers: { "content-type": "text/html; charset=utf-8", "cache-control": "no-store" }, + }) + } + return HttpServerResponse.text(DesignWhiteboard.frameHTML(DesignWhiteboard.mintChannel(bundle.secret, prototype.id)), { + headers: { + "content-type": "text/html; charset=utf-8", + "content-security-policy": DesignWhiteboard.frameCSP(`${vendorPrefix(request)}whiteboard/`), + "referrer-policy": "no-referrer", + "cache-control": "no-store", + }, + }) + } + + if (target.kind === "mermaid-sources") { + if (!authorised) return forbidden() + if (request.method !== "GET") return notFound() + const entry = DesignServe.resolve(prototype.root, "/index.html") + const html = entry ? yield* Effect.promise(() => Bun.file(entry).text()).pipe(Effect.orElseSucceed(() => "")) : "" + const sources = DesignWhiteboard.extractSources(html).map((item) => ({ + ...item, + hash: DesignWhiteboard.sourceHash(item.source), + })) + return HttpServerResponse.jsonUnsafe({ sources }) + } + + if (target.kind === "whiteboard-channel") { + if (!authorised) return forbidden() + if (request.method !== "POST") return notFound() + if (!sameOrigin(request, url)) return forbidden() + const body = yield* readJSON(request) + if (body === undefined) return tooLarge() + const bundle = yield* registry.whiteboard() + if (!DesignWhiteboard.verifyChannel(body.token, bundle.secret, prototype.id)) { + return HttpServerResponse.jsonUnsafe({ error: "invalid whiteboard channel" }, { status: 403 }) + } + return HttpServerResponse.jsonUnsafe({ status: "authenticated" }) + } + + if (target.kind === "whiteboard") { + if (!authorised) return forbidden() + if (request.method === "GET") { + const saved = yield* Effect.promise(() => DesignWhiteboard.load(prototype.root, target.index)) + return HttpServerResponse.jsonUnsafe({ whiteboard: saved }) + } + if (request.method !== "PUT") return notFound() + if (!sameOrigin(request, url)) return forbidden() + if (prototype.ended) return gone(prototype.ended.by) + const body = yield* readJSON(request, DesignWhiteboard.MAX_BODY_BYTES) + if (body === undefined) return tooLarge() + yield* Effect.promise(() => + DesignWhiteboard.save(prototype.root, target.index, { + sourceHash: body.source_hash ?? body.sourceHash, + textMetricsVersion: body.text_metrics_version ?? body.textMetricsVersion, + scene: body.scene ?? null, + baseline: body.baseline ?? null, + }), + ) + return HttpServerResponse.jsonUnsafe({ status: "saved" }) + } + + if (target.kind === "whiteboard-files") { + if (!authorised) return forbidden() + if (request.method !== "POST") return notFound() + if (!sameOrigin(request, url)) return forbidden() + if (prototype.ended) return gone(prototype.ended.by) + const body = yield* readJSON(request, DesignWhiteboard.MAX_BODY_BYTES) + if (body === undefined) return tooLarge() + const written = yield* Effect.promise(() => + DesignWhiteboard.writeFeedbackFiles(prototype.root, target.index, { + scene: body.scene ?? null, + pngDataUrl: body.pngDataUrl ?? body.png_data_url, + }), + ) + return HttpServerResponse.jsonUnsafe({ scene_path: written.scenePath, preview_path: written.previewPath }) + } + // One file, with everything local inside it. Downloaded in the ordinary case; if a browser // renders it instead, the prototype's own policy keeps it at an opaque origin. if (target.kind === "export") { @@ -552,11 +670,14 @@ export function serveDesignEffect(request: HttpServerRequest.HttpServerRequest) } const mime = DesignServe.mimeFor(file)! + const whiteboard = yield* registry.whiteboard() + const framePath = `/design/${prototype.id}/whiteboard` const headers: Record = { "content-type": mime, "content-security-policy": DesignServe.prototypeCSP({ assets: assetPrefix(request, prototype.id), vendor: vendorPrefix(request), + ...(whiteboard.status === "ready" ? { frame: `${assetPrefix(request, prototype.id).replace(/\/files\/$/, "")}/whiteboard` } : {}), }), "referrer-policy": "no-referrer", "x-content-type-options": "nosniff", @@ -575,6 +696,7 @@ export function serveDesignEffect(request: HttpServerRequest.HttpServerRequest) maxBytes: caps.maxBytes, accepted: DesignAttachments.ACCEPTED_MIME, }, + ...(whiteboard.status === "ready" ? { whiteboard: { frame: framePath } } : {}), }), { headers }, ) diff --git a/packages/redcode/src/session/prompt/design-mode.txt b/packages/redcode/src/session/prompt/design-mode.txt index ee551b99eba7..c77a0e1361fa 100644 --- a/packages/redcode/src/session/prompt/design-mode.txt +++ b/packages/redcode/src/session/prompt/design-mode.txt @@ -15,6 +15,8 @@ A note may come with an image — a screenshot, a sketch, something they want it Revise and keep going: the page reloads itself when you save, keeps the user's place and their unsent notes, and shows them your reply. Call `design_preview` again when you want the review window opened or the craft notes re-run. When they want a copy to open elsewhere or send to someone, `design_export` writes the prototype as one self-contained HTML file (the page has the same under ⋮ → Export); when the server listens on the network, `design_preview` also prints the URL a phone on the same network can open. +A Mermaid diagram in a `.mermaid` (or `data-redcode-mermaid`) container can be opened as a whiteboard: the user drags nodes around, redraws arrows, adds shapes and freehand marks, and queues the result. It arrives as a note whose target reads `whiteboard: diagram N`, with a summary of what changed (added, removed, moved, relabeled, drawn) and the paths of the edited scene JSON and a PNG preview on disk. Read the summary and look at the PNG if the words are not enough; then edit the Mermaid source in the prototype. Never write the scene back into the page: the diagram stays Mermaid, and the whiteboard is how the user talks about it. + The browser also audits the layout on its own — text clipped by its container, controls off-screen, a page that scrolls sideways, text covered by another element — and keeps what it finds in an inbox on the review page. That inbox is the user's: nothing in it reaches you until they queue it, and then it arrives as a numbered note whose target reads `layout issues: N queued for repair`. Fix every item in one pass before saving, so the page reloads once. A queued issue is a repair request, not a resolved one; it is marked resolved only when a newer revision's pass no longer finds it, so do not report it fixed until the next `` or `design_preview` says so. The one report that does arrive unasked is ``: the prototype's document or a local asset it names could not be served, so there is nothing to review — fix the file or the reference first. The page can collect structured answers: inside a `data-redcode-question="id"` wrapper, native radios, checkboxes, selects and inputs stay interactive, and a submit or "Queue answer" button that calls `window.redcodeDesign.queuePrompt(text, { tag, text, data, element })` sends exactly one answer per question (an unsent answer for the same question replaces the earlier one). Put `data-redcode-action` only on a custom element that should behave like a control. Native controls need nothing. diff --git a/packages/redcode/test/design/feedback.test.ts b/packages/redcode/test/design/feedback.test.ts index 88bd3ef382a1..b8925cfb7074 100644 --- a/packages/redcode/test/design/feedback.test.ts +++ b/packages/redcode/test/design/feedback.test.ts @@ -241,3 +241,28 @@ describe("a batch of layout fixes", () => { expect(failures).toContain("design_preview") }) }) + +describe("a whiteboard's edits", () => { + test("arrive as a note that names the diagram and carries bounded paths and counts", () => { + const [item] = DesignFeedback.normalize([ + { + tag: "whiteboard", + text: "Whiteboard edits to diagram 2:\nMoved by (40, 0): rectangle \"Server\" (B)", + target: { + type: "excalidraw-scene", + diagramIndex: 1, + diagramId: "mermaid-1", + sourceHash: "abcd", + scenePath: "/w/.review/whiteboards/1.excalidraw", + previewPath: "/w/.review/whiteboards/1.png", + imageFallback: false, + stats: { added: 0, removed: 0, moved: 1, relabeled: 0, drawn: 99999 }, + }, + }, + ]) + expect(item!.target).toMatchObject({ type: "excalidraw-scene", diagramIndex: 1, scenePath: "/w/.review/whiteboards/1.excalidraw" }) + expect((item!.target as { stats: { drawn: number } }).stats.drawn).toBe(10000) + expect(DesignFeedback.where(item!)).toBe("whiteboard: diagram 2 (mermaid-1)") + expect(DesignFeedback.render([item!], { prototype: "p", revision: 3 })).toContain("1. [whiteboard: diagram 2 (mermaid-1)] Whiteboard edits") + }) +}) diff --git a/packages/redcode/test/design/route.test.ts b/packages/redcode/test/design/route.test.ts index 7abdf05ac290..81cfeb562eff 100644 --- a/packages/redcode/test/design/route.test.ts +++ b/packages/redcode/test/design/route.test.ts @@ -664,3 +664,141 @@ describe("the export and the names the surface answers to", () => { }), ) }) + +describe("whiteboards", () => { + const json = (body: unknown, token: string, method = "POST"): RequestInit => ({ + method, + headers: { "content-type": "application/json", "x-redcode-design-token": token }, + body: JSON.stringify(body), + }) + const read = (response: HttpServerResponse.HttpServerResponse) => + bodyOf(response).pipe(Effect.map((text) => JSON.parse(text) as Record)) + const PNG = + "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==" + const DIAGRAMS = ` +

Demo

+
flowchart TD
+  A["OBJECTIVE:
do the thing"] --> B{Ready?}
+
sequenceDiagram
+  CLI->>Server: poll
+` + + /** A bundle on disk, so the frame page and its assets are served; the real one is a build away. */ + const withBundle = (directory: string) => + Effect.gen(function* () { + const bundle = path.join(directory, "whiteboard-bundle") + yield* Effect.promise(async () => { + const { promises: fs } = await import("node:fs") + await fs.mkdir(path.join(bundle, "fonts", "Excalifont"), { recursive: true }) + await fs.writeFile(path.join(bundle, "whiteboard.js"), "// fake bundle\n") + await fs.writeFile(path.join(bundle, "whiteboard.css"), "body{}\n") + await fs.writeFile(path.join(bundle, "fonts", "Excalifont", "Excalifont-Regular.woff2"), "fake-font") + }) + process.env["REDCODE_WHITEBOARD_DIR"] = bundle + return bundle + }) + + it.instance("the sources come from the file, in order, with hashes; scenes round-trip beside the review", () => + Effect.gen(function* () { + const { directory } = yield* TestInstance + const prototype = yield* prototypeIn(directory, "diagrams", { "index.html": DIAGRAMS }) + expect((yield* call(`/design/${prototype.id}/mermaid-sources`)).status).toBe(403) + const sources = yield* read( + yield* call(`/design/${prototype.id}/mermaid-sources`, { headers: { "x-redcode-design-token": prototype.token } }), + ) + expect(sources.sources.length).toBe(2) + const flowchart = 'flowchart TD\n A["OBJECTIVE:
do the thing"] --> B{Ready?}' + expect(sources.sources[0]).toMatchObject({ index: 0, source: flowchart }) + expect(sources.sources[0].hash).toMatch(/^[0-9a-f]{16}$/) + expect(sources.sources[1].source).toBe("sequenceDiagram\n CLI->>Server: poll") + + const empty = yield* read( + yield* call(`/design/${prototype.id}/whiteboard/0`, { headers: { "x-redcode-design-token": prototype.token } }), + ) + expect(empty.whiteboard).toBeNull() + const scene = { elements: [{ id: "A", type: "rectangle" }], appState: { theme: "dark" }, files: {} } + const put = yield* call( + `/design/${prototype.id}/whiteboard/0`, + json({ source_hash: "hash-1", text_metrics_version: 1, scene, baseline: { elements: scene.elements } }, prototype.token, "PUT"), + ) + expect(put.status).toBe(200) + const loaded = yield* read( + yield* call(`/design/${prototype.id}/whiteboard/0`, { headers: { "x-redcode-design-token": prototype.token } }), + ) + expect(loaded.whiteboard.source_hash).toBe("hash-1") + expect(loaded.whiteboard.scene).toEqual({ ...scene, appState: {} }) + expect(loaded.whiteboard.baseline).toEqual({ elements: scene.elements }) + // Written beside the review's own state, which is never served. + expect((yield* call(`/design/${prototype.id}/files/.review/whiteboards/0.json`)).status).toBe(404) + + const files = yield* read( + yield* call( + `/design/${prototype.id}/whiteboard/1/feedback-files`, + json({ scene: { elements: [{ id: "B", type: "ellipse" }], appState: {}, files: {} }, pngDataUrl: PNG }, prototype.token), + ), + ) + expect(files.scene_path.endsWith(path.join(".review", "whiteboards", "1.excalidraw"))).toBe(true) + expect(files.preview_path.endsWith("1.png")).toBe(true) + + // A foreign origin cannot write, and an index that is not a diagram's is not a route. + const cross = yield* call(`/design/${prototype.id}/whiteboard/0`, { + method: "PUT", + headers: { "content-type": "application/json", "x-redcode-design-token": prototype.token, origin: "https://evil.example" }, + body: JSON.stringify({ source_hash: "x", scene: null }), + }) + expect(cross.status).toBe(403) + expect((yield* call(`/design/${prototype.id}/whiteboard/1000`)).status).toBe(404) + }), + ) + + it.instance("the frame page carries a channel token only this prototype can prove, and the bundle answers every origin", () => + Effect.gen(function* () { + const { directory } = yield* TestInstance + const bundle = yield* withBundle(directory) + try { + const prototype = yield* prototypeIn(directory, "frame", { "index.html": DIAGRAMS }) + const other = yield* prototypeIn(directory, "frame-other", { "index.html": DIAGRAMS }) + const page = yield* call(`/design/${prototype.id}/whiteboard?index=0`) + expect(page.status).toBe(200) + expect(page.headers["cache-control"]).toBe("no-store") + expect(String(page.headers["content-security-policy"])).toMatch(/connect-src http:\/\/127\.0\.0\.1(:\d+)?\/design\/vendor\/whiteboard\//) + const html = yield* bodyOf(page) + expect(html).toContain('') + const token = /__redcodeWhiteboardChannelToken="([^"]+)"/.exec(html)?.[1] ?? "" + expect(token.length).toBeGreaterThan(20) + + const accepted = yield* call(`/design/${prototype.id}/whiteboard-channel`, json({ token }, prototype.token)) + expect(accepted.status).toBe(200) + const forged = yield* call(`/design/${prototype.id}/whiteboard-channel`, json({ token: "forged" }, prototype.token)) + expect(forged.status).toBe(403) + // Minted for one prototype, useless for another. + const foreign = yield* call(`/design/${other.id}/whiteboard-channel`, json({ token }, other.token)) + expect(foreign.status).toBe(403) + + const script = yield* call(`/design/vendor/whiteboard/whiteboard.js`) + expect(script.status).toBe(200) + expect(script.headers["access-control-allow-origin"]).toBe("*") + expect(String(script.headers["content-type"])).toContain("javascript") + const font = yield* call(`/design/vendor/whiteboard/fonts/Excalifont/Excalifont-Regular.woff2`) + expect(font.status).toBe(200) + expect(font.headers["access-control-allow-origin"]).toBe("*") + expect((yield* call(`/design/vendor/whiteboard/..%2F..%2Fstate.json`)).status).toBe(404) + expect((yield* call(`/design/vendor/whiteboard/nope.js`)).status).toBe(404) + const again = yield* call(`/design/vendor/whiteboard/whiteboard.js`, { + headers: { "if-none-match": String(script.headers["etag"]) }, + }) + expect(again.status).toBe(304) + + // With the bundle here, the prototype may frame its whiteboards and the SDK knows where. + const served = yield* call(`/design/${prototype.id}/files/index.html`) + expect(String(served.headers["content-security-policy"])).toMatch(new RegExp(`frame-src http://127\\.0\\.0\\.1(:\\d+)?/design/${prototype.id}/whiteboard`)) + expect(yield* bodyOf(served)).toContain(`"whiteboard":{"frame":"/design/${prototype.id}/whiteboard"}`) + const shell = yield* bodyOf(yield* call(`/design/${prototype.id}`)) + expect(shell).toContain('"whiteboard":true') + } finally { + delete process.env["REDCODE_WHITEBOARD_DIR"] + yield* Effect.promise(() => import("node:fs").then(({ promises }) => promises.rm(bundle, { recursive: true, force: true }))) + } + }), + ) +}) diff --git a/packages/redcode/test/design/sdk.test.ts b/packages/redcode/test/design/sdk.test.ts index bcf27215860d..ec2b9ed971e3 100644 --- a/packages/redcode/test/design/sdk.test.ts +++ b/packages/redcode/test/design/sdk.test.ts @@ -107,3 +107,19 @@ describe("the layout audit inside the prototype", () => { expect(() => new Function(script)).not.toThrow() }) }) + +describe("whiteboards inside the prototype", () => { + test("join each rendered diagram only when the bundle is here, as a sibling frame with no origin", () => { + const off = sdkScript({ load: 1 }) + expect(off).not.toContain('"whiteboard"') + const on = sdkScript({ load: 1, whiteboard: { frame: "/design/p1/whiteboard" } }) + expect(on).toContain('"whiteboard":{"frame":"/design/p1/whiteboard"}') + expect(on).toContain('setAttribute("sandbox", "allow-scripts allow-popups")') + expect(on).toContain('setAttribute("data-redcode-ui", "whiteboard-inline")') + expect(on).toContain('case "suspendWhiteboard"') + expect(on).toContain('case "resumeWhiteboard"') + expect(on).toContain('container.insertAdjacentElement("afterend", entry.iframe)') + expect(on).not.toContain("fetch(") + expect(() => new Function(on)).not.toThrow() + }) +}) diff --git a/packages/redcode/test/design/shell.test.ts b/packages/redcode/test/design/shell.test.ts index d99e5b0cce06..4c8f02823cda 100644 --- a/packages/redcode/test/design/shell.test.ts +++ b/packages/redcode/test/design/shell.test.ts @@ -212,3 +212,23 @@ describe("export and another device", () => { expect(() => new Function(script)).not.toThrow() }) }) + +describe("whiteboards in the shell", () => { + test("hosts a diagram full screen in a frame with no origin, and mediates every read and write", () => { + expect(html).toMatch(/id="whiteboardFrame"[^>]*sandbox="allow-scripts allow-popups"/) + expect(html).not.toMatch(/id="whiteboardFrame"[^>]*allow-same-origin/) + // The prototype frame's own sandbox is unchanged by this. + expect(html).toContain('