diff --git a/.depot/workflows/ci-backend.yml b/.depot/workflows/ci-backend.yml
index df9781c6be02..eaa4245582a6 100644
--- a/.depot/workflows/ci-backend.yml
+++ b/.depot/workflows/ci-backend.yml
@@ -1645,6 +1645,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 8fb9cdba8b6d..2058dfb9dbcc 100644
--- a/.dockerignore
+++ b/.dockerignore
@@ -4,6 +4,7 @@
!.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 60279ee3c3e0..d83a575fc4e7 100644
--- a/.github/workflows/ci-backend.yml
+++ b/.github/workflows/ci-backend.yml
@@ -2539,6 +2539,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/.semgrep/rules/security/idor-team-scoped-models.yaml b/.semgrep/rules/security/idor-team-scoped-models.yaml
index 77a87128dd31..b71927914410 100644
--- a/.semgrep/rules/security/idor-team-scoped-models.yaml
+++ b/.semgrep/rules/security/idor-team-scoped-models.yaml
@@ -83,6 +83,9 @@ rules:
|BatchImport
|BriefConfig
|ButtonTile
+ |CanvasApplication
+ |CanvasBuild
+ |CanvasSourceVersion
|ChangeRequest
|ClusteringConfig
|ClusteringJob
@@ -403,6 +406,9 @@ rules:
|BatchImport
|BriefConfig
|ButtonTile
+ |CanvasApplication
+ |CanvasBuild
+ |CanvasSourceVersion
|ChangeRequest
|ClusteringConfig
|ClusteringJob
diff --git a/Dockerfile b/Dockerfile
index 2cfe99494aa4..9626021bb468 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
@@ -397,6 +399,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..9216cb0a4898
--- /dev/null
+++ b/common/canvas-builder/build.mjs
@@ -0,0 +1,464 @@
+import { build, transform } from 'esbuild'
+import { createHash } from 'node:crypto'
+import { builtinModules, createRequire } from 'node:module'
+import path from 'node:path'
+
+const require = createRequire(import.meta.url)
+const admittedDependencies = new Map([
+ ['@posthog/quill', '0.3.0-beta.24'],
+ ['d3', '7.9.0'],
+ ['date-fns', '4.1.0'],
+ ['echarts', '6.1.0'],
+ ['lodash-es', '4.18.1'],
+ ['react', '19.2.6'],
+ ['react-dom', '19.2.6'],
+ ['three', '0.183.2'],
+ ['zod', '4.4.3'],
+])
+const nodeBuiltins = new Set([...builtinModules, ...builtinModules.map((name) => `node:${name}`)])
+const sourceExtensions = ['.ts', '.tsx', '.js', '.jsx', '.mjs', '.css', '.json']
+const moduleScript = /')
+ if (artifactFiles['assets/main.css']) {
+ builtHtml = builtHtml.replace(/<\/head>/i, '')
+ }
+ } catch (error) {
+ const errors = error?.errors ?? []
+ return {
+ ok: false,
+ diagnostics: errors.length
+ ? errors.slice(0, 500).map((entry) =>
+ diagnostic(
+ 'compile_error',
+ entry.text ?? 'Canvas compilation failed',
+ entry.location?.file ? normalizeProjectPath(entry.location.file) : undefined,
+ {
+ ...(entry.location?.line ? { line: entry.location.line } : {}),
+ ...(entry.location?.column !== undefined ? { column: entry.location.column } : {}),
+ }
+ )
+ )
+ : [diagnostic('build_failed', error instanceof Error ? error.message : String(error))],
+ }
+ }
+ }
+ artifactFiles['index.html'] = builtHtml
+ artifactFiles[runtimePath] = runtime
+ const escapedCsp = contentSecurityPolicy(project).replaceAll('&', '&').replaceAll('"', '"')
+ artifactFiles['index.html'] = injectHead(
+ artifactFiles['index.html'],
+ // nosemgrep: javascript.lang.security.audit.unknown-value-with-script-tag.unknown-value-with-script-tag -- CSP origins are URL-validated and HTML-attribute escaped; runtimePath is constant.
+ ``
+ )
+ const files = Object.entries(artifactFiles)
+ .map(([filePath, content]) => ({
+ path: filePath,
+ contentType: contentType(filePath),
+ bytes: Buffer.byteLength(content),
+ sha256: createHash('sha256').update(content).digest('hex'),
+ }))
+ .sort((left, right) => left.path.localeCompare(right.path))
+ return {
+ ok: true,
+ diagnostics: [],
+ artifactFiles,
+ manifest: {
+ schemaVersion: 1,
+ entryHtml: 'index.html',
+ files,
+ canvasSdkVersion: project.canvasSdkVersion,
+ dependencies: project.dependencies,
+ capabilities: project.capabilities,
+ },
+ }
+}
+
+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({
+ ok: false,
+ 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..9fd492ea7635
--- /dev/null
+++ b/common/canvas-builder/package-lock.json
@@ -0,0 +1,1077 @@
+{
+ "name": "@posthog/canvas-builder",
+ "version": "1.0.0",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "@posthog/canvas-builder",
+ "version": "1.0.0",
+ "dependencies": {
+ "@posthog/quill": "0.3.0-beta.24",
+ "d3": "7.9.0",
+ "date-fns": "4.1.0",
+ "echarts": "6.1.0",
+ "esbuild": "0.28.1",
+ "lodash-es": "4.18.1",
+ "react": "19.2.6",
+ "react-dom": "19.2.6",
+ "three": "0.183.2",
+ "zod": "4.4.3"
+ }
+ },
+ "../../node_modules/.pnpm/@posthog+quill@0.3.0-beta.24_@base-ui+react@1.6.0_@date-fns+tz@1.4.1_@types+react@18.3._53a6abc235f08e9f8fdb2e5f5b6c80ed/node_modules/@posthog/quill": {
+ "version": "0.3.0-beta.24",
+ "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"
+ },
+ "devDependencies": {
+ "@base-ui/react": "1.6.0",
+ "@posthog/quill-blocks": "0.3.0-beta.15",
+ "@posthog/quill-components": "0.3.0-beta.15",
+ "@posthog/quill-primitives": "0.3.0-beta.15",
+ "@posthog/quill-tokens": "0.3.0-beta.24",
+ "tsx": "^4.19.0",
+ "vite": "^8.1.0",
+ "vite-plugin-dts": "^4.5.4"
+ },
+ "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/.pnpm/three@0.183.2/node_modules/three": {
+ "version": "0.183.2",
+ "license": "MIT",
+ "devDependencies": {
+ "@eslint/js": "^9.0.0",
+ "@rollup/plugin-node-resolve": "^16.0.0",
+ "@rollup/plugin-terser": "^0.4.0",
+ "eslint": "^9.0.0",
+ "eslint-config-mdcs": "^5.0.0",
+ "eslint-plugin-compat": "^6.0.0",
+ "eslint-plugin-html": "^8.1.3",
+ "eslint-plugin-jsdoc": "^62.0.0",
+ "globals": "^17.0.0",
+ "jpeg-js": "^0.4.4",
+ "jsdoc": "^4.0.5",
+ "magic-string": "^0.30.0",
+ "pngjs": "^7.0.0",
+ "puppeteer": "^24.25.0",
+ "qunit": "^2.19.4",
+ "rollup": "^4.6.0",
+ "turndown": "^7.2.2"
+ }
+ },
+ "node_modules/@esbuild/aix-ppc64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz",
+ "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==",
+ "cpu": [
+ "ppc64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "aix"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-arm": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz",
+ "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==",
+ "cpu": [
+ "arm"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-arm64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz",
+ "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-x64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz",
+ "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/darwin-arm64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz",
+ "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/darwin-x64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz",
+ "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/freebsd-arm64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz",
+ "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/freebsd-x64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz",
+ "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-arm": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz",
+ "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==",
+ "cpu": [
+ "arm"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-arm64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz",
+ "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-ia32": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz",
+ "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==",
+ "cpu": [
+ "ia32"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-loong64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz",
+ "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==",
+ "cpu": [
+ "loong64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-mips64el": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz",
+ "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==",
+ "cpu": [
+ "mips64el"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-ppc64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz",
+ "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==",
+ "cpu": [
+ "ppc64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-riscv64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz",
+ "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==",
+ "cpu": [
+ "riscv64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-s390x": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz",
+ "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==",
+ "cpu": [
+ "s390x"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-x64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz",
+ "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/netbsd-arm64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz",
+ "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/netbsd-x64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz",
+ "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openbsd-arm64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz",
+ "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openbsd-x64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz",
+ "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openharmony-arm64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz",
+ "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openharmony"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/sunos-x64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz",
+ "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "sunos"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-arm64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz",
+ "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-ia32": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz",
+ "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==",
+ "cpu": [
+ "ia32"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-x64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz",
+ "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@posthog/quill": {
+ "resolved": "../../node_modules/.pnpm/@posthog+quill@0.3.0-beta.24_@base-ui+react@1.6.0_@date-fns+tz@1.4.1_@types+react@18.3._53a6abc235f08e9f8fdb2e5f5b6c80ed/node_modules/@posthog/quill",
+ "link": true
+ },
+ "node_modules/commander": {
+ "version": "7.2.0",
+ "resolved": "https://registry.npmjs.org/commander/-/commander-7.2.0.tgz",
+ "integrity": "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/d3": {
+ "version": "7.9.0",
+ "resolved": "https://registry.npmjs.org/d3/-/d3-7.9.0.tgz",
+ "integrity": "sha512-e1U46jVP+w7Iut8Jt8ri1YsPOvFpg46k+K8TpCb0P+zjCkjkPnV7WzfDJzMHy1LnA+wj5pLT1wjO901gLXeEhA==",
+ "license": "ISC",
+ "dependencies": {
+ "d3-array": "3",
+ "d3-axis": "3",
+ "d3-brush": "3",
+ "d3-chord": "3",
+ "d3-color": "3",
+ "d3-contour": "4",
+ "d3-delaunay": "6",
+ "d3-dispatch": "3",
+ "d3-drag": "3",
+ "d3-dsv": "3",
+ "d3-ease": "3",
+ "d3-fetch": "3",
+ "d3-force": "3",
+ "d3-format": "3",
+ "d3-geo": "3",
+ "d3-hierarchy": "3",
+ "d3-interpolate": "3",
+ "d3-path": "3",
+ "d3-polygon": "3",
+ "d3-quadtree": "3",
+ "d3-random": "3",
+ "d3-scale": "4",
+ "d3-scale-chromatic": "3",
+ "d3-selection": "3",
+ "d3-shape": "3",
+ "d3-time": "3",
+ "d3-time-format": "4",
+ "d3-timer": "3",
+ "d3-transition": "3",
+ "d3-zoom": "3"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "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-axis": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/d3-axis/-/d3-axis-3.0.0.tgz",
+ "integrity": "sha512-IH5tgjV4jE/GhHkRV0HiVYPDtvfjHQlQfJHs0usq7M30XcSBvOotpmH1IgkcXsO/5gEQZD43B//fc7SRT5S+xw==",
+ "license": "ISC",
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-brush": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/d3-brush/-/d3-brush-3.0.0.tgz",
+ "integrity": "sha512-ALnjWlVYkXsVIGlOsuWH1+3udkYFI48Ljihfnh8FZPF2QS9o+PzGLBslO0PjzVoHLZ2KCVgAM8NVkXPJB2aNnQ==",
+ "license": "ISC",
+ "dependencies": {
+ "d3-dispatch": "1 - 3",
+ "d3-drag": "2 - 3",
+ "d3-interpolate": "1 - 3",
+ "d3-selection": "3",
+ "d3-transition": "3"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-chord": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/d3-chord/-/d3-chord-3.0.1.tgz",
+ "integrity": "sha512-VE5S6TNa+j8msksl7HwjxMHDM2yNK3XCkusIlpX5kwauBfXuyLAtNg9jCp/iHH61tgI4sb6R/EIMWCqEIdjT/g==",
+ "license": "ISC",
+ "dependencies": {
+ "d3-path": "1 - 3"
+ },
+ "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-contour": {
+ "version": "4.0.2",
+ "resolved": "https://registry.npmjs.org/d3-contour/-/d3-contour-4.0.2.tgz",
+ "integrity": "sha512-4EzFTRIikzs47RGmdxbeUvLWtGedDUNkTcmzoeyg4sP/dvCexO47AaQL7VKy/gul85TOxw+IBgA8US2xwbToNA==",
+ "license": "ISC",
+ "dependencies": {
+ "d3-array": "^3.2.0"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-delaunay": {
+ "version": "6.0.4",
+ "resolved": "https://registry.npmjs.org/d3-delaunay/-/d3-delaunay-6.0.4.tgz",
+ "integrity": "sha512-mdjtIZ1XLAM8bm/hx3WwjfHt6Sggek7qH043O8KEjDXN40xi3vx/6pYSVTwLjEgiXQTbvaouWKynLBiUZ6SK6A==",
+ "license": "ISC",
+ "dependencies": {
+ "delaunator": "5"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-dispatch": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-3.0.1.tgz",
+ "integrity": "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==",
+ "license": "ISC",
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-drag": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/d3-drag/-/d3-drag-3.0.0.tgz",
+ "integrity": "sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==",
+ "license": "ISC",
+ "dependencies": {
+ "d3-dispatch": "1 - 3",
+ "d3-selection": "3"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-dsv": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/d3-dsv/-/d3-dsv-3.0.1.tgz",
+ "integrity": "sha512-UG6OvdI5afDIFP9w4G0mNq50dSOsXHJaRE8arAS5o9ApWnIElp8GZw1Dun8vP8OyHOZ/QJUKUJwxiiCCnUwm+Q==",
+ "license": "ISC",
+ "dependencies": {
+ "commander": "7",
+ "iconv-lite": "0.6",
+ "rw": "1"
+ },
+ "bin": {
+ "csv2json": "bin/dsv2json.js",
+ "csv2tsv": "bin/dsv2dsv.js",
+ "dsv2dsv": "bin/dsv2dsv.js",
+ "dsv2json": "bin/dsv2json.js",
+ "json2csv": "bin/json2dsv.js",
+ "json2dsv": "bin/json2dsv.js",
+ "json2tsv": "bin/json2dsv.js",
+ "tsv2csv": "bin/dsv2dsv.js",
+ "tsv2json": "bin/dsv2json.js"
+ },
+ "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-fetch": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/d3-fetch/-/d3-fetch-3.0.1.tgz",
+ "integrity": "sha512-kpkQIM20n3oLVBKGg6oHrUchHM3xODkTzjMoj7aWQFq5QEM+R6E4WkzT5+tojDY7yjez8KgCBRoj4aEr99Fdqw==",
+ "license": "ISC",
+ "dependencies": {
+ "d3-dsv": "1 - 3"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-force": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/d3-force/-/d3-force-3.0.0.tgz",
+ "integrity": "sha512-zxV/SsA+U4yte8051P4ECydjD/S+qeYtnaIyAs9tgHCqfguma/aAQDjo85A9Z6EKhBirHRJHXIgJUlffT4wdLg==",
+ "license": "ISC",
+ "dependencies": {
+ "d3-dispatch": "1 - 3",
+ "d3-quadtree": "1 - 3",
+ "d3-timer": "1 - 3"
+ },
+ "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-geo": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/d3-geo/-/d3-geo-3.1.1.tgz",
+ "integrity": "sha512-637ln3gXKXOwhalDzinUgY83KzNWZRKbYubaG+fGVuc/dxO64RRljtCTnf5ecMyE1RIdtqpkVcq0IbtU2S8j2Q==",
+ "license": "ISC",
+ "dependencies": {
+ "d3-array": "2.5.0 - 3"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-hierarchy": {
+ "version": "3.1.2",
+ "resolved": "https://registry.npmjs.org/d3-hierarchy/-/d3-hierarchy-3.1.2.tgz",
+ "integrity": "sha512-FX/9frcub54beBdugHjDCdikxThEqjnR93Qt7PvQTOHxyiNCAlvMrHhclk3cD5VeAaq9fxmfRp+CnWw9rEMBuA==",
+ "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-polygon": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/d3-polygon/-/d3-polygon-3.0.1.tgz",
+ "integrity": "sha512-3vbA7vXYwfe1SYhED++fPUQlWSYTTGmFmQiany/gdbiWgU/iEyQzyymwL9SkJjFFuCS4902BSzewVGsHHmHtXg==",
+ "license": "ISC",
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-quadtree": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/d3-quadtree/-/d3-quadtree-3.0.1.tgz",
+ "integrity": "sha512-04xDrxQTDTCFwP5H6hRhsRcb9xxv2RzkcsygFzmkSIOJy3PeRJP7sNk3VRIbKXcog561P9oU0/rVH6vDROAgUw==",
+ "license": "ISC",
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-random": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/d3-random/-/d3-random-3.0.1.tgz",
+ "integrity": "sha512-FXMe9GfxTxqd5D6jFsQ+DJ8BJS4E/fT5mqqdjovykEB2oFbTMDVdg1MGFxfQW+FBOGoB++k8swBrgwSHT1cUXQ==",
+ "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-scale-chromatic": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/d3-scale-chromatic/-/d3-scale-chromatic-3.1.0.tgz",
+ "integrity": "sha512-A3s5PWiZ9YCXFye1o246KoscMWqf8BsD9eRiJ3He7C9OBaxKhAd5TFCdEx/7VbKtxxTsu//1mMJFrEt572cEyQ==",
+ "license": "ISC",
+ "dependencies": {
+ "d3-color": "1 - 3",
+ "d3-interpolate": "1 - 3"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-selection": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz",
+ "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==",
+ "license": "ISC",
+ "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/d3-transition": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/d3-transition/-/d3-transition-3.0.1.tgz",
+ "integrity": "sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==",
+ "license": "ISC",
+ "dependencies": {
+ "d3-color": "1 - 3",
+ "d3-dispatch": "1 - 3",
+ "d3-ease": "1 - 3",
+ "d3-interpolate": "1 - 3",
+ "d3-timer": "1 - 3"
+ },
+ "engines": {
+ "node": ">=12"
+ },
+ "peerDependencies": {
+ "d3-selection": "2 - 3"
+ }
+ },
+ "node_modules/d3-zoom": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/d3-zoom/-/d3-zoom-3.0.0.tgz",
+ "integrity": "sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==",
+ "license": "ISC",
+ "dependencies": {
+ "d3-dispatch": "1 - 3",
+ "d3-drag": "2 - 3",
+ "d3-interpolate": "1 - 3",
+ "d3-selection": "2 - 3",
+ "d3-transition": "2 - 3"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/date-fns": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz",
+ "integrity": "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/kossnocorp"
+ }
+ },
+ "node_modules/delaunator": {
+ "version": "5.1.0",
+ "resolved": "https://registry.npmjs.org/delaunator/-/delaunator-5.1.0.tgz",
+ "integrity": "sha512-AGrQ4QSgssa1NGmWmLPqN5NY2KajF5MqxetNEO+o0n3ZwZZeTmt7bBnvzHWrmkZFxGgr4HdyFgelzgi06otLuQ==",
+ "license": "ISC",
+ "dependencies": {
+ "robust-predicates": "^3.0.2"
+ }
+ },
+ "node_modules/echarts": {
+ "version": "6.1.0",
+ "resolved": "https://registry.npmjs.org/echarts/-/echarts-6.1.0.tgz",
+ "integrity": "sha512-q0yaFPggC9FUdsWH4blavRWFmxdrIodbkoKNAjJudAI6CA9gNPxHtV2RcZNEepZVlk4yvBYkOkbk6HIVpIyHZA==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "tslib": "2.3.0",
+ "zrender": "6.1.0"
+ }
+ },
+ "node_modules/esbuild": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz",
+ "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==",
+ "hasInstallScript": true,
+ "license": "MIT",
+ "bin": {
+ "esbuild": "bin/esbuild"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "optionalDependencies": {
+ "@esbuild/aix-ppc64": "0.28.1",
+ "@esbuild/android-arm": "0.28.1",
+ "@esbuild/android-arm64": "0.28.1",
+ "@esbuild/android-x64": "0.28.1",
+ "@esbuild/darwin-arm64": "0.28.1",
+ "@esbuild/darwin-x64": "0.28.1",
+ "@esbuild/freebsd-arm64": "0.28.1",
+ "@esbuild/freebsd-x64": "0.28.1",
+ "@esbuild/linux-arm": "0.28.1",
+ "@esbuild/linux-arm64": "0.28.1",
+ "@esbuild/linux-ia32": "0.28.1",
+ "@esbuild/linux-loong64": "0.28.1",
+ "@esbuild/linux-mips64el": "0.28.1",
+ "@esbuild/linux-ppc64": "0.28.1",
+ "@esbuild/linux-riscv64": "0.28.1",
+ "@esbuild/linux-s390x": "0.28.1",
+ "@esbuild/linux-x64": "0.28.1",
+ "@esbuild/netbsd-arm64": "0.28.1",
+ "@esbuild/netbsd-x64": "0.28.1",
+ "@esbuild/openbsd-arm64": "0.28.1",
+ "@esbuild/openbsd-x64": "0.28.1",
+ "@esbuild/openharmony-arm64": "0.28.1",
+ "@esbuild/sunos-x64": "0.28.1",
+ "@esbuild/win32-arm64": "0.28.1",
+ "@esbuild/win32-ia32": "0.28.1",
+ "@esbuild/win32-x64": "0.28.1"
+ }
+ },
+ "node_modules/iconv-lite": {
+ "version": "0.6.3",
+ "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
+ "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==",
+ "license": "MIT",
+ "dependencies": {
+ "safer-buffer": ">= 2.1.2 < 3.0.0"
+ },
+ "engines": {
+ "node": ">=0.10.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/lodash-es": {
+ "version": "4.18.1",
+ "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.18.1.tgz",
+ "integrity": "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==",
+ "license": "MIT"
+ },
+ "node_modules/react": {
+ "version": "19.2.6",
+ "resolved": "https://registry.npmjs.org/react/-/react-19.2.6.tgz",
+ "integrity": "sha512-sfWGGfavi0xr8Pg0sVsyHMAOziVYKgPLNrS7ig+ivMNb3wbCBw3KxtflsGBAwD3gYQlE/AEZsTLgToRrSCjb0Q==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/react-dom": {
+ "version": "19.2.6",
+ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.6.tgz",
+ "integrity": "sha512-0prMI+hvBbPjsWnxDLxlCGyM8PN6UuWjEUCYmZhO67xIV9Xasa/r/vDnq+Xyq4Lo27g8QSbO5YzARu0D1Sps3g==",
+ "license": "MIT",
+ "dependencies": {
+ "scheduler": "^0.27.0"
+ },
+ "peerDependencies": {
+ "react": "^19.2.6"
+ }
+ },
+ "node_modules/robust-predicates": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/robust-predicates/-/robust-predicates-3.0.3.tgz",
+ "integrity": "sha512-NS3levdsRIUOmiJ8FZWCP7LG3QpJyrs/TE0Zpf1yvZu8cAJJ6QMW92H1c7kWpdIHo8RvmLxN/o2JXTKHp74lUA==",
+ "license": "Unlicense"
+ },
+ "node_modules/rw": {
+ "version": "1.3.3",
+ "resolved": "https://registry.npmjs.org/rw/-/rw-1.3.3.tgz",
+ "integrity": "sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ==",
+ "license": "BSD-3-Clause"
+ },
+ "node_modules/safer-buffer": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
+ "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
+ "license": "MIT"
+ },
+ "node_modules/scheduler": {
+ "version": "0.27.0",
+ "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz",
+ "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==",
+ "license": "MIT"
+ },
+ "node_modules/three": {
+ "resolved": "../../node_modules/.pnpm/three@0.183.2/node_modules/three",
+ "link": true
+ },
+ "node_modules/tslib": {
+ "version": "2.3.0",
+ "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.0.tgz",
+ "integrity": "sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==",
+ "license": "0BSD"
+ },
+ "node_modules/zod": {
+ "version": "4.4.3",
+ "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz",
+ "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==",
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/colinhacks"
+ }
+ },
+ "node_modules/zrender": {
+ "version": "6.1.0",
+ "resolved": "https://registry.npmjs.org/zrender/-/zrender-6.1.0.tgz",
+ "integrity": "sha512-oEGMDB6pOP2S6OwRR4PdVv610zrjnA3Bh+JnSG12fYJlBKjtNAoEb5fSUoCOOINlH96I2fU38/A2UpRKs67xYQ==",
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "tslib": "2.3.0"
+ }
+ }
+ }
+}
diff --git a/common/canvas-builder/package.json b/common/canvas-builder/package.json
new file mode 100644
index 000000000000..e7ff2c774ea9
--- /dev/null
+++ b/common/canvas-builder/package.json
@@ -0,0 +1,18 @@
+{
+ "name": "@posthog/canvas-builder",
+ "version": "1.0.0",
+ "private": true,
+ "type": "module",
+ "dependencies": {
+ "@posthog/quill": "0.3.0-beta.24",
+ "d3": "7.9.0",
+ "date-fns": "4.1.0",
+ "echarts": "6.1.0",
+ "esbuild": "0.28.1",
+ "lodash-es": "4.18.1",
+ "react": "19.2.6",
+ "react-dom": "19.2.6",
+ "three": "0.183.2",
+ "zod": "4.4.3"
+ }
+}
diff --git a/docs/internal/canvas-application-builds.md b/docs/internal/canvas-application-builds.md
new file mode 100644
index 000000000000..042e1e6ea3e0
--- /dev/null
+++ b/docs/internal/canvas-application-builds.md
@@ -0,0 +1,37 @@
+# Canvas application builds
+
+Canvas source and build records are control-plane metadata in PostgreSQL. The
+complete, compressed source project is stored privately under
+`canvas/source//sha256/…` in the configured object-storage bucket. Built
+HTML, JavaScript, and CSS are immutable objects under
+`canvas/artifacts///…`. Source archives are never served by the
+artifact endpoint.
+
+The `build_canvas` Celery task invokes the pinned Node builder shipped in the
+main Django image. The builder accepts the fixed source-project schema, disables
+package lifecycle scripts, bundles without executing canvas source, and emits a
+bounded manifest that the worker independently verifies before upload. A ready
+build becomes active only while its source version is still the canvas head;
+failed and stale builds cannot replace the last-known-good artifact.
+
+Production requires both settings below:
+
+- `CANVAS_ARTIFACT_ORIGIN`: an HTTPS origin dedicated to untrusted user content,
+ with requests for `/canvas-artifacts/*` routed to Django. It must not share the
+ PostHog application host or its cookies.
+- `CANVAS_ARTIFACT_SIGNING_KEYS`: comma-separated secrets of at least 32 bytes,
+ newest first. Keep old keys during rotation until issued five-minute artifact
+ URLs have expired.
+
+Token generation and artifact serving fail closed when either production
+requirement is absent. The artifact endpoint also rejects requests received on
+a host other than the configured user-content origin. Canvases run in an
+`allow-scripts` iframe without same-origin access, with a generated CSP and a
+manifest-enforced PostHog bridge. Direct external network capabilities remain
+disabled until a user-facing capability approval flow exists.
+
+The daily `collect_canvas_objects` task retains all source history, the active
+and previous builds, and pinned builds. It removes other build artifacts after
+30 days and deletes unreferenced source or artifact objects after a 24-hour
+recovery window. Hosted functions, secrets, databases, and serverless backends
+are future work and are not part of this runtime.
diff --git a/frontend/src/generated/core/api.schemas.ts b/frontend/src/generated/core/api.schemas.ts
index 14caaa09a019..7d890eeff3a2 100644
--- a/frontend/src/generated/core/api.schemas.ts
+++ b/frontend/src/generated/core/api.schemas.ts
@@ -2816,6 +2816,379 @@ export interface CanvasPublishConflictApi {
current_version_id: string | null
}
+/**
+ * * `queued` - Queued
+ * * `building` - Building
+ * * `ready` - Ready
+ * * `failed` - Failed
+ */
+export type CanvasBuildStatusEnumApi = (typeof CanvasBuildStatusEnumApi)[keyof typeof CanvasBuildStatusEnumApi]
+
+export const CanvasBuildStatusEnumApi = {
+ Queued: 'queued',
+ Building: 'building',
+ Ready: 'ready',
+ Failed: 'failed',
+} as const
+
+/**
+ * * `error` - error
+ * * `warning` - warning
+ * * `info` - info
+ */
+export type IngestionWarningSeverityEnumApi =
+ (typeof IngestionWarningSeverityEnumApi)[keyof typeof IngestionWarningSeverityEnumApi]
+
+export const IngestionWarningSeverityEnumApi = {
+ Error: 'error',
+ Warning: 'warning',
+ Info: 'info',
+} as const
+
+export interface CanvasDiagnosticApi {
+ /** Diagnostic severity.
+ *
+ * * `error` - error
+ * * `warning` - warning
+ * * `info` - info */
+ severity: IngestionWarningSeverityEnumApi
+ /**
+ * Stable diagnostic code.
+ * @maxLength 100
+ */
+ code: string
+ /**
+ * Build diagnostic message.
+ * @maxLength 10000
+ */
+ message: string
+ /** Project-relative source file. */
+ file?: string
+ /**
+ * One-based source line.
+ * @minimum 1
+ */
+ line?: number
+ /**
+ * Zero-based source column.
+ * @minimum 0
+ */
+ column?: number
+}
+
+export interface CanvasArtifactFileApi {
+ /** Normalized artifact path relative to this build. */
+ readonly path: string
+ /** HTTP content type for this artifact file. */
+ readonly contentType: string
+ /**
+ * UTF-8 artifact size in bytes.
+ * @minimum 0
+ */
+ readonly bytes: number
+ /**
+ * Lowercase SHA-256 digest of the artifact content.
+ * @pattern ^[a-f0-9]{64}$
+ */
+ readonly sha256: string
+}
+
+export interface CanvasPostHogCapabilitiesApi {
+ /**
+ * Insight short IDs that this canvas may load.
+ * @maxItems 256
+ * @items.minLength 1
+ * @items.maxLength 128
+ */
+ insights: string[]
+ /** Whether this canvas may execute inline PostHog queries. */
+ inlineQueries: boolean
+ /**
+ * Event names that this canvas may capture.
+ * @maxItems 256
+ * @items.minLength 1
+ * @items.maxLength 200
+ */
+ captureEvents: string[]
+}
+
+export interface CanvasNetworkCapabilitiesApi {
+ /**
+ * HTTPS origins that the canvas may contact directly.
+ * @maxItems 64
+ * @items.maxLength 2048
+ */
+ origins: string[]
+}
+
+export interface CanvasCapabilitiesApi {
+ /** PostHog data and capture capabilities. */
+ posthog: CanvasPostHogCapabilitiesApi
+ /** Direct network capabilities. */
+ network: CanvasNetworkCapabilitiesApi
+}
+
+/**
+ * Exact package versions included in this build.
+ */
+export type CanvasArtifactManifestApiDependencies = { [key: string]: string }
+
+export interface CanvasArtifactManifestApi {
+ /** Artifact manifest schema version. */
+ readonly schemaVersion: number
+ /** HTML entry file for this artifact. */
+ readonly entryHtml: string
+ /** Immutable emitted artifact files. */
+ readonly files: readonly CanvasArtifactFileApi[]
+ /** Canvas runtime SDK version used by this build. */
+ readonly canvasSdkVersion: string
+ /** Exact package versions included in this build. */
+ readonly dependencies: CanvasArtifactManifestApiDependencies
+ /** Capabilities enforced for this artifact. */
+ readonly capabilities: CanvasCapabilitiesApi
+}
+
+export interface CanvasBuildApi {
+ /** Immutable cloud build ID. */
+ readonly id: string
+ /** Source version compiled by this build. */
+ readonly sourceVersionId: string
+ /** Current build lifecycle status.
+ *
+ * * `queued` - Queued
+ * * `building` - Building
+ * * `ready` - Ready
+ * * `failed` - Failed */
+ readonly status: CanvasBuildStatusEnumApi
+ /**
+ * Short-lived URL for the immutable artifact entry HTML.
+ * @nullable
+ */
+ readonly artifactUrl: string | null
+ /**
+ * SHA-256 integrity value for entry HTML.
+ * @nullable
+ */
+ readonly integrity: string | null
+ /** Bounded build diagnostics. */
+ readonly diagnostics: readonly CanvasDiagnosticApi[]
+ /** Immutable artifact and capability manifest when ready. */
+ readonly manifest: CanvasArtifactManifestApi | null
+ /** Build creation time as Unix milliseconds. */
+ readonly createdAt: number
+ /**
+ * Build completion time as Unix milliseconds, if complete.
+ * @nullable
+ */
+ readonly completedAt: number | null
+}
+
+export interface CanvasSourceVersionApi {
+ /** Immutable source version ID. */
+ readonly id: string
+ /**
+ * Source version edited to create this version.
+ * @nullable
+ */
+ readonly parentVersionId: string | null
+ /** Task that produced this version. */
+ readonly taskId: string
+ /** Fresh task run that produced this version. */
+ readonly taskRunId: string
+ /** Canonical source SHA-256 digest. */
+ readonly sourceHash: string
+ /** Canonical source size in bytes. */
+ readonly sourceSize: number
+ /**
+ * Description of the requested canvas change.
+ * @nullable
+ */
+ readonly prompt: string | null
+ /** Creation time as Unix milliseconds. */
+ readonly createdAt: number
+}
+
+export interface CanvasHistoryApi {
+ /**
+ * Current source version for this canvas.
+ * @nullable
+ */
+ readonly currentSourceVersionId: string | null
+ /**
+ * Last-known-good build currently displayed by this canvas.
+ * @nullable
+ */
+ readonly activeBuildId: string | null
+ /** Source versions in creation order. */
+ readonly versions: readonly CanvasSourceVersionApi[]
+ /** Build attempts in creation order. */
+ readonly builds: readonly CanvasBuildApi[]
+}
+
+/**
+ * * `base64` - base64
+ */
+export type EncodingEnumApi = (typeof EncodingEnumApi)[keyof typeof EncodingEnumApi]
+
+export const EncodingEnumApi = {
+ Base64: 'base64',
+} as const
+
+/**
+ * * `application/wasm` - application/wasm
+ * * `application/octet-stream` - application/octet-stream
+ * * `font/otf` - font/otf
+ * * `font/ttf` - font/ttf
+ * * `font/woff` - font/woff
+ * * `font/woff2` - font/woff2
+ * * `image/avif` - image/avif
+ * * `image/gif` - image/gif
+ * * `image/jpeg` - image/jpeg
+ * * `image/png` - image/png
+ * * `image/svg+xml` - image/svg+xml
+ * * `image/webp` - image/webp
+ */
+export type ContentTypeEnumApi = (typeof ContentTypeEnumApi)[keyof typeof ContentTypeEnumApi]
+
+export const ContentTypeEnumApi = {
+ ApplicationWasm: 'application/wasm',
+ ApplicationOctetStream: 'application/octet-stream',
+ FontOtf: 'font/otf',
+ FontTtf: 'font/ttf',
+ FontWoff: 'font/woff',
+ FontWoff2: 'font/woff2',
+ ImageAvif: 'image/avif',
+ ImageGif: 'image/gif',
+ ImageJpeg: 'image/jpeg',
+ ImagePng: 'image/png',
+ ImageSvgXml: 'image/svg+xml',
+ ImageWebp: 'image/webp',
+} as const
+
+export interface CanvasAssetApi {
+ encoding: EncodingEnumApi
+ contentType: ContentTypeEnumApi
+ content: string
+}
+
+/**
+ * Complete map of normalized project-relative paths to UTF-8 source files.
+ */
+export type CanvasSourceProjectApiFiles = { [key: string]: string }
+
+/**
+ * Binary assets mapped by normalized project-relative path.
+ */
+export type CanvasSourceProjectApiAssets = { [key: string]: CanvasAssetApi }
+
+/**
+ * Browser package names mapped to exact admitted semantic versions.
+ */
+export type CanvasSourceProjectApiDependencies = { [key: string]: string }
+
+export interface CanvasSourceProjectApi {
+ /**
+ * Canvas source schema version.
+ * @minimum 1
+ * @maximum 1
+ */
+ schemaVersion: number
+ /** Complete map of normalized project-relative paths to UTF-8 source files. */
+ files: CanvasSourceProjectApiFiles
+ /** Binary assets mapped by normalized project-relative path. */
+ assets?: CanvasSourceProjectApiAssets
+ /** HTML entry file. Must be "index.html". */
+ entryHtml: string
+ /** Browser package names mapped to exact admitted semantic versions. */
+ dependencies: CanvasSourceProjectApiDependencies
+ /** Exact canvas runtime SDK version. */
+ canvasSdkVersion: string
+ /** Capabilities enforced by the build and runtime. */
+ capabilities: CanvasCapabilitiesApi
+}
+
+export interface CanvasSourceSnapshotApi {
+ /** Current immutable source version metadata. */
+ readonly version: CanvasSourceVersionApi
+ /** Complete current source project. */
+ readonly project: CanvasSourceProjectApi
+}
+
+export interface CanvasPublishRequestApi {
+ /** Complete canvas source project to publish. */
+ project: CanvasSourceProjectApi
+ /**
+ * Current source version that this edit is based on. Pass null for the first version.
+ * @nullable
+ */
+ expectedCurrentVersionId: string | null
+ /** Task that produced this source version. Sandbox tasks are attributed automatically. */
+ taskId?: string
+ /** Fresh task run that produced this source version. Sandbox tasks are attributed automatically. */
+ taskRunId?: string
+ /**
+ * Short description of the requested canvas change.
+ * @maxLength 10000
+ */
+ prompt?: string
+}
+
+export interface CanvasPublishResponseApi {
+ /** Published immutable source version metadata. */
+ readonly version: CanvasSourceVersionApi
+ /** Queued authoritative cloud build. */
+ readonly build: CanvasBuildApi
+}
+
+export interface CanvasApplicationConflictApi {
+ /** Always "version_conflict". */
+ readonly code: string
+ /** How to recover from the conflicting edit. */
+ readonly detail: string
+ /**
+ * Current source version that rejected the stale edit.
+ * @nullable
+ */
+ readonly currentVersionId: string | null
+}
+
+export type CanvasSourcePatchApiUpsertFiles = { [key: string]: string }
+
+export type CanvasSourcePatchApiUpsertAssets = { [key: string]: CanvasAssetApi }
+
+export type CanvasSourcePatchApiDependencies = { [key: string]: string }
+
+export interface CanvasSourcePatchApi {
+ upsertFiles?: CanvasSourcePatchApiUpsertFiles
+ /** @maxItems 128 */
+ deleteFiles?: string[]
+ upsertAssets?: CanvasSourcePatchApiUpsertAssets
+ /** @maxItems 128 */
+ deleteAssets?: string[]
+ dependencies?: CanvasSourcePatchApiDependencies
+ capabilities?: CanvasCapabilitiesApi
+}
+
+export interface PatchedCanvasPatchPublishRequestApi {
+ /** File, asset, dependency, and capability changes to apply. */
+ patch?: CanvasSourcePatchApi
+ /** Current source version that this patch is based on. */
+ expectedCurrentVersionId?: string
+ taskId?: string
+ taskRunId?: string
+ /** @maxLength 10000 */
+ prompt?: string
+}
+
+export interface CanvasValidationResponseApi {
+ /** Whether the candidate produced a valid artifact. */
+ readonly ok: boolean
+ /** Structured validation diagnostics. */
+ readonly diagnostics: readonly CanvasDiagnosticApi[]
+ /** Validated candidate manifest when successful. */
+ readonly manifest: CanvasArtifactManifestApi | null
+}
+
export interface ContextGenerationApi {
/**
* ID of the Task currently generating this folder's CONTEXT.md, or null if none.
diff --git a/frontend/src/generated/core/api.ts b/frontend/src/generated/core/api.ts
index 529edd4faf68..7c081f52810d 100644
--- a/frontend/src/generated/core/api.ts
+++ b/frontend/src/generated/core/api.ts
@@ -13,6 +13,13 @@ import type {
BulkUpdateTagsResponseApi,
CIMDVerificationTokenApi,
CIMDVerificationTokenWithValueApi,
+ CanvasBuildApi,
+ CanvasHistoryApi,
+ CanvasPublishRequestApi,
+ CanvasPublishResponseApi,
+ CanvasSourceProjectApi,
+ CanvasSourceSnapshotApi,
+ CanvasValidationResponseApi,
CimdVerificationTokensListParams,
ContextGenerationApi,
ContextGenerationSetApi,
@@ -56,6 +63,7 @@ import type {
PaginatedProjectSecretAPIKeyListApi,
PaginatedUserGitHubIntegrationListResponseListApi,
PaginatedUserListApi,
+ PatchedCanvasPatchPublishRequestApi,
PatchedCanvasPublishApi,
PatchedEnterprisePropertyDefinitionApi,
PatchedFileSystemApi,
@@ -1458,6 +1466,142 @@ export const desktopFileSystemCanvasPartialUpdate = async (
})
}
+export const getDesktopFileSystemCanvasBuildsRetrieveUrl = (projectId: string, id: string, buildId: string) => {
+ return `/api/projects/${projectId}/desktop_file_system/${id}/canvas/builds/${buildId}/`
+}
+
+/**
+ * The file tree for the desktop product surface. Reuses all FileSystemViewSet behaviour but is
+ * scoped to the "desktop" surface, so its tree is fully isolated from the default "web" tree.
+ *
+ * Adds per-folder, versioned markdown instructions describing the contents of a folder.
+ */
+export const desktopFileSystemCanvasBuildsRetrieve = async (
+ projectId: string,
+ id: string,
+ buildId: string,
+ options?: RequestInit
+): Promise => {
+ return apiMutator(getDesktopFileSystemCanvasBuildsRetrieveUrl(projectId, id, buildId), {
+ ...options,
+ method: 'GET',
+ })
+}
+
+export const getDesktopFileSystemCanvasHistoryRetrieveUrl = (projectId: string, id: string) => {
+ return `/api/projects/${projectId}/desktop_file_system/${id}/canvas/history/`
+}
+
+/**
+ * The file tree for the desktop product surface. Reuses all FileSystemViewSet behaviour but is
+ * scoped to the "desktop" surface, so its tree is fully isolated from the default "web" tree.
+ *
+ * Adds per-folder, versioned markdown instructions describing the contents of a folder.
+ */
+export const desktopFileSystemCanvasHistoryRetrieve = async (
+ projectId: string,
+ id: string,
+ options?: RequestInit
+): Promise => {
+ return apiMutator(getDesktopFileSystemCanvasHistoryRetrieveUrl(projectId, id), {
+ ...options,
+ method: 'GET',
+ })
+}
+
+export const getDesktopFileSystemCanvasSourceRetrieveUrl = (projectId: string, id: string) => {
+ return `/api/projects/${projectId}/desktop_file_system/${id}/canvas/source/`
+}
+
+/**
+ * The file tree for the desktop product surface. Reuses all FileSystemViewSet behaviour but is
+ * scoped to the "desktop" surface, so its tree is fully isolated from the default "web" tree.
+ *
+ * Adds per-folder, versioned markdown instructions describing the contents of a folder.
+ */
+export const desktopFileSystemCanvasSourceRetrieve = async (
+ projectId: string,
+ id: string,
+ options?: RequestInit
+): Promise => {
+ return apiMutator(getDesktopFileSystemCanvasSourceRetrieveUrl(projectId, id), {
+ ...options,
+ method: 'GET',
+ })
+}
+
+export const getDesktopFileSystemCanvasSourceCreateUrl = (projectId: string, id: string) => {
+ return `/api/projects/${projectId}/desktop_file_system/${id}/canvas/source/`
+}
+
+/**
+ * The file tree for the desktop product surface. Reuses all FileSystemViewSet behaviour but is
+ * scoped to the "desktop" surface, so its tree is fully isolated from the default "web" tree.
+ *
+ * Adds per-folder, versioned markdown instructions describing the contents of a folder.
+ */
+export const desktopFileSystemCanvasSourceCreate = async (
+ projectId: string,
+ id: string,
+ canvasPublishRequestApi: CanvasPublishRequestApi,
+ options?: RequestInit
+): Promise => {
+ return apiMutator(getDesktopFileSystemCanvasSourceCreateUrl(projectId, id), {
+ ...options,
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json', ...options?.headers },
+ body: JSON.stringify(canvasPublishRequestApi),
+ })
+}
+
+export const getDesktopFileSystemCanvasSourcePartialUpdateUrl = (projectId: string, id: string) => {
+ return `/api/projects/${projectId}/desktop_file_system/${id}/canvas/source/`
+}
+
+/**
+ * The file tree for the desktop product surface. Reuses all FileSystemViewSet behaviour but is
+ * scoped to the "desktop" surface, so its tree is fully isolated from the default "web" tree.
+ *
+ * Adds per-folder, versioned markdown instructions describing the contents of a folder.
+ */
+export const desktopFileSystemCanvasSourcePartialUpdate = async (
+ projectId: string,
+ id: string,
+ patchedCanvasPatchPublishRequestApi?: PatchedCanvasPatchPublishRequestApi,
+ options?: RequestInit
+): Promise => {
+ return apiMutator(getDesktopFileSystemCanvasSourcePartialUpdateUrl(projectId, id), {
+ ...options,
+ method: 'PATCH',
+ headers: { 'Content-Type': 'application/json', ...options?.headers },
+ body: JSON.stringify(patchedCanvasPatchPublishRequestApi),
+ })
+}
+
+export const getDesktopFileSystemCanvasValidateCreateUrl = (projectId: string, id: string) => {
+ return `/api/projects/${projectId}/desktop_file_system/${id}/canvas/validate/`
+}
+
+/**
+ * The file tree for the desktop product surface. Reuses all FileSystemViewSet behaviour but is
+ * scoped to the "desktop" surface, so its tree is fully isolated from the default "web" tree.
+ *
+ * Adds per-folder, versioned markdown instructions describing the contents of a folder.
+ */
+export const desktopFileSystemCanvasValidateCreate = async (
+ projectId: string,
+ id: string,
+ canvasSourceProjectApi: CanvasSourceProjectApi,
+ options?: RequestInit
+): Promise => {
+ return apiMutator(getDesktopFileSystemCanvasValidateCreateUrl(projectId, id), {
+ ...options,
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json', ...options?.headers },
+ body: JSON.stringify(canvasSourceProjectApi),
+ })
+}
+
export const getDesktopFileSystemContextGenerationRetrieveUrl = (projectId: string, id: string) => {
return `/api/projects/${projectId}/desktop_file_system/${id}/context_generation/`
}
diff --git a/frontend/src/generated/core/api.zod.ts b/frontend/src/generated/core/api.zod.ts
index 4b4561282eee..6f53bb3d29ef 100644
--- a/frontend/src/generated/core/api.zod.ts
+++ b/frontend/src/generated/core/api.zod.ts
@@ -9003,6 +9003,377 @@ export const DesktopFileSystemCanvasPartialUpdateBody = /* @__PURE__ */ zod
})
.describe("Payload for publishing a freeform canvas's React source via the agent.")
+/**
+ * The file tree for the desktop product surface. Reuses all FileSystemViewSet behaviour but is
+ * scoped to the "desktop" surface, so its tree is fully isolated from the default "web" tree.
+ *
+ * Adds per-folder, versioned markdown instructions describing the contents of a folder.
+ */
+export const desktopFileSystemCanvasSourceCreateBodyProjectOneSchemaVersionMax = 1
+
+export const desktopFileSystemCanvasSourceCreateBodyProjectOneCapabilitiesOnePosthogOneInsightsItemMax = 128
+
+export const desktopFileSystemCanvasSourceCreateBodyProjectOneCapabilitiesOnePosthogOneInsightsMax = 256
+
+export const desktopFileSystemCanvasSourceCreateBodyProjectOneCapabilitiesOnePosthogOneCaptureEventsItemMax = 200
+
+export const desktopFileSystemCanvasSourceCreateBodyProjectOneCapabilitiesOnePosthogOneCaptureEventsMax = 256
+
+export const desktopFileSystemCanvasSourceCreateBodyProjectOneCapabilitiesOneNetworkOneOriginsItemMax = 2048
+
+export const desktopFileSystemCanvasSourceCreateBodyProjectOneCapabilitiesOneNetworkOneOriginsMax = 64
+
+export const desktopFileSystemCanvasSourceCreateBodyPromptMax = 10000
+
+export const DesktopFileSystemCanvasSourceCreateBody = /* @__PURE__ */ zod.object({
+ project: zod
+ .object({
+ schemaVersion: zod
+ .number()
+ .min(1)
+ .max(desktopFileSystemCanvasSourceCreateBodyProjectOneSchemaVersionMax)
+ .describe('Canvas source schema version.'),
+ files: zod
+ .record(zod.string(), zod.string())
+ .describe('Complete map of normalized project-relative paths to UTF-8 source files.'),
+ assets: zod
+ .record(
+ zod.string(),
+ zod.object({
+ encoding: zod.enum(['base64']).describe('\* `base64` - base64'),
+ contentType: zod
+ .enum([
+ 'application/wasm',
+ 'application/octet-stream',
+ 'font/otf',
+ 'font/ttf',
+ 'font/woff',
+ 'font/woff2',
+ 'image/avif',
+ 'image/gif',
+ 'image/jpeg',
+ 'image/png',
+ 'image/svg+xml',
+ 'image/webp',
+ ])
+ .describe(
+ '\* `application\/wasm` - application\/wasm\n\* `application\/octet-stream` - application\/octet-stream\n\* `font\/otf` - font\/otf\n\* `font\/ttf` - font\/ttf\n\* `font\/woff` - font\/woff\n\* `font\/woff2` - font\/woff2\n\* `image\/avif` - image\/avif\n\* `image\/gif` - image\/gif\n\* `image\/jpeg` - image\/jpeg\n\* `image\/png` - image\/png\n\* `image\/svg+xml` - image\/svg+xml\n\* `image\/webp` - image\/webp'
+ ),
+ content: zod.string(),
+ })
+ )
+ .optional()
+ .describe('Binary assets mapped by normalized project-relative path.'),
+ entryHtml: zod.string().describe('HTML entry file. Must be \"index.html\".'),
+ dependencies: zod
+ .record(zod.string(), zod.string())
+ .describe('Browser package names mapped to exact admitted semantic versions.'),
+ canvasSdkVersion: zod.string().describe('Exact canvas runtime SDK version.'),
+ capabilities: zod
+ .object({
+ posthog: zod
+ .object({
+ insights: zod
+ .array(
+ zod
+ .string()
+ .min(1)
+ .max(
+ desktopFileSystemCanvasSourceCreateBodyProjectOneCapabilitiesOnePosthogOneInsightsItemMax
+ )
+ )
+ .max(
+ desktopFileSystemCanvasSourceCreateBodyProjectOneCapabilitiesOnePosthogOneInsightsMax
+ )
+ .describe('Insight short IDs that this canvas may load.'),
+ inlineQueries: zod
+ .boolean()
+ .describe('Whether this canvas may execute inline PostHog queries.'),
+ captureEvents: zod
+ .array(
+ zod
+ .string()
+ .min(1)
+ .max(
+ desktopFileSystemCanvasSourceCreateBodyProjectOneCapabilitiesOnePosthogOneCaptureEventsItemMax
+ )
+ )
+ .max(
+ desktopFileSystemCanvasSourceCreateBodyProjectOneCapabilitiesOnePosthogOneCaptureEventsMax
+ )
+ .describe('Event names that this canvas may capture.'),
+ })
+ .describe('PostHog data and capture capabilities.'),
+ network: zod
+ .object({
+ origins: zod
+ .array(
+ zod
+ .string()
+ .max(
+ desktopFileSystemCanvasSourceCreateBodyProjectOneCapabilitiesOneNetworkOneOriginsItemMax
+ )
+ )
+ .max(
+ desktopFileSystemCanvasSourceCreateBodyProjectOneCapabilitiesOneNetworkOneOriginsMax
+ )
+ .describe('HTTPS origins that the canvas may contact directly.'),
+ })
+ .describe('Direct network capabilities.'),
+ })
+ .describe('Capabilities enforced by the build and runtime.'),
+ })
+ .describe('Complete canvas source project to publish.'),
+ expectedCurrentVersionId: zod
+ .uuid()
+ .nullable()
+ .describe('Current source version that this edit is based on. Pass null for the first version.'),
+ taskId: zod
+ .uuid()
+ .optional()
+ .describe('Task that produced this source version. Sandbox tasks are attributed automatically.'),
+ taskRunId: zod
+ .uuid()
+ .optional()
+ .describe('Fresh task run that produced this source version. Sandbox tasks are attributed automatically.'),
+ prompt: zod
+ .string()
+ .max(desktopFileSystemCanvasSourceCreateBodyPromptMax)
+ .optional()
+ .describe('Short description of the requested canvas change.'),
+})
+
+/**
+ * The file tree for the desktop product surface. Reuses all FileSystemViewSet behaviour but is
+ * scoped to the "desktop" surface, so its tree is fully isolated from the default "web" tree.
+ *
+ * Adds per-folder, versioned markdown instructions describing the contents of a folder.
+ */
+export const desktopFileSystemCanvasSourcePartialUpdateBodyPatchOneDeleteFilesMax = 128
+
+export const desktopFileSystemCanvasSourcePartialUpdateBodyPatchOneDeleteAssetsMax = 128
+
+export const desktopFileSystemCanvasSourcePartialUpdateBodyPatchOneCapabilitiesPosthogOneInsightsItemMax = 128
+
+export const desktopFileSystemCanvasSourcePartialUpdateBodyPatchOneCapabilitiesPosthogOneInsightsMax = 256
+
+export const desktopFileSystemCanvasSourcePartialUpdateBodyPatchOneCapabilitiesPosthogOneCaptureEventsItemMax = 200
+
+export const desktopFileSystemCanvasSourcePartialUpdateBodyPatchOneCapabilitiesPosthogOneCaptureEventsMax = 256
+
+export const desktopFileSystemCanvasSourcePartialUpdateBodyPatchOneCapabilitiesNetworkOneOriginsItemMax = 2048
+
+export const desktopFileSystemCanvasSourcePartialUpdateBodyPatchOneCapabilitiesNetworkOneOriginsMax = 64
+
+export const desktopFileSystemCanvasSourcePartialUpdateBodyPromptMax = 10000
+
+export const DesktopFileSystemCanvasSourcePartialUpdateBody = /* @__PURE__ */ zod.object({
+ patch: zod
+ .object({
+ upsertFiles: zod.record(zod.string(), zod.string()).optional(),
+ deleteFiles: zod
+ .array(zod.string())
+ .max(desktopFileSystemCanvasSourcePartialUpdateBodyPatchOneDeleteFilesMax)
+ .optional(),
+ upsertAssets: zod
+ .record(
+ zod.string(),
+ zod.object({
+ encoding: zod.enum(['base64']).describe('\* `base64` - base64'),
+ contentType: zod
+ .enum([
+ 'application/wasm',
+ 'application/octet-stream',
+ 'font/otf',
+ 'font/ttf',
+ 'font/woff',
+ 'font/woff2',
+ 'image/avif',
+ 'image/gif',
+ 'image/jpeg',
+ 'image/png',
+ 'image/svg+xml',
+ 'image/webp',
+ ])
+ .describe(
+ '\* `application\/wasm` - application\/wasm\n\* `application\/octet-stream` - application\/octet-stream\n\* `font\/otf` - font\/otf\n\* `font\/ttf` - font\/ttf\n\* `font\/woff` - font\/woff\n\* `font\/woff2` - font\/woff2\n\* `image\/avif` - image\/avif\n\* `image\/gif` - image\/gif\n\* `image\/jpeg` - image\/jpeg\n\* `image\/png` - image\/png\n\* `image\/svg+xml` - image\/svg+xml\n\* `image\/webp` - image\/webp'
+ ),
+ content: zod.string(),
+ })
+ )
+ .optional(),
+ deleteAssets: zod
+ .array(zod.string())
+ .max(desktopFileSystemCanvasSourcePartialUpdateBodyPatchOneDeleteAssetsMax)
+ .optional(),
+ dependencies: zod.record(zod.string(), zod.string()).optional(),
+ capabilities: zod
+ .object({
+ posthog: zod
+ .object({
+ insights: zod
+ .array(
+ zod
+ .string()
+ .min(1)
+ .max(
+ desktopFileSystemCanvasSourcePartialUpdateBodyPatchOneCapabilitiesPosthogOneInsightsItemMax
+ )
+ )
+ .max(
+ desktopFileSystemCanvasSourcePartialUpdateBodyPatchOneCapabilitiesPosthogOneInsightsMax
+ )
+ .describe('Insight short IDs that this canvas may load.'),
+ inlineQueries: zod
+ .boolean()
+ .describe('Whether this canvas may execute inline PostHog queries.'),
+ captureEvents: zod
+ .array(
+ zod
+ .string()
+ .min(1)
+ .max(
+ desktopFileSystemCanvasSourcePartialUpdateBodyPatchOneCapabilitiesPosthogOneCaptureEventsItemMax
+ )
+ )
+ .max(
+ desktopFileSystemCanvasSourcePartialUpdateBodyPatchOneCapabilitiesPosthogOneCaptureEventsMax
+ )
+ .describe('Event names that this canvas may capture.'),
+ })
+ .describe('PostHog data and capture capabilities.'),
+ network: zod
+ .object({
+ origins: zod
+ .array(
+ zod
+ .string()
+ .max(
+ desktopFileSystemCanvasSourcePartialUpdateBodyPatchOneCapabilitiesNetworkOneOriginsItemMax
+ )
+ )
+ .max(
+ desktopFileSystemCanvasSourcePartialUpdateBodyPatchOneCapabilitiesNetworkOneOriginsMax
+ )
+ .describe('HTTPS origins that the canvas may contact directly.'),
+ })
+ .describe('Direct network capabilities.'),
+ })
+ .optional(),
+ })
+ .optional()
+ .describe('File, asset, dependency, and capability changes to apply.'),
+ expectedCurrentVersionId: zod.uuid().optional().describe('Current source version that this patch is based on.'),
+ taskId: zod.uuid().optional(),
+ taskRunId: zod.uuid().optional(),
+ prompt: zod.string().max(desktopFileSystemCanvasSourcePartialUpdateBodyPromptMax).optional(),
+})
+
+/**
+ * The file tree for the desktop product surface. Reuses all FileSystemViewSet behaviour but is
+ * scoped to the "desktop" surface, so its tree is fully isolated from the default "web" tree.
+ *
+ * Adds per-folder, versioned markdown instructions describing the contents of a folder.
+ */
+export const desktopFileSystemCanvasValidateCreateBodySchemaVersionMax = 1
+
+export const desktopFileSystemCanvasValidateCreateBodyCapabilitiesOnePosthogOneInsightsItemMax = 128
+
+export const desktopFileSystemCanvasValidateCreateBodyCapabilitiesOnePosthogOneInsightsMax = 256
+
+export const desktopFileSystemCanvasValidateCreateBodyCapabilitiesOnePosthogOneCaptureEventsItemMax = 200
+
+export const desktopFileSystemCanvasValidateCreateBodyCapabilitiesOnePosthogOneCaptureEventsMax = 256
+
+export const desktopFileSystemCanvasValidateCreateBodyCapabilitiesOneNetworkOneOriginsItemMax = 2048
+
+export const desktopFileSystemCanvasValidateCreateBodyCapabilitiesOneNetworkOneOriginsMax = 64
+
+export const DesktopFileSystemCanvasValidateCreateBody = /* @__PURE__ */ zod.object({
+ schemaVersion: zod
+ .number()
+ .min(1)
+ .max(desktopFileSystemCanvasValidateCreateBodySchemaVersionMax)
+ .describe('Canvas source schema version.'),
+ files: zod
+ .record(zod.string(), zod.string())
+ .describe('Complete map of normalized project-relative paths to UTF-8 source files.'),
+ assets: zod
+ .record(
+ zod.string(),
+ zod.object({
+ encoding: zod.enum(['base64']).describe('\* `base64` - base64'),
+ contentType: zod
+ .enum([
+ 'application/wasm',
+ 'application/octet-stream',
+ 'font/otf',
+ 'font/ttf',
+ 'font/woff',
+ 'font/woff2',
+ 'image/avif',
+ 'image/gif',
+ 'image/jpeg',
+ 'image/png',
+ 'image/svg+xml',
+ 'image/webp',
+ ])
+ .describe(
+ '\* `application\/wasm` - application\/wasm\n\* `application\/octet-stream` - application\/octet-stream\n\* `font\/otf` - font\/otf\n\* `font\/ttf` - font\/ttf\n\* `font\/woff` - font\/woff\n\* `font\/woff2` - font\/woff2\n\* `image\/avif` - image\/avif\n\* `image\/gif` - image\/gif\n\* `image\/jpeg` - image\/jpeg\n\* `image\/png` - image\/png\n\* `image\/svg+xml` - image\/svg+xml\n\* `image\/webp` - image\/webp'
+ ),
+ content: zod.string(),
+ })
+ )
+ .optional()
+ .describe('Binary assets mapped by normalized project-relative path.'),
+ entryHtml: zod.string().describe('HTML entry file. Must be \"index.html\".'),
+ dependencies: zod
+ .record(zod.string(), zod.string())
+ .describe('Browser package names mapped to exact admitted semantic versions.'),
+ canvasSdkVersion: zod.string().describe('Exact canvas runtime SDK version.'),
+ capabilities: zod
+ .object({
+ posthog: zod
+ .object({
+ insights: zod
+ .array(
+ zod
+ .string()
+ .min(1)
+ .max(desktopFileSystemCanvasValidateCreateBodyCapabilitiesOnePosthogOneInsightsItemMax)
+ )
+ .max(desktopFileSystemCanvasValidateCreateBodyCapabilitiesOnePosthogOneInsightsMax)
+ .describe('Insight short IDs that this canvas may load.'),
+ inlineQueries: zod.boolean().describe('Whether this canvas may execute inline PostHog queries.'),
+ captureEvents: zod
+ .array(
+ zod
+ .string()
+ .min(1)
+ .max(
+ desktopFileSystemCanvasValidateCreateBodyCapabilitiesOnePosthogOneCaptureEventsItemMax
+ )
+ )
+ .max(desktopFileSystemCanvasValidateCreateBodyCapabilitiesOnePosthogOneCaptureEventsMax)
+ .describe('Event names that this canvas may capture.'),
+ })
+ .describe('PostHog data and capture capabilities.'),
+ network: zod
+ .object({
+ origins: zod
+ .array(
+ zod
+ .string()
+ .max(desktopFileSystemCanvasValidateCreateBodyCapabilitiesOneNetworkOneOriginsItemMax)
+ )
+ .max(desktopFileSystemCanvasValidateCreateBodyCapabilitiesOneNetworkOneOriginsMax)
+ .describe('HTTPS origins that the canvas may contact directly.'),
+ })
+ .describe('Direct network capabilities.'),
+ })
+ .describe('Capabilities enforced by the build and runtime.'),
+})
+
/**
* Set or clear the Task associated with this folder's CONTEXT.md generation.
*/
diff --git a/posthog/api/canvas_artifacts.py b/posthog/api/canvas_artifacts.py
new file mode 100644
index 000000000000..d3469b9cba40
--- /dev/null
+++ b/posthog/api/canvas_artifacts.py
@@ -0,0 +1,112 @@
+from __future__ import annotations
+
+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 import CanvasBuild
+from posthog.storage import object_storage
+
+ARTIFACT_TOKEN_MAX_AGE_SECONDS = 300
+ARTIFACT_TOKEN_SALT = "posthog.canvas.artifact.v1"
+
+
+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:
+ if not settings.CANVAS_ARTIFACT_SIGNING_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(settings.CANVAS_ARTIFACT_SIGNING_KEYS[0]) < 32:
+ return None
+ if not (settings.DEBUG or settings.TEST) and _configured_artifact_host() is None:
+ return None
+ signer = signing.TimestampSigner(key=settings.CANVAS_ARTIFACT_SIGNING_KEYS[0], salt=ARTIFACT_TOKEN_SALT)
+ return signer.sign_object(
+ {"team_id": build.team_id, "canvas_id": str(build.canvas_id), "build_id": str(build.id)},
+ compress=True,
+ )
+
+
+def _read_token(token: str) -> dict[str, Any]:
+ for key in settings.CANVAS_ARTIFACT_SIGNING_KEYS:
+ if not (settings.DEBUG or settings.TEST) and len(key) < 32:
+ continue
+ 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,
+ build_status=CanvasBuild.Status.READY,
+ )
+ .first()
+ )
+ if build is None or not build.artifact_object_prefix or not isinstance(build.manifest, dict):
+ raise Http404
+ files = build.manifest.get("files")
+ file_manifest = (
+ next(
+ (entry for entry in files if isinstance(entry, dict) and entry.get("path") == artifact_path),
+ None,
+ )
+ if isinstance(files, list)
+ else None
+ )
+ if file_manifest is None:
+ raise Http404
+ content = object_storage.read_bytes(f"{build.artifact_object_prefix}/{artifact_path}")
+ if content is None:
+ raise Http404
+ response = HttpResponse(content, content_type=file_manifest.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"
+ return response
diff --git a/posthog/api/file_system/canvas_application.py b/posthog/api/file_system/canvas_application.py
new file mode 100644
index 000000000000..46aadc198e3c
--- /dev/null
+++ b/posthog/api/file_system/canvas_application.py
@@ -0,0 +1,578 @@
+from __future__ import annotations
+
+import re
+import json
+import base64
+import hashlib
+import binascii
+from typing import Any
+from urllib.parse import urlparse
+from uuid import UUID
+
+from django.conf import settings
+from django.db import IntegrityError, transaction
+from django.utils import timezone
+
+from rest_framework import serializers
+
+from posthog.api.canvas_artifacts import create_canvas_artifact_token
+from posthog.models.file_system.canvas import (
+ CanvasApplication,
+ CanvasBuild,
+ CanvasSourceVersion,
+ serialize_canvas_project,
+)
+from posthog.models.file_system.file_system import FileSystem
+from posthog.storage import object_storage
+from posthog.tasks.canvas_builds import build_canvas
+
+from products.tasks.backend.facade import api as tasks_facade
+
+CANVAS_MAX_FILES = 128
+CANVAS_MAX_FILE_BYTES = 1_000_000
+CANVAS_MAX_SOURCE_BYTES = 5_000_000
+CANVAS_MAX_DIAGNOSTICS = 500
+CANVAS_MAX_DEPENDENCIES = 64
+CANVAS_MAX_ASSET_BYTES = 10_000_000
+CANVAS_MAX_PROJECT_BYTES = 18_500_000
+CANVAS_SDK_VERSION = "1.0.0"
+ADMITTED_DEPENDENCIES = {
+ "@posthog/quill": "0.3.0-beta.24",
+ "d3": "7.9.0",
+ "date-fns": "4.1.0",
+ "echarts": "6.1.0",
+ "lodash-es": "4.18.1",
+ "react": "19.2.6",
+ "react-dom": "19.2.6",
+ "three": "0.183.2",
+ "zod": "4.4.3",
+}
+PACKAGE_NAME = re.compile(r"^(?:@[a-z0-9][a-z0-9._-]*/)?[a-z0-9][a-z0-9._-]*$")
+EXACT_VERSION = re.compile(
+ r"^\d+\.\d+\.\d+(?:-[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$"
+)
+
+
+class StrictSerializer(serializers.Serializer):
+ def to_internal_value(self, data: Any) -> dict[str, Any]:
+ if isinstance(data, dict):
+ unknown = set(data) - set(self.fields)
+ if unknown:
+ raise serializers.ValidationError(dict.fromkeys(sorted(unknown), "Unknown field."))
+ return super().to_internal_value(data)
+
+
+class CanvasPostHogCapabilitiesSerializer(StrictSerializer):
+ insights = serializers.ListField(
+ child=serializers.CharField(min_length=1, max_length=128),
+ max_length=256,
+ help_text="Insight short IDs that this canvas may load.",
+ )
+ inlineQueries = serializers.BooleanField(help_text="Whether this canvas may execute inline PostHog queries.")
+ captureEvents = serializers.ListField(
+ child=serializers.CharField(min_length=1, max_length=200),
+ max_length=256,
+ help_text="Event names that this canvas may capture.",
+ )
+
+ def validate_insights(self, value: list[str]) -> list[str]:
+ return list(dict.fromkeys(value))
+
+ def validate_captureEvents(self, value: list[str]) -> list[str]:
+ return list(dict.fromkeys(value))
+
+
+class CanvasNetworkCapabilitiesSerializer(StrictSerializer):
+ origins = serializers.ListField(
+ child=serializers.CharField(max_length=2048),
+ max_length=64,
+ help_text="HTTPS origins that the canvas may contact directly.",
+ )
+
+ def validate_origins(self, value: list[str]) -> list[str]:
+ for origin in value:
+ parsed = urlparse(origin)
+ if parsed.scheme != "https" or not parsed.netloc or parsed.username or parsed.password:
+ raise serializers.ValidationError("Each network capability must be an HTTPS origin.")
+ if origin != f"{parsed.scheme}://{parsed.netloc}":
+ raise serializers.ValidationError("Network capabilities must not include paths, queries, or fragments.")
+ if value:
+ raise serializers.ValidationError(
+ "External network access is unavailable until canvas capability approval is implemented."
+ )
+ return list(dict.fromkeys(value))
+
+
+class CanvasCapabilitiesSerializer(StrictSerializer):
+ posthog = CanvasPostHogCapabilitiesSerializer(help_text="PostHog data and capture capabilities.")
+ network = CanvasNetworkCapabilitiesSerializer(help_text="Direct network capabilities.")
+
+
+class CanvasAssetSerializer(StrictSerializer):
+ encoding = serializers.ChoiceField(choices=["base64"])
+ contentType = serializers.ChoiceField(
+ choices=[
+ "application/wasm",
+ "application/octet-stream",
+ "font/otf",
+ "font/ttf",
+ "font/woff",
+ "font/woff2",
+ "image/avif",
+ "image/gif",
+ "image/jpeg",
+ "image/png",
+ "image/svg+xml",
+ "image/webp",
+ ]
+ )
+ content = serializers.CharField(trim_whitespace=False)
+
+ def validate_content(self, value: str) -> str:
+ try:
+ decoded = base64.b64decode(value, validate=True)
+ except (binascii.Error, ValueError):
+ raise serializers.ValidationError("Asset content must be canonical base64.")
+ if base64.b64encode(decoded).decode() != value:
+ raise serializers.ValidationError("Asset content must be canonical base64.")
+ return value
+
+
+class CanvasSourceProjectSerializer(StrictSerializer):
+ schemaVersion = serializers.IntegerField(min_value=1, max_value=1, help_text="Canvas source schema version.")
+ files = serializers.DictField(
+ child=serializers.CharField(trim_whitespace=False, allow_blank=True),
+ help_text="Complete map of normalized project-relative paths to UTF-8 source files.",
+ )
+ assets = serializers.DictField(
+ child=CanvasAssetSerializer(),
+ required=False,
+ help_text="Binary assets mapped by normalized project-relative path.",
+ )
+ entryHtml = serializers.CharField(help_text='HTML entry file. Must be "index.html".')
+ dependencies = serializers.DictField(
+ child=serializers.CharField(),
+ help_text="Browser package names mapped to exact admitted semantic versions.",
+ )
+ canvasSdkVersion = serializers.CharField(help_text="Exact canvas runtime SDK version.")
+ capabilities = CanvasCapabilitiesSerializer(help_text="Capabilities enforced by the build and runtime.")
+
+ def validate_files(self, files: dict[str, str]) -> dict[str, str]:
+ if len(files) > CANVAS_MAX_FILES:
+ raise serializers.ValidationError(f"A canvas may contain at most {CANVAS_MAX_FILES} files.")
+ total = 0
+ for path, content in files.items():
+ if not isinstance(path, str) or not path or len(path) > 240:
+ raise serializers.ValidationError("Source paths must be non-empty strings of at most 240 characters.")
+ segments = path.split("/")
+ if (
+ path.startswith("/")
+ or "\\" in path
+ or any(ord(character) < 32 or ord(character) == 127 for character in path)
+ or any(segment in {"", ".", ".."} for segment in segments)
+ ):
+ raise serializers.ValidationError("Source paths must be normalized project-relative paths.")
+ size = len(content.encode())
+ if size > CANVAS_MAX_FILE_BYTES:
+ raise serializers.ValidationError(
+ f"Each canvas file may contain at most {CANVAS_MAX_FILE_BYTES} bytes."
+ )
+ total += size
+ if total > CANVAS_MAX_SOURCE_BYTES:
+ raise serializers.ValidationError(f"Canvas source may contain at most {CANVAS_MAX_SOURCE_BYTES} bytes.")
+ return files
+
+ def validate_dependencies(self, dependencies: dict[str, str]) -> dict[str, str]:
+ if len(dependencies) > CANVAS_MAX_DEPENDENCIES:
+ raise serializers.ValidationError(f"A canvas may declare at most {CANVAS_MAX_DEPENDENCIES} dependencies.")
+ for name, version in dependencies.items():
+ if len(name) > 214 or not PACKAGE_NAME.fullmatch(name):
+ raise serializers.ValidationError(f'Invalid package name: "{name}".')
+ if len(version) > 100 or not EXACT_VERSION.fullmatch(version):
+ raise serializers.ValidationError(f'Package "{name}" must use an exact semantic version.')
+ if ADMITTED_DEPENDENCIES.get(name) != version:
+ raise serializers.ValidationError(
+ f'Package "{name}" at {version} is not available in the canvas build environment.'
+ )
+ return dependencies
+
+ def validate_assets(self, assets: dict[str, dict[str, str]]) -> dict[str, dict[str, str]]:
+ if len(assets) > CANVAS_MAX_FILES:
+ raise serializers.ValidationError(f"A canvas may contain at most {CANVAS_MAX_FILES} assets.")
+ total = 0
+ for path, asset in assets.items():
+ segments = path.split("/")
+ if (
+ not path
+ or len(path) > 240
+ or path.startswith("/")
+ or "\\" in path
+ or any(ord(character) < 32 or ord(character) == 127 for character in path)
+ or any(segment in {"", ".", ".."} for segment in segments)
+ ):
+ raise serializers.ValidationError("Asset paths must be normalized project-relative paths.")
+ total += len(base64.b64decode(asset["content"], validate=True))
+ if total > CANVAS_MAX_ASSET_BYTES:
+ raise serializers.ValidationError(f"Canvas assets may contain at most {CANVAS_MAX_ASSET_BYTES} bytes.")
+ return assets
+
+ def validate_canvasSdkVersion(self, value: str) -> str:
+ if not EXACT_VERSION.fullmatch(value):
+ raise serializers.ValidationError("The canvas SDK must use an exact semantic version.")
+ if value != CANVAS_SDK_VERSION:
+ raise serializers.ValidationError(f"Canvas SDK version {value} is not supported.")
+ return value
+
+ def validate(self, attrs: dict[str, Any]) -> dict[str, Any]:
+ attrs = super().validate(attrs)
+ if attrs["entryHtml"] != "index.html":
+ raise serializers.ValidationError({"entryHtml": 'The canvas entry file must be "index.html".'})
+ if attrs["entryHtml"] not in attrs["files"]:
+ raise serializers.ValidationError({"entryHtml": "The canvas entry file is missing from files."})
+ collisions = set(attrs["files"]) & set(attrs.get("assets", {}))
+ if collisions:
+ raise serializers.ValidationError({"assets": "Asset paths must not collide with source files."})
+ serialized_size = len(json.dumps(attrs, sort_keys=True, separators=(",", ":"), ensure_ascii=False).encode())
+ if serialized_size > CANVAS_MAX_PROJECT_BYTES:
+ raise serializers.ValidationError(
+ f"Serialized canvas projects may contain at most {CANVAS_MAX_PROJECT_BYTES} bytes."
+ )
+ return attrs
+
+
+class CanvasPublishRequestSerializer(StrictSerializer):
+ project = CanvasSourceProjectSerializer(help_text="Complete canvas source project to publish.")
+ expectedCurrentVersionId = serializers.UUIDField(
+ allow_null=True,
+ help_text="Current source version that this edit is based on. Pass null for the first version.",
+ )
+ taskId = serializers.UUIDField(
+ required=False,
+ help_text="Task that produced this source version. Sandbox tasks are attributed automatically.",
+ )
+ taskRunId = serializers.UUIDField(
+ required=False,
+ help_text="Fresh task run that produced this source version. Sandbox tasks are attributed automatically.",
+ )
+ prompt = serializers.CharField(
+ required=False,
+ allow_blank=True,
+ max_length=10_000,
+ trim_whitespace=False,
+ help_text="Short description of the requested canvas change.",
+ )
+
+
+class CanvasSourcePatchSerializer(StrictSerializer):
+ upsertFiles = serializers.DictField(
+ child=serializers.CharField(trim_whitespace=False, allow_blank=True), required=False, default=dict
+ )
+ deleteFiles = serializers.ListField(child=serializers.CharField(), required=False, default=list, max_length=128)
+ upsertAssets = serializers.DictField(child=CanvasAssetSerializer(), required=False, default=dict)
+ deleteAssets = serializers.ListField(child=serializers.CharField(), required=False, default=list, max_length=128)
+ dependencies = serializers.DictField(child=serializers.CharField(), required=False)
+ capabilities = CanvasCapabilitiesSerializer(required=False)
+
+ def validate(self, attrs: dict[str, Any]) -> dict[str, Any]:
+ attrs = super().validate(attrs)
+ if not any(
+ [
+ attrs.get("upsertFiles"),
+ attrs.get("deleteFiles"),
+ attrs.get("upsertAssets"),
+ attrs.get("deleteAssets"),
+ "dependencies" in attrs,
+ "capabilities" in attrs,
+ ]
+ ):
+ raise serializers.ValidationError("A canvas source patch must contain at least one change.")
+ return attrs
+
+
+class CanvasPatchPublishRequestSerializer(StrictSerializer):
+ patch = CanvasSourcePatchSerializer(help_text="File, asset, dependency, and capability changes to apply.")
+ expectedCurrentVersionId = serializers.UUIDField(
+ allow_null=False, help_text="Current source version that this patch is based on."
+ )
+ taskId = serializers.UUIDField(required=False)
+ taskRunId = serializers.UUIDField(required=False)
+ prompt = serializers.CharField(required=False, allow_blank=True, max_length=10_000, trim_whitespace=False)
+
+
+class CanvasApplicationConflictSerializer(StrictSerializer):
+ code = serializers.CharField(read_only=True, help_text='Always "version_conflict".')
+ detail = serializers.CharField(read_only=True, help_text="How to recover from the conflicting edit.")
+ currentVersionId = serializers.UUIDField(
+ allow_null=True, read_only=True, help_text="Current source version that rejected the stale edit."
+ )
+
+
+class CanvasDiagnosticSerializer(StrictSerializer):
+ severity = serializers.ChoiceField(choices=["error", "warning", "info"], help_text="Diagnostic severity.")
+ code = serializers.CharField(max_length=100, help_text="Stable diagnostic code.")
+ message = serializers.CharField(max_length=10_000, help_text="Build diagnostic message.")
+ file = serializers.CharField(required=False, help_text="Project-relative source file.")
+ line = serializers.IntegerField(required=False, min_value=1, help_text="One-based source line.")
+ column = serializers.IntegerField(required=False, min_value=0, help_text="Zero-based source column.")
+
+
+class CanvasArtifactFileSerializer(StrictSerializer):
+ path = serializers.CharField(read_only=True, help_text="Normalized artifact path relative to this build.")
+ contentType = serializers.CharField(read_only=True, help_text="HTTP content type for this artifact file.")
+ bytes = serializers.IntegerField(read_only=True, min_value=0, help_text="UTF-8 artifact size in bytes.")
+ sha256 = serializers.RegexField(
+ regex=r"^[a-f0-9]{64}$", read_only=True, help_text="Lowercase SHA-256 digest of the artifact content."
+ )
+
+
+class CanvasArtifactManifestSerializer(StrictSerializer):
+ schemaVersion = serializers.IntegerField(read_only=True, help_text="Artifact manifest schema version.")
+ entryHtml = serializers.CharField(read_only=True, help_text="HTML entry file for this artifact.")
+ files = CanvasArtifactFileSerializer(many=True, read_only=True, help_text="Immutable emitted artifact files.")
+ canvasSdkVersion = serializers.CharField(read_only=True, help_text="Canvas runtime SDK version used by this build.")
+ dependencies = serializers.DictField(
+ child=serializers.CharField(), read_only=True, help_text="Exact package versions included in this build."
+ )
+ capabilities = CanvasCapabilitiesSerializer(read_only=True, help_text="Capabilities enforced for this artifact.")
+
+
+class CanvasSourceVersionSerializer(serializers.ModelSerializer):
+ id = serializers.UUIDField(read_only=True, help_text="Immutable source version ID.")
+ parentVersionId = serializers.UUIDField(
+ source="parent_version_id",
+ allow_null=True,
+ read_only=True,
+ help_text="Source version edited to create this version.",
+ )
+ taskId = serializers.UUIDField(source="task_id", read_only=True, help_text="Task that produced this version.")
+ taskRunId = serializers.UUIDField(
+ source="task_run_id", read_only=True, help_text="Fresh task run that produced this version."
+ )
+ sourceHash = serializers.CharField(
+ source="source_hash", read_only=True, help_text="Canonical source SHA-256 digest."
+ )
+ sourceSize = serializers.IntegerField(
+ source="source_size", read_only=True, help_text="Canonical source size in bytes."
+ )
+ prompt = serializers.CharField(
+ allow_null=True, read_only=True, help_text="Description of the requested canvas change."
+ )
+ createdAt = serializers.SerializerMethodField(help_text="Creation time as Unix milliseconds.")
+
+ class Meta:
+ model = CanvasSourceVersion
+ fields = ["id", "parentVersionId", "taskId", "taskRunId", "sourceHash", "sourceSize", "prompt", "createdAt"]
+
+ def get_createdAt(self, instance: CanvasSourceVersion) -> int:
+ return int(instance.created_at.timestamp() * 1000)
+
+
+class CanvasBuildSerializer(serializers.ModelSerializer):
+ id = serializers.UUIDField(read_only=True, help_text="Immutable cloud build ID.")
+ sourceVersionId = serializers.UUIDField(
+ source="source_version_id", read_only=True, help_text="Source version compiled by this build."
+ )
+ status = serializers.ChoiceField(
+ source="build_status",
+ choices=CanvasBuild.Status.choices,
+ read_only=True,
+ help_text="Current build lifecycle status.",
+ )
+ artifactUrl = serializers.SerializerMethodField(help_text="Short-lived URL for the immutable artifact entry HTML.")
+ integrity = serializers.CharField(
+ allow_null=True, read_only=True, help_text="SHA-256 integrity value for entry HTML."
+ )
+ diagnostics = CanvasDiagnosticSerializer(many=True, read_only=True, help_text="Bounded build diagnostics.")
+ manifest = CanvasArtifactManifestSerializer(
+ allow_null=True, read_only=True, help_text="Immutable artifact and capability manifest when ready."
+ )
+ createdAt = serializers.SerializerMethodField(help_text="Build creation time as Unix milliseconds.")
+ completedAt = serializers.SerializerMethodField(
+ help_text="Build completion time as Unix milliseconds, if complete."
+ )
+
+ class Meta:
+ model = CanvasBuild
+ fields = [
+ "id",
+ "sourceVersionId",
+ "status",
+ "artifactUrl",
+ "integrity",
+ "diagnostics",
+ "manifest",
+ "createdAt",
+ "completedAt",
+ ]
+
+ def get_artifactUrl(self, instance: CanvasBuild) -> str | None:
+ if instance.build_status != CanvasBuild.Status.READY or not instance.artifact_object_prefix:
+ return None
+ request = self.context.get("request")
+ token = create_canvas_artifact_token(instance)
+ if request is None or token is None:
+ return None
+ path = f"/canvas-artifacts/{token}/index.html"
+ return (
+ f"{settings.CANVAS_ARTIFACT_ORIGIN}{path}"
+ if settings.CANVAS_ARTIFACT_ORIGIN
+ else request.build_absolute_uri(path)
+ )
+
+ def get_createdAt(self, instance: CanvasBuild) -> int:
+ return int(instance.created_at.timestamp() * 1000)
+
+ def get_completedAt(self, instance: CanvasBuild) -> int | None:
+ return int(instance.completed_at.timestamp() * 1000) if instance.completed_at else None
+
+
+class CanvasSourceSnapshotSerializer(StrictSerializer):
+ version = CanvasSourceVersionSerializer(read_only=True, help_text="Current immutable source version metadata.")
+ project = CanvasSourceProjectSerializer(read_only=True, help_text="Complete current source project.")
+
+
+class CanvasPublishResponseSerializer(StrictSerializer):
+ version = CanvasSourceVersionSerializer(read_only=True, help_text="Published immutable source version metadata.")
+ build = CanvasBuildSerializer(read_only=True, help_text="Queued authoritative cloud build.")
+
+
+class CanvasValidationResponseSerializer(StrictSerializer):
+ ok = serializers.BooleanField(read_only=True, help_text="Whether the candidate produced a valid artifact.")
+ diagnostics = CanvasDiagnosticSerializer(many=True, read_only=True, help_text="Structured validation diagnostics.")
+ manifest = CanvasArtifactManifestSerializer(
+ allow_null=True, read_only=True, help_text="Validated candidate manifest when successful."
+ )
+
+
+class CanvasHistorySerializer(StrictSerializer):
+ currentSourceVersionId = serializers.UUIDField(
+ allow_null=True, read_only=True, help_text="Current source version for this canvas."
+ )
+ activeBuildId = serializers.UUIDField(
+ allow_null=True, read_only=True, help_text="Last-known-good build currently displayed by this canvas."
+ )
+ versions = CanvasSourceVersionSerializer(many=True, read_only=True, help_text="Source versions in creation order.")
+ builds = CanvasBuildSerializer(many=True, read_only=True, help_text="Build attempts in creation order.")
+
+
+def validate_task_run_provenance(*, task_id: UUID, task_run_id: UUID, team_id: int, user_id: int | None) -> None:
+ run = tasks_facade.get_task_run(task_run_id, team_id=team_id)
+ if run is None or run.task_id != task_id or run.created_by_id != user_id:
+ raise serializers.ValidationError({"taskRunId": "The task run does not match this task, project, and user."})
+
+
+def publish_canvas_source(
+ *, canvas: FileSystem, payload: dict[str, Any], user_id: int | None
+) -> tuple[CanvasSourceVersion, CanvasBuild]:
+ validate_task_run_provenance(
+ task_id=payload["taskId"],
+ task_run_id=payload["taskRunId"],
+ team_id=canvas.team_id,
+ user_id=user_id,
+ )
+ canonical, archive = serialize_canvas_project(payload["project"])
+ source_hash = hashlib.sha256(canonical).hexdigest()
+ object_key = f"canvas/source/{canvas.team_id}/sha256/{source_hash}.json.gz"
+ object_storage.write(object_key, archive, extras={"ContentType": "application/gzip"})
+
+ with transaction.atomic():
+ locked_canvas = FileSystem.objects.select_for_update().get(id=canvas.id, team_id=canvas.team_id)
+ application, _ = CanvasApplication.objects.for_team(canvas.team_id).get_or_create(
+ team_id=canvas.team_id,
+ canvas_id=canvas.id,
+ )
+ application = CanvasApplication.objects.for_team(canvas.team_id).select_for_update().get(id=application.id)
+ current_id = application.current_source_version_id
+ if current_id != payload["expectedCurrentVersionId"]:
+ raise CanvasVersionConflict(current_id)
+ try:
+ version = CanvasSourceVersion.objects.for_team(canvas.team_id).create(
+ team_id=canvas.team_id,
+ canvas_id=canvas.id,
+ parent_version_id=current_id,
+ task_id=payload["taskId"],
+ task_run_id=payload["taskRunId"],
+ source_hash=source_hash,
+ source_object_key=object_key,
+ source_size=len(canonical),
+ prompt=payload.get("prompt") or None,
+ created_by_id=user_id,
+ )
+ except IntegrityError as error:
+ raise serializers.ValidationError(
+ {"taskRunId": "This task run has already published a canvas version."}
+ ) from error
+ build = CanvasBuild.objects.for_team(canvas.team_id).create(
+ team_id=canvas.team_id,
+ canvas_id=canvas.id,
+ source_version=version,
+ )
+ application.current_source_version = version
+ application.save(update_fields=["current_source_version", "updated_at"])
+ meta = dict(locked_canvas.meta or {})
+ meta.update({"kind": "freeform", "currentSourceVersionId": str(version.id)})
+ locked_canvas.meta = meta
+ locked_canvas.save(update_fields=["meta"])
+
+ transaction.on_commit(lambda: build_canvas.delay(str(build.id), canvas.team_id))
+ return version, build
+
+
+def apply_canvas_source_patch(*, canvas: FileSystem, payload: dict[str, Any]) -> dict[str, Any]:
+ try:
+ base = CanvasSourceVersion.objects.for_team(canvas.team_id).get(
+ id=payload["expectedCurrentVersionId"], canvas_id=canvas.id
+ )
+ except CanvasSourceVersion.DoesNotExist:
+ application = CanvasApplication.objects.for_team(canvas.team_id).filter(canvas_id=canvas.id).first()
+ raise CanvasVersionConflict(application.current_source_version_id if application else None)
+ project = base.read_project()
+ patch = payload["patch"]
+ files = dict(project["files"])
+ assets = dict(project.get("assets", {}))
+ for path in patch.get("deleteFiles", []):
+ files.pop(path, None)
+ files.update(patch.get("upsertFiles", {}))
+ for path in patch.get("deleteAssets", []):
+ assets.pop(path, None)
+ assets.update(patch.get("upsertAssets", {}))
+ candidate = {
+ **project,
+ "files": files,
+ **({"assets": assets} if assets else {}),
+ "dependencies": patch.get("dependencies", project["dependencies"]),
+ "capabilities": patch.get("capabilities", project["capabilities"]),
+ }
+ serializer = CanvasSourceProjectSerializer(data=candidate)
+ serializer.is_valid(raise_exception=True)
+ return serializer.validated_data
+
+
+class CanvasVersionConflict(Exception):
+ def __init__(self, current_version_id: UUID | None):
+ self.current_version_id = current_version_id
+ super().__init__("Canvas source version conflict")
+
+
+def current_canvas_source(canvas: FileSystem) -> tuple[CanvasSourceVersion, dict[str, Any]] | None:
+ application = CanvasApplication.objects.for_team(canvas.team_id).filter(canvas_id=canvas.id).first()
+ if application is None or application.current_source_version_id is None:
+ return None
+ version = CanvasSourceVersion.objects.for_team(canvas.team_id).get(id=application.current_source_version_id)
+ return version, version.read_project()
+
+
+def canvas_history(canvas: FileSystem) -> tuple[CanvasApplication | None, list[CanvasSourceVersion], list[CanvasBuild]]:
+ application = CanvasApplication.objects.for_team(canvas.team_id).filter(canvas_id=canvas.id).first()
+ versions = list(
+ CanvasSourceVersion.objects.for_team(canvas.team_id).filter(canvas_id=canvas.id).order_by("created_at")
+ )
+ builds = list(CanvasBuild.objects.for_team(canvas.team_id).filter(canvas_id=canvas.id).order_by("created_at"))
+ return application, versions, builds
+
+
+def mark_build_failed(build: CanvasBuild, diagnostics: list[dict[str, Any]]) -> None:
+ build.build_status = CanvasBuild.Status.FAILED
+ build.diagnostics = diagnostics[:CANVAS_MAX_DIAGNOSTICS]
+ build.completed_at = timezone.now()
+ build.save(update_fields=["build_status", "diagnostics", "completed_at"])
diff --git a/posthog/api/file_system/file_system.py b/posthog/api/file_system/file_system.py
index 6a2c0826547a..ca96e4df233b 100644
--- a/posthog/api/file_system/file_system.py
+++ b/posthog/api/file_system/file_system.py
@@ -10,12 +10,29 @@
from django.db.models import Case, F, IntegerField, Q, QuerySet, Value, When
from django.db.models.functions import Concat, Lower
-from drf_spectacular.utils import OpenApiResponse, extend_schema
+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.file_system.access_levels import FileSystemAccessLevelSerializerMixin
+from posthog.api.file_system.canvas_application import (
+ CanvasApplicationConflictSerializer,
+ CanvasBuildSerializer,
+ CanvasHistorySerializer,
+ CanvasPatchPublishRequestSerializer,
+ CanvasPublishRequestSerializer,
+ CanvasPublishResponseSerializer,
+ CanvasSourceProjectSerializer,
+ CanvasSourceSnapshotSerializer,
+ CanvasSourceVersionSerializer,
+ CanvasValidationResponseSerializer,
+ CanvasVersionConflict,
+ apply_canvas_source_patch,
+ canvas_history,
+ current_canvas_source,
+ publish_canvas_source,
+)
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 import CanvasApplication, CanvasBuild
from posthog.models.file_system.file_system import (
DEFAULT_SURFACE,
FileSystem,
@@ -63,6 +81,8 @@
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.rate_limit import CanvasValidationBurstThrottle, CanvasValidationDailyThrottle
+from posthog.tasks.canvas_builds import validate_canvas_project
from posthog.temporal.oauth import SANDBOX_OAUTH_APP_CLIENT_IDS
from posthog.utils import str_to_bool
@@ -77,6 +97,7 @@
class FileSystemSerializer(FileSystemAccessLevelSerializerMixin, serializers.ModelSerializer):
last_viewed_at = serializers.DateTimeField(read_only=True, allow_null=True)
+ _canvas_application_cache: dict[str, CanvasApplication] | None = None
class Meta:
model = FileSystem
@@ -107,6 +128,43 @@ def update(self, instance: FileSystem, validated_data: dict[str, Any]) -> FileSy
instance.depth = len(split_path(validated_data["path"]))
return super().update(instance, validated_data)
+ def to_representation(self, instance: FileSystem) -> dict[str, Any]:
+ representation = super().to_representation(instance)
+ meta = representation.get("meta")
+ if not isinstance(meta, dict) or meta.get("kind") != "freeform":
+ return representation
+ application = self._canvas_applications(instance).get(str(instance.id))
+ active_build = application.active_build if application else None
+ if active_build is None or active_build.build_status != CanvasBuild.Status.READY:
+ return representation
+ serialized_build = CanvasBuildSerializer(active_build, context=self.context).data
+ artifact_url = serialized_build.get("artifactUrl")
+ if artifact_url:
+ representation["meta"] = {
+ **meta,
+ "activeBuildId": str(active_build.id),
+ "activeBuildArtifactUrl": artifact_url,
+ "activeBuildCapabilities": (active_build.manifest or {}).get("capabilities"),
+ }
+ return representation
+
+ def _canvas_applications(self, instance: FileSystem) -> dict[str, CanvasApplication]:
+ if self._canvas_application_cache is not None:
+ return self._canvas_application_cache
+ parent_instances = getattr(getattr(self, "parent", None), "instance", None)
+ if isinstance(parent_instances, QuerySet):
+ canvas_ids = list(parent_instances.values_list("id", flat=True))
+ else:
+ candidates = parent_instances if isinstance(parent_instances, (list, tuple)) else [instance]
+ canvas_ids = [candidate.id for candidate in candidates if isinstance(candidate, FileSystem)]
+ applications = (
+ CanvasApplication.objects.for_team(instance.team_id)
+ .select_related("active_build")
+ .filter(canvas_id__in=canvas_ids)
+ )
+ self._canvas_application_cache = {str(application.canvas_id): application for application in applications}
+ return self._canvas_application_cache
+
def create(self, validated_data: dict[str, Any], *args: Any, **kwargs: Any) -> FileSystem:
request = self.context["request"]
team = self.context["get_team"]()
@@ -217,6 +275,7 @@ class FileSystemViewSet(TeamAndOrgViewSetMixin, viewsets.ModelViewSet):
"count",
"count_by_path",
"context_generation",
+ "validate_canvas_source_application",
]
scope_object_write_actions = [
"create",
@@ -1241,6 +1300,181 @@ def publish_canvas(self, request: Request, *args: Any, **kwargs: Any) -> Respons
return Response(self.get_serializer(dashboard).data)
+ @extend_schema(
+ responses={200: CanvasSourceSnapshotSerializer},
+ operation_id="desktop_file_system_canvas_source_retrieve",
+ )
+ @action(methods=["GET"], detail=True, url_path="canvas/source")
+ def canvas_source(self, request: Request, *args: Any, **kwargs: Any) -> Response:
+ dashboard = self._get_dashboard_or_400()
+ if isinstance(dashboard, Response):
+ return dashboard
+ current = current_canvas_source(dashboard)
+ if current is None:
+ return Response(status=status.HTTP_404_NOT_FOUND)
+ version, project = current
+ return Response(
+ {
+ "version": CanvasSourceVersionSerializer(version).data,
+ "project": project,
+ }
+ )
+
+ @extend_schema(
+ request=CanvasPublishRequestSerializer,
+ responses={
+ 201: CanvasPublishResponseSerializer,
+ 409: OpenApiResponse(
+ response=CanvasApplicationConflictSerializer, description="The canvas source changed."
+ ),
+ },
+ operation_id="desktop_file_system_canvas_source_create",
+ )
+ @canvas_source.mapping.post
+ def publish_canvas_source_application(self, request: Request, *args: Any, **kwargs: Any) -> Response:
+ dashboard = self._get_dashboard_or_400()
+ if isinstance(dashboard, Response):
+ return dashboard
+ payload = CanvasPublishRequestSerializer(data=request.data)
+ payload.is_valid(raise_exception=True)
+ publish_payload = dict(payload.validated_data)
+ return self._publish_canvas_application(request, dashboard, publish_payload)
+
+ @extend_schema(
+ request=CanvasPatchPublishRequestSerializer,
+ responses={
+ 201: CanvasPublishResponseSerializer,
+ 409: OpenApiResponse(
+ response=CanvasApplicationConflictSerializer, description="The canvas source changed."
+ ),
+ },
+ operation_id="desktop_file_system_canvas_source_partial_update",
+ )
+ @canvas_source.mapping.patch
+ def patch_canvas_source_application(self, request: Request, *args: Any, **kwargs: Any) -> Response:
+ dashboard = self._get_dashboard_or_400()
+ if isinstance(dashboard, Response):
+ return dashboard
+ payload = CanvasPatchPublishRequestSerializer(data=request.data)
+ payload.is_valid(raise_exception=True)
+ publish_payload = dict(payload.validated_data)
+ try:
+ publish_payload["project"] = apply_canvas_source_patch(canvas=dashboard, payload=publish_payload)
+ except CanvasVersionConflict as conflict:
+ return Response(
+ {
+ "code": "version_conflict",
+ "detail": "The canvas changed since it was read. Load the current source and apply the edit again.",
+ "currentVersionId": str(conflict.current_version_id) if conflict.current_version_id else None,
+ },
+ status=status.HTTP_409_CONFLICT,
+ )
+ publish_payload.pop("patch")
+ return self._publish_canvas_application(request, dashboard, publish_payload)
+
+ def _publish_canvas_application(
+ self, request: Request, dashboard: FileSystem, publish_payload: dict[str, Any]
+ ) -> Response:
+ if self._is_sandbox_authenticated(request):
+ try:
+ header_task_id = UUID((request.headers.get("X-PostHog-Task-Id") or "").strip())
+ header_run_id = UUID((request.headers.get("X-PostHog-Task-Run-Id") or "").strip())
+ except ValueError:
+ raise serializers.ValidationError("Sandbox canvas publishes require task and run attribution headers.")
+ if publish_payload.get("taskId") not in {None, header_task_id}:
+ raise serializers.ValidationError({"taskId": "Does not match the current sandbox task."})
+ if publish_payload.get("taskRunId") not in {None, header_run_id}:
+ raise serializers.ValidationError({"taskRunId": "Does not match the current sandbox run."})
+ publish_payload.update({"taskId": header_task_id, "taskRunId": header_run_id})
+ elif "taskId" not in publish_payload or "taskRunId" not in publish_payload:
+ raise serializers.ValidationError("Canvas publishes require task and run attribution.")
+ user = request.user if isinstance(request.user, User) else None
+ try:
+ version, build = publish_canvas_source(
+ canvas=dashboard,
+ payload=publish_payload,
+ user_id=user.id if user else None,
+ )
+ except CanvasVersionConflict as conflict:
+ return Response(
+ {
+ "code": "version_conflict",
+ "detail": "The canvas changed since it was read. Load the current source and apply the edit again.",
+ "currentVersionId": str(conflict.current_version_id) if conflict.current_version_id else None,
+ },
+ status=status.HTTP_409_CONFLICT,
+ )
+ return Response(
+ {
+ "version": CanvasSourceVersionSerializer(version).data,
+ "build": CanvasBuildSerializer(build, context={"request": request}).data,
+ },
+ status=status.HTTP_201_CREATED,
+ )
+
+ @extend_schema(
+ request=CanvasSourceProjectSerializer,
+ responses={200: CanvasValidationResponseSerializer},
+ operation_id="desktop_file_system_canvas_validate_create",
+ )
+ @action(
+ methods=["POST"],
+ detail=True,
+ url_path="canvas/validate",
+ throttle_classes=[CanvasValidationBurstThrottle, CanvasValidationDailyThrottle],
+ )
+ def validate_canvas_source_application(self, request: Request, *args: Any, **kwargs: Any) -> Response:
+ dashboard = self._get_dashboard_or_400()
+ if isinstance(dashboard, Response):
+ return dashboard
+ payload = CanvasSourceProjectSerializer(data=request.data)
+ payload.is_valid(raise_exception=True)
+ result = validate_canvas_project(payload.validated_data)
+ return Response(CanvasValidationResponseSerializer(result).data)
+
+ @extend_schema(responses={200: CanvasHistorySerializer})
+ @action(methods=["GET"], detail=True, url_path="canvas/history")
+ def canvas_history(self, request: Request, *args: Any, **kwargs: Any) -> Response:
+ dashboard = self._get_dashboard_or_400()
+ if isinstance(dashboard, Response):
+ return dashboard
+ application, versions, builds = canvas_history(dashboard)
+ return Response(
+ {
+ "currentSourceVersionId": (
+ str(application.current_source_version_id)
+ if application and application.current_source_version_id
+ else None
+ ),
+ "activeBuildId": str(application.active_build_id)
+ if application and application.active_build_id
+ else None,
+ "versions": CanvasSourceVersionSerializer(versions, many=True).data,
+ "builds": CanvasBuildSerializer(builds, many=True, context={"request": request}).data,
+ }
+ )
+
+ @extend_schema(
+ parameters=[
+ OpenApiParameter(
+ name="build_id",
+ type=str,
+ location=OpenApiParameter.PATH,
+ description="Immutable canvas build ID.",
+ )
+ ],
+ responses={200: CanvasBuildSerializer},
+ )
+ @action(methods=["GET"], detail=True, url_path=r"canvas/builds/(?P[^/.]+)")
+ def canvas_build(self, request: Request, build_id: str, *args: Any, **kwargs: Any) -> Response:
+ dashboard = self._get_dashboard_or_400()
+ if isinstance(dashboard, Response):
+ return dashboard
+ build = CanvasBuild.objects.for_team(dashboard.team_id).filter(id=build_id, canvas_id=dashboard.id).first()
+ if build is None:
+ return Response(status=status.HTTP_404_NOT_FOUND)
+ return Response(CanvasBuildSerializer(build, context={"request": request}).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_application.py b/posthog/api/file_system/test/test_canvas_application.py
new file mode 100644
index 000000000000..517cae870bf7
--- /dev/null
+++ b/posthog/api/file_system/test/test_canvas_application.py
@@ -0,0 +1,392 @@
+import gzip
+import json
+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.models.file_system.canvas import CanvasApplication, CanvasBuild, CanvasSourceVersion
+from posthog.models.file_system.file_system import FileSystem
+from posthog.tasks.canvas_builds import _complete_build, _fail_build, collect_canvas_objects
+
+from products.tasks.backend.facade import api as tasks_api
+
+
+def source_project(label: str = "hello") -> dict[str, Any]:
+ return {
+ "schemaVersion": 1,
+ "files": {
+ "index.html": '',
+ "src/main.ts": f'document.querySelector("#root")!.textContent = "{label}"',
+ },
+ "entryHtml": "index.html",
+ "dependencies": {},
+ "canvasSdkVersion": "1.0.0",
+ "capabilities": {
+ "posthog": {"insights": [], "inlineQueries": False, "captureEvents": []},
+ "network": {"origins": []},
+ },
+ }
+
+
+class TestCanvasApplicationAPI(APIBaseTest):
+ def setUp(self) -> None:
+ super().setUp()
+ self.user.is_staff = True
+ self.user.save()
+ response = self.client.post(
+ f"/api/projects/{self.team.id}/desktop_file_system/",
+ {"path": "Channel/Canvas", "type": "dashboard", "meta": {"kind": "freeform"}},
+ )
+ self.assertEqual(response.status_code, status.HTTP_201_CREATED)
+ self.canvas_id = UUID(cast(str, response.json()["id"]))
+ self.task_id = tasks_api.create_task_without_run(
+ team=self.team,
+ user_id=self.user.id,
+ origin_product=tasks_api.TaskOriginProduct.USER_CREATED,
+ title="Canvas task",
+ )
+ self.task_run_id = tasks_api.create_run(self.task_id).id
+ self.objects: dict[str, bytes] = {}
+
+ def source_url(self) -> str:
+ return f"/api/projects/{self.team.id}/desktop_file_system/{self.canvas_id}/canvas/source/"
+
+ def history_url(self) -> str:
+ return f"/api/projects/{self.team.id}/desktop_file_system/{self.canvas_id}/canvas/history/"
+
+ def publish_payload(self, label: str = "hello", expected: str | None = None) -> dict[str, Any]:
+ return {
+ "project": source_project(label),
+ "expectedCurrentVersionId": expected,
+ "taskId": str(self.task_id),
+ "taskRunId": str(self.task_run_id),
+ "prompt": f"Build {label}",
+ }
+
+ def object_write(self, key: str, content: bytes, **kwargs: Any) -> None:
+ self.objects[key] = content
+
+ def object_read(self, key: str, **kwargs: Any) -> bytes | None:
+ return self.objects.get(key)
+
+ @patch("posthog.api.file_system.canvas_application.build_canvas.delay")
+ @patch("posthog.models.file_system.canvas.object_storage.read_bytes")
+ @patch("posthog.models.file_system.canvas.object_storage.write")
+ def test_publish_persists_private_source_and_queues_build(self, write: Any, read: Any, delay: Any) -> None:
+ write.side_effect = self.object_write
+ read.side_effect = self.object_read
+
+ with self.captureOnCommitCallbacks(execute=True):
+ response = self.client.post(self.source_url(), self.publish_payload())
+
+ self.assertEqual(response.status_code, status.HTTP_201_CREATED, response.json())
+ body = response.json()
+ self.assertEqual(body["version"]["taskId"], str(self.task_id))
+ self.assertEqual(body["version"]["taskRunId"], str(self.task_run_id))
+ self.assertEqual(body["version"]["parentVersionId"], None)
+ self.assertEqual(body["build"]["status"], "queued")
+ self.assertNotIn("project", body["version"])
+
+ version = CanvasSourceVersion.objects.for_team(self.team.id).get(id=body["version"]["id"])
+ self.assertTrue(version.source_object_key.startswith(f"canvas/source/{self.team.id}/sha256/"))
+ archive = gzip.decompress(self.objects[version.source_object_key])
+ self.assertEqual(json.loads(archive), source_project())
+ delay.assert_called_once_with(str(body["build"]["id"]), self.team.id)
+
+ current = self.client.get(self.source_url())
+ self.assertEqual(current.status_code, status.HTTP_200_OK, current.json())
+ self.assertEqual(current.json()["project"], source_project())
+ self.assertEqual(current.json()["version"], body["version"])
+
+ @patch("posthog.api.file_system.canvas_application.build_canvas.delay")
+ @patch("posthog.models.file_system.canvas.object_storage.read_bytes")
+ @patch("posthog.models.file_system.canvas.object_storage.write")
+ def test_stale_publish_cannot_advance_source_history(self, write: Any, read: Any, delay: Any) -> None:
+ write.side_effect = self.object_write
+ read.side_effect = self.object_read
+ with self.captureOnCommitCallbacks(execute=True):
+ first = self.client.post(self.source_url(), self.publish_payload())
+ self.assertEqual(first.status_code, status.HTTP_201_CREATED)
+
+ second_run = tasks_api.create_run(self.task_id)
+ payload = self.publish_payload("stale", expected="00000000-0000-0000-0000-000000000000")
+ payload["taskRunId"] = str(second_run.id)
+ response = self.client.post(self.source_url(), payload)
+
+ self.assertEqual(response.status_code, status.HTTP_409_CONFLICT, response.json())
+ self.assertEqual(response.json()["code"], "version_conflict")
+ self.assertEqual(response.json()["currentVersionId"], first.json()["version"]["id"])
+ self.assertEqual(CanvasSourceVersion.objects.for_team(self.team.id).count(), 1)
+
+ @patch("posthog.api.file_system.canvas_application.build_canvas.delay")
+ @patch("posthog.models.file_system.canvas.object_storage.read_bytes")
+ @patch("posthog.models.file_system.canvas.object_storage.write")
+ def test_patch_publishes_a_diff_without_replacing_untouched_files(self, write: Any, read: Any, delay: Any) -> None:
+ write.side_effect = self.object_write
+ read.side_effect = self.object_read
+ with self.captureOnCommitCallbacks(execute=True):
+ first = self.client.post(self.source_url(), self.publish_payload())
+ next_run = tasks_api.create_run(self.task_id)
+
+ with self.captureOnCommitCallbacks(execute=True):
+ patched = self.client.patch(
+ self.source_url(),
+ {
+ "patch": {
+ "upsertFiles": {"src/main.ts": 'document.body.textContent = "patched"'},
+ "deleteFiles": [],
+ "upsertAssets": {},
+ "deleteAssets": [],
+ },
+ "expectedCurrentVersionId": first.json()["version"]["id"],
+ "taskId": str(self.task_id),
+ "taskRunId": str(next_run.id),
+ },
+ format="json",
+ )
+
+ self.assertEqual(patched.status_code, status.HTTP_201_CREATED, patched.json())
+ current = self.client.get(self.source_url()).json()["project"]
+ self.assertEqual(current["files"]["src/main.ts"], 'document.body.textContent = "patched"')
+ self.assertIn("index.html", current["files"])
+
+ @patch("posthog.api.file_system.canvas_application.build_canvas.delay")
+ @patch("posthog.models.file_system.canvas.object_storage.read_bytes")
+ @patch("posthog.models.file_system.canvas.object_storage.write")
+ def test_history_is_normalized_and_scoped_to_canvas(self, write: Any, read: Any, delay: Any) -> None:
+ write.side_effect = self.object_write
+ read.side_effect = self.object_read
+ with self.captureOnCommitCallbacks(execute=True):
+ published = self.client.post(self.source_url(), self.publish_payload())
+ self.assertEqual(published.status_code, status.HTTP_201_CREATED)
+
+ history = self.client.get(self.history_url())
+ self.assertEqual(history.status_code, status.HTTP_200_OK, history.json())
+ self.assertEqual(history.json()["currentSourceVersionId"], published.json()["version"]["id"])
+ self.assertEqual(history.json()["activeBuildId"], None)
+ self.assertEqual(history.json()["versions"], [published.json()["version"]])
+ self.assertEqual(history.json()["builds"], [published.json()["build"]])
+
+ def test_publish_requires_matching_task_run_provenance(self) -> None:
+ other_task_id = tasks_api.create_task_without_run(
+ team=self.team,
+ user_id=self.user.id,
+ origin_product=tasks_api.TaskOriginProduct.USER_CREATED,
+ title="Other",
+ )
+ other_run = tasks_api.create_run(other_task_id)
+ payload = self.publish_payload()
+ payload["taskRunId"] = str(other_run.id)
+
+ response = self.client.post(self.source_url(), payload)
+
+ self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST, response.json())
+ self.assertEqual(CanvasSourceVersion.objects.for_team(self.team.id).count(), 0)
+
+ @patch("posthog.api.file_system.file_system.DesktopFileSystemViewSet._is_sandbox_authenticated", return_value=True)
+ @patch("posthog.api.file_system.canvas_application.build_canvas.delay")
+ @patch("posthog.models.file_system.canvas.object_storage.write")
+ def test_sandbox_publish_derives_task_and_run_attribution(
+ self, write: Any, delay: Any, sandbox_authenticated: Any
+ ) -> None:
+ payload = self.publish_payload()
+ payload.pop("taskId")
+ payload.pop("taskRunId")
+
+ with self.captureOnCommitCallbacks(execute=True):
+ response = self.client.post(
+ self.source_url(),
+ payload,
+ HTTP_X_POSTHOG_TASK_ID=str(self.task_id),
+ HTTP_X_POSTHOG_TASK_RUN_ID=str(self.task_run_id),
+ )
+
+ self.assertEqual(response.status_code, status.HTTP_201_CREATED, response.json())
+ self.assertEqual(response.json()["version"]["taskId"], str(self.task_id))
+ self.assertEqual(response.json()["version"]["taskRunId"], str(self.task_run_id))
+ sandbox_authenticated.assert_called()
+
+ def test_canvas_source_is_not_visible_through_another_team(self) -> None:
+ other_team = self.organization.teams.create(name="Other team")
+ other_canvas = FileSystem.objects.create(
+ team=other_team,
+ path="Other/Canvas",
+ depth=2,
+ type="dashboard",
+ surface="desktop",
+ meta={"kind": "freeform"},
+ )
+
+ response = self.client.get(f"/api/projects/{self.team.id}/desktop_file_system/{other_canvas.id}/canvas/source/")
+
+ self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND)
+
+ @patch("posthog.api.file_system.canvas_application.build_canvas.delay")
+ @patch("posthog.models.file_system.canvas.object_storage.read_bytes")
+ @patch("posthog.models.file_system.canvas.object_storage.write")
+ def test_a_task_run_cannot_publish_two_versions_to_one_canvas(self, write: Any, read: Any, delay: Any) -> None:
+ write.side_effect = self.object_write
+ read.side_effect = self.object_read
+ with self.captureOnCommitCallbacks(execute=True):
+ first = self.client.post(self.source_url(), self.publish_payload())
+ payload = self.publish_payload("again", expected=first.json()["version"]["id"])
+
+ response = self.client.post(self.source_url(), payload)
+
+ self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST, response.json())
+ self.assertEqual(CanvasSourceVersion.objects.for_team(self.team.id).count(), 1)
+
+ @patch("posthog.tasks.canvas_builds.tasks_facade.post_canvas_created_thread_update")
+ @patch("posthog.tasks.canvas_builds.tasks_facade.post_canvas_build_thread_update")
+ @patch("posthog.tasks.canvas_builds.object_storage.write")
+ @patch("posthog.api.file_system.canvas_application.build_canvas.delay")
+ @patch("posthog.models.file_system.canvas.object_storage.read_bytes")
+ @patch("posthog.models.file_system.canvas.object_storage.write")
+ def test_only_the_current_source_build_activates_and_failed_builds_keep_last_good(
+ self,
+ source_write: Any,
+ source_read: Any,
+ delay: Any,
+ artifact_write: Any,
+ build_update: Any,
+ created_update: Any,
+ ) -> None:
+ source_write.side_effect = self.object_write
+ source_read.side_effect = self.object_read
+ with self.captureOnCommitCallbacks(execute=True):
+ first = self.client.post(self.source_url(), self.publish_payload("first"))
+ second_run = tasks_api.create_run(self.task_id)
+ second_payload = self.publish_payload("second", expected=first.json()["version"]["id"])
+ second_payload["taskRunId"] = str(second_run.id)
+ with self.captureOnCommitCallbacks(execute=True):
+ second = self.client.post(self.source_url(), second_payload)
+
+ first_build = (
+ CanvasBuild.objects.for_team(self.team.id)
+ .select_related("source_version", "canvas")
+ .get(id=first.json()["build"]["id"])
+ )
+ second_build = (
+ CanvasBuild.objects.for_team(self.team.id)
+ .select_related("source_version", "canvas")
+ .get(id=second.json()["build"]["id"])
+ )
+ artifacts, manifest = ready_artifact()
+ _complete_build(first_build, artifacts, manifest)
+ application = CanvasApplication.objects.for_team(self.team.id).get(canvas_id=self.canvas_id)
+ self.assertIsNone(application.active_build_id)
+
+ _complete_build(second_build, artifacts, manifest)
+ application.refresh_from_db()
+ self.assertEqual(application.active_build_id, second_build.id)
+ detail = self.client.get(f"/api/projects/{self.team.id}/desktop_file_system/{self.canvas_id}/")
+ self.assertEqual(detail.status_code, status.HTTP_200_OK, detail.json())
+ self.assertIn("/canvas-artifacts/", detail.json()["meta"]["activeBuildArtifactUrl"])
+ self.assertEqual(detail.json()["meta"]["activeBuildCapabilities"], manifest["capabilities"])
+
+ third_run = tasks_api.create_run(self.task_id)
+ third_payload = self.publish_payload("third", expected=second.json()["version"]["id"])
+ third_payload["taskRunId"] = str(third_run.id)
+ with self.captureOnCommitCallbacks(execute=True):
+ third = self.client.post(self.source_url(), third_payload)
+ third_build = (
+ CanvasBuild.objects.for_team(self.team.id)
+ .select_related("source_version", "canvas")
+ .get(id=third.json()["build"]["id"])
+ )
+ _fail_build(third_build, [{"severity": "error", "code": "compile_error", "message": "Broken"}])
+ application.refresh_from_db()
+ self.assertEqual(application.active_build_id, second_build.id)
+
+ artifact_write.assert_called()
+ build_update.assert_called()
+ created_update.assert_not_called()
+
+ @patch("posthog.tasks.canvas_builds.object_storage.delete_objects")
+ @patch("posthog.tasks.canvas_builds.object_storage.list_objects")
+ @patch("posthog.api.file_system.canvas_application.build_canvas.delay")
+ @patch("posthog.models.file_system.canvas.object_storage.read_bytes")
+ @patch("posthog.models.file_system.canvas.object_storage.write")
+ def test_retention_expires_only_unprotected_build_artifacts(
+ self,
+ source_write: Any,
+ source_read: Any,
+ delay: Any,
+ list_objects: Any,
+ delete_objects: Any,
+ ) -> None:
+ source_write.side_effect = self.object_write
+ source_read.side_effect = self.object_read
+ with self.captureOnCommitCallbacks(execute=True):
+ published = self.client.post(self.source_url(), self.publish_payload())
+ version = CanvasSourceVersion.objects.for_team(self.team.id).get(id=published.json()["version"]["id"])
+ old = timezone.now() - timedelta(days=31)
+ active = CanvasBuild.objects.for_team(self.team.id).get(id=published.json()["build"]["id"])
+ active.artifact_object_prefix = f"canvas/artifacts/{self.team.id}/{active.id}"
+ active.completed_at = old
+ active.build_status = CanvasBuild.Status.READY
+ active.save(update_fields=["artifact_object_prefix", "completed_at", "build_status"])
+ expired = CanvasBuild.objects.for_team(self.team.id).create(
+ team_id=self.team.id,
+ canvas_id=self.canvas_id,
+ source_version=version,
+ build_status=CanvasBuild.Status.READY,
+ artifact_object_prefix=f"canvas/artifacts/{self.team.id}/expired",
+ completed_at=old,
+ )
+ pinned = CanvasBuild.objects.for_team(self.team.id).create(
+ team_id=self.team.id,
+ canvas_id=self.canvas_id,
+ source_version=version,
+ build_status=CanvasBuild.Status.READY,
+ artifact_object_prefix=f"canvas/artifacts/{self.team.id}/pinned",
+ completed_at=old,
+ pinned=True,
+ )
+ application = CanvasApplication.objects.for_team(self.team.id).get(canvas_id=self.canvas_id)
+ application.active_build = active
+ application.save(update_fields=["active_build"])
+ list_objects.side_effect = lambda prefix: (
+ [f"{expired.artifact_object_prefix}/index.html"] if prefix == expired.artifact_object_prefix else []
+ )
+
+ collect_canvas_objects()
+
+ active.refresh_from_db()
+ expired.refresh_from_db()
+ pinned.refresh_from_db()
+ self.assertIsNotNone(active.artifact_object_prefix)
+ self.assertIsNone(expired.artifact_object_prefix)
+ self.assertIsNotNone(pinned.artifact_object_prefix)
+ delete_objects.assert_called_once_with([f"canvas/artifacts/{self.team.id}/expired/index.html"])
+
+
+def ready_artifact() -> tuple[dict[str, bytes], dict[str, Any]]:
+ import hashlib
+
+ content = b"Ready
"
+ return {"index.html": content}, {
+ "schemaVersion": 1,
+ "entryHtml": "index.html",
+ "files": [
+ {
+ "path": "index.html",
+ "contentType": "text/html; charset=utf-8",
+ "bytes": len(content),
+ "sha256": hashlib.sha256(content).hexdigest(),
+ }
+ ],
+ "canvasSdkVersion": "1.0.0",
+ "dependencies": {},
+ "capabilities": {
+ "posthog": {"insights": [], "inlineQueries": False, "captureEvents": []},
+ "network": {"origins": []},
+ },
+ }
diff --git a/posthog/api/test/test_canvas_artifacts.py b/posthog/api/test/test_canvas_artifacts.py
new file mode 100644
index 000000000000..64ec1baeafb2
--- /dev/null
+++ b/posthog/api/test/test_canvas_artifacts.py
@@ -0,0 +1,99 @@
+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"}
+ old_token = signing.TimestampSigner(key="old-key", salt="posthog.canvas.artifact.v1").sign_object(
+ claims, compress=True
+ )
+
+ self.assertEqual(_read_token(old_token), claims)
+
+ @override_settings(CANVAS_ARTIFACT_SIGNING_KEYS=["key"])
+ @patch("django.core.signing.time.time", side_effect=[1000, 1301])
+ def test_tokens_expire_after_five_minutes(self, _time: MagicMock) -> None:
+ build = MagicMock(team_id=1, canvas_id="canvas", id="build")
+ token = create_canvas_artifact_token(build)
+
+ self.assertIsNotNone(token)
+ with self.assertRaises(Http404):
+ _read_token(token or "")
+
+ @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(
+ team_id=1,
+ canvas_id="00000000-0000-0000-0000-000000000001",
+ id="00000000-0000-0000-0000-000000000002",
+ artifact_object_prefix="canvas/artifacts/1/00000000-0000-0000-0000-000000000002",
+ manifest={
+ "files": [
+ {
+ "path": "index.html",
+ "contentType": "text/html; charset=utf-8",
+ "bytes": 4,
+ "sha256": "0" * 64,
+ }
+ ]
+ },
+ )
+ for_team.return_value.filter.return_value.first.return_value = build
+ token = create_canvas_artifact_token(build)
+ request = RequestFactory().get("/")
+
+ response = canvas_artifact(request, token or "", "index.html")
+
+ self.assertEqual(response.content, b"body")
+ self.assertTrue(response.xframe_options_exempt)
+ self.assertEqual(response["Content-Disposition"], "inline")
+ self.assertEqual(response["X-Content-Type-Options"], "nosniff")
+ read_bytes.assert_called_once_with("canvas/artifacts/1/00000000-0000-0000-0000-000000000002/index.html")
+
+ with self.assertRaises(Http404):
+ canvas_artifact(request, token or "", "source.ts")
+ read_bytes.assert_called_once()
+
+ @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")
+
+ @override_settings(
+ DEBUG=False,
+ TEST=False,
+ CANVAS_ARTIFACT_SIGNING_KEYS=["a-production-signing-key-at-least-32-bytes"],
+ CANVAS_ARTIFACT_ORIGIN="",
+ )
+ def test_production_token_generation_requires_a_dedicated_origin(self) -> None:
+ self.assertIsNone(create_canvas_artifact_token(MagicMock()))
+
+ @override_settings(
+ DEBUG=False,
+ TEST=False,
+ CANVAS_ARTIFACT_SIGNING_KEYS=["short"],
+ CANVAS_ARTIFACT_ORIGIN="https://usercontent.example",
+ )
+ def test_production_token_generation_requires_a_strong_signing_key(self) -> None:
+ self.assertIsNone(create_canvas_artifact_token(MagicMock()))
diff --git a/posthog/migrations/1265_canvas_application_builds.py b/posthog/migrations/1265_canvas_application_builds.py
new file mode 100644
index 000000000000..a9824bc2dacb
--- /dev/null
+++ b/posthog/migrations/1265_canvas_application_builds.py
@@ -0,0 +1,232 @@
+# Generated by Django 5.2.14 on 2026-07-26 07:41
+
+import django.utils.timezone
+import django.db.models.deletion
+from django.db import migrations, models
+
+import posthog.uuidt
+
+
+class Migration(migrations.Migration):
+ dependencies = [
+ ("posthog", "1264_delete_revenue_analytics_user_product_list"),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name="CanvasSourceVersion",
+ fields=[
+ (
+ "id",
+ models.UUIDField(
+ default=posthog.uuidt.uuid7,
+ editable=False,
+ primary_key=True,
+ serialize=False,
+ ),
+ ),
+ ("task_id", models.UUIDField()),
+ ("task_run_id", models.UUIDField()),
+ ("source_hash", models.CharField(max_length=64)),
+ ("source_object_key", models.CharField(max_length=500)),
+ ("source_size", models.PositiveIntegerField()),
+ ("prompt", models.TextField(blank=True, null=True)),
+ ("created_by_id", models.BigIntegerField(blank=True, null=True)),
+ (
+ "created_at",
+ models.DateTimeField(default=django.utils.timezone.now, editable=False),
+ ),
+ (
+ "canvas",
+ models.ForeignKey(
+ db_constraint=False,
+ on_delete=django.db.models.deletion.CASCADE,
+ related_name="canvas_source_versions",
+ to="posthog.filesystem",
+ ),
+ ),
+ (
+ "parent_version",
+ models.ForeignKey(
+ blank=True,
+ null=True,
+ on_delete=django.db.models.deletion.PROTECT,
+ related_name="child_versions",
+ 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,
+ ),
+ ),
+ (
+ "build_status",
+ models.CharField(
+ choices=[
+ ("queued", "Queued"),
+ ("building", "Building"),
+ ("ready", "Ready"),
+ ("failed", "Failed"),
+ ],
+ default="queued",
+ max_length=16,
+ ),
+ ),
+ (
+ "artifact_object_prefix",
+ models.CharField(blank=True, max_length=500, null=True),
+ ),
+ ("integrity", models.CharField(blank=True, max_length=100, null=True)),
+ ("diagnostics", models.JSONField(default=list)),
+ ("manifest", models.JSONField(blank=True, null=True)),
+ ("pinned", models.BooleanField(default=False)),
+ (
+ "created_at",
+ models.DateTimeField(default=django.utils.timezone.now, editable=False),
+ ),
+ ("started_at", models.DateTimeField(blank=True, null=True)),
+ ("completed_at", models.DateTimeField(blank=True, null=True)),
+ (
+ "canvas",
+ models.ForeignKey(
+ db_constraint=False,
+ 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.CreateModel(
+ name="CanvasApplication",
+ fields=[
+ (
+ "id",
+ models.UUIDField(
+ default=posthog.uuidt.uuid7,
+ editable=False,
+ primary_key=True,
+ serialize=False,
+ ),
+ ),
+ (
+ "created_at",
+ models.DateTimeField(default=django.utils.timezone.now, editable=False),
+ ),
+ ("updated_at", models.DateTimeField(auto_now=True)),
+ (
+ "canvas",
+ models.OneToOneField(
+ db_constraint=False,
+ on_delete=django.db.models.deletion.CASCADE,
+ related_name="canvas_application",
+ to="posthog.filesystem",
+ ),
+ ),
+ (
+ "team",
+ models.ForeignKey(
+ db_constraint=False,
+ on_delete=django.db.models.deletion.CASCADE,
+ to="posthog.team",
+ ),
+ ),
+ (
+ "active_build",
+ models.ForeignKey(
+ blank=True,
+ db_constraint=False,
+ null=True,
+ on_delete=django.db.models.deletion.SET_NULL,
+ related_name="active_for_applications",
+ to="posthog.canvasbuild",
+ ),
+ ),
+ (
+ "previous_build",
+ models.ForeignKey(
+ blank=True,
+ db_constraint=False,
+ null=True,
+ on_delete=django.db.models.deletion.SET_NULL,
+ related_name="previous_for_applications",
+ to="posthog.canvasbuild",
+ ),
+ ),
+ (
+ "current_source_version",
+ models.ForeignKey(
+ blank=True,
+ db_constraint=False,
+ null=True,
+ on_delete=django.db.models.deletion.SET_NULL,
+ related_name="current_for_applications",
+ to="posthog.canvassourceversion",
+ ),
+ ),
+ ],
+ options={
+ "abstract": False,
+ },
+ ),
+ migrations.AddIndex(
+ model_name="canvassourceversion",
+ index=models.Index(
+ fields=["team", "canvas", "created_at"],
+ name="canvas_source_history_idx",
+ ),
+ ),
+ migrations.AddIndex(
+ model_name="canvassourceversion",
+ index=models.Index(fields=["team", "source_hash"], name="canvas_source_hash_idx"),
+ ),
+ migrations.AddConstraint(
+ model_name="canvassourceversion",
+ constraint=models.UniqueConstraint(fields=("canvas", "task_run_id"), name="canvas_source_unique_run"),
+ ),
+ migrations.AddIndex(
+ model_name="canvasbuild",
+ index=models.Index(fields=["team", "canvas", "created_at"], name="canvas_build_history_idx"),
+ ),
+ migrations.AddIndex(
+ model_name="canvasbuild",
+ index=models.Index(
+ fields=["build_status", "completed_at"],
+ name="canvas_build_retention_idx",
+ ),
+ ),
+ ]
diff --git a/posthog/migrations/max_migration.txt b/posthog/migrations/max_migration.txt
index 872666d56a96..36ebbc82ab44 100644
--- a/posthog/migrations/max_migration.txt
+++ b/posthog/migrations/max_migration.txt
@@ -1 +1 @@
-1264_delete_revenue_analytics_user_product_list
+1265_canvas_application_builds
diff --git a/posthog/models/__init__.py b/posthog/models/__init__.py
index c29d02f3ed28..1845a8d0f115 100644
--- a/posthog/models/__init__.py
+++ b/posthog/models/__init__.py
@@ -28,6 +28,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 import CanvasApplication, 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
@@ -114,6 +115,9 @@
"RoleExternalReference",
"FileSystem",
"FileSystemFolderContextGeneration",
+ "CanvasApplication",
+ "CanvasBuild",
+ "CanvasSourceVersion",
"FileSystemFolderInstructions",
"FileSystemViewLog",
"PersistedFolder",
diff --git a/posthog/models/file_system/canvas.py b/posthog/models/file_system/canvas.py
new file mode 100644
index 000000000000..dea7157c44cc
--- /dev/null
+++ b/posthog/models/file_system/canvas.py
@@ -0,0 +1,134 @@
+from __future__ import annotations
+
+import gzip
+import json
+from typing import Any
+
+from django.db import models
+from django.utils import timezone
+
+from posthog.models.scoping.root_mixin import TeamScopedRootMixin
+from posthog.models.utils import UUIDModel, uuid7
+from posthog.storage import object_storage
+
+
+class CanvasApplication(TeamScopedRootMixin, UUIDModel):
+ team = models.ForeignKey("posthog.Team", on_delete=models.CASCADE, db_constraint=False)
+ canvas = models.OneToOneField(
+ "posthog.FileSystem",
+ on_delete=models.CASCADE,
+ related_name="canvas_application",
+ db_constraint=False,
+ )
+ current_source_version = models.ForeignKey(
+ "posthog.CanvasSourceVersion",
+ on_delete=models.SET_NULL,
+ null=True,
+ blank=True,
+ related_name="current_for_applications",
+ db_constraint=False,
+ )
+ active_build = models.ForeignKey(
+ "posthog.CanvasBuild",
+ on_delete=models.SET_NULL,
+ null=True,
+ blank=True,
+ related_name="active_for_applications",
+ db_constraint=False,
+ )
+ previous_build = models.ForeignKey(
+ "posthog.CanvasBuild",
+ on_delete=models.SET_NULL,
+ null=True,
+ blank=True,
+ related_name="previous_for_applications",
+ db_constraint=False,
+ )
+ created_at = models.DateTimeField(default=timezone.now, editable=False)
+ updated_at = models.DateTimeField(auto_now=True)
+
+
+class CanvasSourceVersion(TeamScopedRootMixin):
+ id = models.UUIDField(primary_key=True, default=uuid7, editable=False)
+ team = models.ForeignKey("posthog.Team", on_delete=models.CASCADE, db_constraint=False)
+ canvas = models.ForeignKey(
+ "posthog.FileSystem",
+ on_delete=models.CASCADE,
+ related_name="canvas_source_versions",
+ db_constraint=False,
+ )
+ parent_version = models.ForeignKey(
+ "self",
+ on_delete=models.PROTECT,
+ null=True,
+ blank=True,
+ related_name="child_versions",
+ )
+ task_id = models.UUIDField()
+ task_run_id = models.UUIDField()
+ source_hash = models.CharField(max_length=64)
+ source_object_key = models.CharField(max_length=500)
+ source_size = models.PositiveIntegerField()
+ prompt = models.TextField(null=True, blank=True)
+ created_by_id = models.BigIntegerField(null=True, blank=True)
+ created_at = models.DateTimeField(default=timezone.now, editable=False)
+
+ class Meta:
+ constraints = [
+ models.UniqueConstraint(fields=["canvas", "task_run_id"], name="canvas_source_unique_run"),
+ ]
+ indexes = [
+ models.Index(fields=["team", "canvas", "created_at"], name="canvas_source_history_idx"),
+ models.Index(fields=["team", "source_hash"], name="canvas_source_hash_idx"),
+ ]
+
+ def read_project(self) -> dict[str, Any]:
+ content = object_storage.read_bytes(self.source_object_key)
+ if content is None:
+ raise FileNotFoundError(self.source_object_key)
+ project = json.loads(gzip.decompress(content))
+ if not isinstance(project, dict):
+ raise ValueError("Canvas source archive does not contain an object")
+ return project
+
+
+class CanvasBuild(TeamScopedRootMixin):
+ class Status(models.TextChoices):
+ QUEUED = "queued"
+ BUILDING = "building"
+ READY = "ready"
+ FAILED = "failed"
+
+ id = models.UUIDField(primary_key=True, default=uuid7, editable=False)
+ team = models.ForeignKey("posthog.Team", on_delete=models.CASCADE, db_constraint=False)
+ canvas = models.ForeignKey(
+ "posthog.FileSystem",
+ on_delete=models.CASCADE,
+ related_name="canvas_builds",
+ db_constraint=False,
+ )
+ source_version = models.ForeignKey(
+ CanvasSourceVersion,
+ on_delete=models.CASCADE,
+ related_name="builds",
+ )
+ build_status = models.CharField(max_length=16, choices=Status.choices, default=Status.QUEUED)
+ artifact_object_prefix = models.CharField(max_length=500, null=True, blank=True)
+ integrity = models.CharField(max_length=100, null=True, blank=True)
+ diagnostics = models.JSONField(default=list)
+ manifest = models.JSONField(null=True, blank=True)
+ pinned = models.BooleanField(default=False)
+ created_at = models.DateTimeField(default=timezone.now, editable=False)
+ started_at = models.DateTimeField(null=True, blank=True)
+ completed_at = models.DateTimeField(null=True, blank=True)
+
+ class Meta:
+ indexes = [
+ models.Index(fields=["team", "canvas", "created_at"], name="canvas_build_history_idx"),
+ models.Index(fields=["build_status", "completed_at"], name="canvas_build_retention_idx"),
+ ]
+
+
+def serialize_canvas_project(project: dict[str, Any]) -> tuple[bytes, bytes]:
+ canonical = json.dumps(project, sort_keys=True, separators=(",", ":"), ensure_ascii=False).encode()
+ return canonical, gzip.compress(canonical, mtime=0)
diff --git a/posthog/rate_limit.py b/posthog/rate_limit.py
index eb18281022e8..8d67b960d798 100644
--- a/posthog/rate_limit.py
+++ b/posthog/rate_limit.py
@@ -720,6 +720,16 @@ class CustomSourceAIBuilderDailyThrottle(_CustomSourceAIBuilderThrottle):
rate = "50/day"
+class CanvasValidationBurstThrottle(PersonalApiKeyOrUserRateThrottle):
+ scope = "canvas_validation_burst"
+ rate = "10/minute"
+
+
+class CanvasValidationDailyThrottle(PersonalApiKeyOrUserRateThrottle):
+ scope = "canvas_validation_daily"
+ rate = "200/day"
+
+
class PersonalSpendBurstThrottle(PersonalApiKeyOrUserRateThrottle):
# Burst limit for the personal LLM spend analysis endpoint.
# ClickHouse-bound; protects against impatient refresh-spamming.
diff --git a/posthog/settings/__init__.py b/posthog/settings/__init__.py
index 66e68a1f7636..dafadf10213e 100644
--- a/posthog/settings/__init__.py
+++ b/posthog/settings/__init__.py
@@ -25,6 +25,7 @@
from posthog.settings.agents import *
from posthog.settings.async_migrations import *
from posthog.settings.batch_exports import *
+from posthog.settings.canvas import *
from posthog.settings.celery import *
from posthog.settings.cohorts import *
from posthog.settings.kafka 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/tasks/__init__.py b/posthog/tasks/__init__.py
index 78860776c846..df7c37cedece 100644
--- a/posthog/tasks/__init__.py
+++ b/posthog/tasks/__init__.py
@@ -4,6 +4,7 @@
activity_log,
async_migrations,
calculate_cohort,
+ canvas_builds,
demo_create_data,
demo_reset_master_team,
early_access_feature,
@@ -30,6 +31,7 @@
"activity_log",
"async_migrations",
"calculate_cohort",
+ "canvas_builds",
"demo_create_data",
"demo_reset_master_team",
"early_access_feature",
diff --git a/posthog/tasks/canvas_builds.py b/posthog/tasks/canvas_builds.py
new file mode 100644
index 000000000000..43aa1f0a0bf4
--- /dev/null
+++ b/posthog/tasks/canvas_builds.py
@@ -0,0 +1,323 @@
+from __future__ import annotations
+
+import json
+import base64
+import hashlib
+import subprocess
+from datetime import timedelta
+from pathlib import Path
+from typing import Any, Literal
+
+from django.conf import settings
+from django.db import transaction
+from django.utils import timezone
+
+from celery import shared_task
+
+from posthog.celery_queues import CeleryQueue
+from posthog.models.file_system.canvas import CanvasApplication, CanvasBuild, CanvasSourceVersion
+from posthog.models.file_system.file_system import FileSystem
+from posthog.storage import object_storage
+
+from products.tasks.backend.facade import api as tasks_facade
+
+MAX_ARTIFACT_FILES = 512
+MAX_ARTIFACT_BYTES = 20_000_000
+BUILDER_PATH = Path(settings.BASE_DIR) / "common" / "canvas-builder" / "build.mjs"
+
+
+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)
+ )
+
+
+def _run_builder(project: dict[str, Any]) -> dict[str, Any]:
+ process = subprocess.run(
+ ["node", "--max-old-space-size=256", str(BUILDER_PATH)],
+ input=json.dumps({"project": project}, separators=(",", ":")),
+ capture_output=True,
+ text=True,
+ timeout=120,
+ check=False,
+ cwd=settings.BASE_DIR,
+ env={"PATH": "/usr/local/bin:/usr/bin:/bin", "NODE_ENV": "production"},
+ )
+ if process.returncode != 0:
+ raise RuntimeError("Canvas builder process failed")
+ result = json.loads(process.stdout)
+ if not isinstance(result, dict):
+ raise ValueError("Canvas builder returned an invalid response")
+ return result
+
+
+def _validated_artifacts(result: dict[str, Any]) -> tuple[dict[str, bytes], dict[str, Any]]:
+ artifact_files = result.get("artifactFiles")
+ manifest = result.get("manifest")
+ if not isinstance(artifact_files, dict) or not isinstance(manifest, dict):
+ raise ValueError("Canvas builder omitted artifacts or manifest")
+ manifest_files = manifest.get("files")
+ if not isinstance(manifest_files, list) or len(manifest_files) > MAX_ARTIFACT_FILES:
+ raise ValueError("Canvas artifact manifest has too many files")
+ declared: dict[str, dict[str, Any]] = {}
+ for entry in manifest_files:
+ if not isinstance(entry, dict):
+ raise ValueError("Canvas artifact manifest contains an invalid file")
+ path = entry.get("path")
+ content_type = entry.get("contentType")
+ size = entry.get("bytes")
+ digest = entry.get("sha256")
+ if (
+ not isinstance(path, str)
+ or not _valid_artifact_path(path)
+ or not isinstance(content_type, str)
+ or not content_type
+ or "\n" in content_type
+ or "\r" in content_type
+ or not isinstance(size, int)
+ or isinstance(size, bool)
+ or size < 0
+ or not isinstance(digest, str)
+ or len(digest) != 64
+ or any(character not in "0123456789abcdef" for character in digest)
+ or path in declared
+ ):
+ raise ValueError("Canvas artifact manifest contains an invalid file")
+ declared[path] = entry
+ if len(declared) != len(manifest_files) or set(declared) != set(artifact_files):
+ raise ValueError("Canvas artifact manifest does not match emitted files")
+ encoded: dict[str, bytes] = {}
+ total = 0
+ for path, content in artifact_files.items():
+ if not isinstance(path, str) or not _valid_artifact_path(path) or not isinstance(content, str):
+ raise ValueError("Canvas builder emitted an invalid artifact")
+ data = content.encode()
+ entry = declared[path]
+ if entry.get("sha256") != hashlib.sha256(data).hexdigest() or entry.get("bytes") != len(data):
+ raise ValueError("Canvas artifact integrity does not match its manifest")
+ total += len(data)
+ encoded[path] = data
+ if total > MAX_ARTIFACT_BYTES:
+ raise ValueError("Canvas build exceeds the artifact size limit")
+ if manifest.get("entryHtml") != "index.html" or "index.html" not in encoded:
+ raise ValueError("Canvas build does not contain index.html")
+ return encoded, manifest
+
+
+def validate_canvas_project(project: dict[str, Any]) -> dict[str, Any]:
+ try:
+ result = _run_builder(project)
+ diagnostics = result.get("diagnostics")
+ bounded_diagnostics = diagnostics[:500] if isinstance(diagnostics, list) else []
+ if result.get("ok") is not True:
+ return {
+ "ok": False,
+ "diagnostics": bounded_diagnostics,
+ "manifest": None,
+ }
+ _, manifest = _validated_artifacts(result)
+ return {
+ "ok": True,
+ "diagnostics": bounded_diagnostics,
+ "manifest": manifest,
+ }
+ except (subprocess.TimeoutExpired, OSError, json.JSONDecodeError, RuntimeError, ValueError):
+ return {
+ "ok": False,
+ "diagnostics": [
+ {
+ "severity": "error",
+ "code": "validation_unavailable",
+ "message": "Canvas validation is temporarily unavailable.",
+ }
+ ],
+ "manifest": None,
+ }
+
+
+def _complete_build(build: CanvasBuild, artifacts: dict[str, bytes], manifest: dict[str, Any]) -> None:
+ prefix = f"canvas/artifacts/{build.team_id}/{build.id}"
+ manifest_files = {entry["path"]: entry for entry in manifest["files"]}
+ for path, content in artifacts.items():
+ object_storage.write(
+ f"{prefix}/{path}",
+ content,
+ extras={
+ "ContentType": manifest_files[path]["contentType"],
+ "CacheControl": "private, max-age=31536000, immutable",
+ },
+ )
+ integrity = "sha256-" + base64.b64encode(hashlib.sha256(artifacts["index.html"]).digest()).decode()
+
+ with transaction.atomic():
+ locked = CanvasBuild.objects.for_team(build.team_id).select_for_update().get(id=build.id)
+ application = (
+ CanvasApplication.objects.for_team(build.team_id).select_for_update().get(canvas_id=build.canvas_id)
+ )
+ locked.build_status = CanvasBuild.Status.READY
+ locked.artifact_object_prefix = prefix
+ locked.integrity = integrity
+ locked.diagnostics = []
+ locked.manifest = manifest
+ locked.completed_at = timezone.now()
+ locked.save(
+ update_fields=[
+ "build_status",
+ "artifact_object_prefix",
+ "integrity",
+ "diagnostics",
+ "manifest",
+ "completed_at",
+ ]
+ )
+ if application.current_source_version_id != locked.source_version_id:
+ return
+ application.previous_build_id = application.active_build_id
+ application.active_build = locked
+ application.save(update_fields=["previous_build", "active_build", "updated_at"])
+ canvas = FileSystem.objects.select_for_update().get(id=build.canvas_id, team_id=build.team_id)
+ meta = dict(canvas.meta or {})
+ meta.update({"activeBuildId": str(locked.id), "currentSourceVersionId": str(locked.source_version_id)})
+ canvas.meta = meta
+ canvas.save(update_fields=["meta"])
+ _post_build_update(build, "ready")
+
+
+def _fail_build(build: CanvasBuild, diagnostics: list[dict[str, Any]]) -> None:
+ CanvasBuild.objects.for_team(build.team_id).filter(id=build.id).update(
+ build_status=CanvasBuild.Status.FAILED,
+ diagnostics=diagnostics[:500],
+ completed_at=timezone.now(),
+ )
+ _post_build_update(build, "failed")
+
+
+def _post_build_update(build: CanvasBuild, build_status: Literal["ready", "failed"]) -> None:
+ source = build.source_version
+ canvas = build.canvas
+ channel_id = (canvas.meta or {}).get("channelId")
+ canvas_url = f"{settings.SITE_URL}/code/canvas/{channel_id}/{canvas.id}" if channel_id else None
+ tasks_facade.post_canvas_build_thread_update(
+ source.task_id,
+ build.team_id,
+ acting_user_id=source.created_by_id,
+ canvas_name=canvas.path.rsplit("/", 1)[-1] or "Canvas",
+ canvas_url=canvas_url,
+ build_id=build.id,
+ source_version_id=source.id,
+ build_status=build_status,
+ )
+ if source.parent_version_id is None and build_status == "ready":
+ tasks_facade.post_canvas_created_thread_update(
+ source.task_id,
+ build.team_id,
+ acting_user_id=source.created_by_id,
+ canvas_name=canvas.path.rsplit("/", 1)[-1] or "Canvas",
+ canvas_url=canvas_url,
+ )
+
+
+@shared_task(bind=True, max_retries=2, queue=CeleryQueue.LONG_RUNNING.value, soft_time_limit=150, time_limit=180)
+def build_canvas(self: Any, build_id: str, team_id: int) -> None:
+ build = CanvasBuild.objects.for_team(team_id).select_related("source_version", "canvas").filter(id=build_id).first()
+ if build is None or build.build_status in {CanvasBuild.Status.READY, CanvasBuild.Status.FAILED}:
+ return
+ started_at = timezone.now()
+ claimed = (
+ CanvasBuild.objects.for_team(team_id)
+ .filter(id=build.id, build_status=CanvasBuild.Status.QUEUED)
+ .update(build_status=CanvasBuild.Status.BUILDING, started_at=started_at)
+ )
+ if not claimed:
+ claimed = (
+ CanvasBuild.objects.for_team(team_id)
+ .filter(
+ id=build.id,
+ build_status=CanvasBuild.Status.BUILDING,
+ started_at__lt=started_at - timedelta(minutes=5),
+ )
+ .update(started_at=started_at)
+ )
+ if not claimed:
+ return
+ try:
+ result = _run_builder(build.source_version.read_project())
+ diagnostics = result.get("diagnostics")
+ if result.get("ok") is not True:
+ _fail_build(build, diagnostics if isinstance(diagnostics, list) else [])
+ return
+ artifacts, manifest = _validated_artifacts(result)
+ _complete_build(build, artifacts, manifest)
+ except (subprocess.TimeoutExpired, OSError, json.JSONDecodeError) as error:
+ if self.request.retries < self.max_retries:
+ CanvasBuild.objects.for_team(team_id).filter(id=build.id).update(build_status=CanvasBuild.Status.QUEUED)
+ raise self.retry(exc=error, countdown=2 ** (self.request.retries + 1))
+ _fail_build(
+ build,
+ [
+ {
+ "severity": "error",
+ "code": "build_unavailable",
+ "message": "The canvas build service is unavailable. Retry the build.",
+ }
+ ],
+ )
+ except (RuntimeError, ValueError) as error:
+ _fail_build(build, [{"severity": "error", "code": "invalid_build_output", "message": str(error)[:10_000]}])
+
+
+def _older_than(metadata: dict[str, Any] | None, cutoff: Any) -> bool:
+ if not metadata:
+ return False
+ modified = metadata.get("LastModified")
+ return bool(modified and modified < cutoff)
+
+
+@shared_task(queue=CeleryQueue.LONG_RUNNING.value, soft_time_limit=300, time_limit=360)
+def collect_canvas_objects() -> None:
+ now = timezone.now()
+ protected_build_ids: set[Any] = set()
+ for active_id, previous_id in CanvasApplication.objects.unscoped().values_list(
+ "active_build_id", "previous_build_id"
+ ):
+ if active_id:
+ protected_build_ids.add(active_id)
+ if previous_id:
+ protected_build_ids.add(previous_id)
+
+ expired_builds = (
+ CanvasBuild.objects.unscoped()
+ .exclude(id__in=protected_build_ids)
+ .exclude(pinned=True)
+ .filter(
+ completed_at__lt=now - timedelta(days=30),
+ artifact_object_prefix__isnull=False,
+ )
+ )
+ for build in expired_builds.iterator(chunk_size=100):
+ keys = object_storage.list_objects(build.artifact_object_prefix or "") or []
+ if keys:
+ object_storage.delete_objects(keys)
+ CanvasBuild.objects.unscoped().filter(id=build.id).update(artifact_object_prefix=None)
+
+ source_keys = set(CanvasSourceVersion.objects.unscoped().values_list("source_object_key", flat=True))
+ for key in object_storage.list_objects("canvas/source/") or []:
+ if key not in source_keys and _older_than(object_storage.head_object(key), now - timedelta(days=1)):
+ object_storage.delete(key)
+
+ build_ids = {
+ str(value)
+ for value in CanvasBuild.objects.unscoped()
+ .filter(artifact_object_prefix__isnull=False)
+ .values_list("id", flat=True)
+ }
+ for key in object_storage.list_objects("canvas/artifacts/") or []:
+ parts = key.split("/")
+ if len(parts) < 5 or parts[3] in build_ids:
+ continue
+ if _older_than(object_storage.head_object(key), now - timedelta(days=1)):
+ object_storage.delete(key)
diff --git a/posthog/tasks/scheduled.py b/posthog/tasks/scheduled.py
index 3ce70ff46039..690db0a32cd2 100644
--- a/posthog/tasks/scheduled.py
+++ b/posthog/tasks/scheduled.py
@@ -11,6 +11,7 @@
from posthog.clickhouse.client.execute_async import QueryStatusManager
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.canvas_builds import collect_canvas_objects
from posthog.tasks.email import (
EXTERNAL_DATA_DIGEST_DAY_BOUNDARY_HOUR_UTC,
send_error_tracking_weekly_digest,
@@ -207,6 +208,11 @@ def setup_periodic_tasks(sender: Celery, **kwargs: Any) -> None:
capture_task_run_state_metrics.s(),
name="tasks run state metrics",
)
+ sender.add_periodic_task(
+ crontab(hour="3", minute="20"),
+ collect_canvas_objects.s(),
+ name="canvas object retention",
+ )
sender.add_periodic_task(10, redis_heartbeat.s(), name="10 sec heartbeat")
sender.add_periodic_task(
diff --git a/posthog/tasks/test/test_canvas_builds.py b/posthog/tasks/test/test_canvas_builds.py
new file mode 100644
index 000000000000..d0898838e347
--- /dev/null
+++ b/posthog/tasks/test/test_canvas_builds.py
@@ -0,0 +1,259 @@
+import hashlib
+from typing import Any
+
+from unittest.mock import patch
+
+from django.test import SimpleTestCase
+
+from parameterized import parameterized
+
+from posthog.api.file_system.canvas_application import CanvasSourceProjectSerializer
+from posthog.tasks.canvas_builds import MAX_ARTIFACT_FILES, _run_builder, _validated_artifacts, validate_canvas_project
+
+
+def project(source: str, *, dependencies: dict[str, str] | None = None) -> dict[str, Any]:
+ return {
+ "schemaVersion": 1,
+ "files": {
+ "index.html": '',
+ "src/main.ts": source,
+ },
+ "entryHtml": "index.html",
+ "dependencies": dependencies or {},
+ "canvasSdkVersion": "1.0.0",
+ "capabilities": {
+ "posthog": {"insights": [], "inlineQueries": False, "captureEvents": []},
+ "network": {"origins": []},
+ },
+ }
+
+
+class TestCanvasBuilder(SimpleTestCase):
+ def test_builds_vanilla_typescript_with_csp_and_runtime(self) -> None:
+ result = _run_builder(project('document.querySelector("#root")!.textContent = "Hello"'))
+
+ self.assertTrue(result["ok"])
+ self.assertIn("assets/main.js", result["artifactFiles"])
+ self.assertIn("Content-Security-Policy", result["artifactFiles"]["index.html"])
+ self.assertIn("assets/canvas-runtime.js", result["artifactFiles"])
+
+ def test_builds_binary_assets_and_module_workers(self) -> None:
+ payload = 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=",
+ },
+ }
+
+ serializer = CanvasSourceProjectSerializer(data=payload)
+ self.assertTrue(serializer.is_valid(), serializer.errors)
+ result = _run_builder(serializer.validated_data)
+
+ self.assertTrue(result["ok"], result["diagnostics"])
+ self.assertIn("new Blob", result["artifactFiles"]["assets/main.js"])
+
+ @parameterized.expand([("invalid_base64", "%%%", "image/png"), ("active_content", "PGgxLz4=", "text/html")])
+ def test_rejects_unsafe_assets(self, _name: str, content: str, content_type: str) -> None:
+ payload = 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_validation_returns_manifest_without_executable_artifacts(self) -> None:
+ result = validate_canvas_project(project('document.querySelector("#root")!.textContent = "Hello"'))
+
+ self.assertTrue(result["ok"])
+ self.assertEqual(result["manifest"]["entryHtml"], "index.html")
+ self.assertNotIn("artifactFiles", result)
+
+ @patch("posthog.tasks.canvas_builds._run_builder", side_effect=RuntimeError("private detail"))
+ def test_validation_failure_does_not_leak_builder_internals(self, _run: Any) -> None:
+ result = validate_canvas_project(project(""))
+
+ self.assertFalse(result["ok"])
+ self.assertEqual(result["diagnostics"][0]["code"], "validation_unavailable")
+ self.assertNotIn("private detail", result["diagnostics"][0]["message"])
+
+ @parameterized.expand(
+ [
+ ("node_builtin", 'import "node:fs"', "forbidden_import"),
+ ("undeclared_package", 'import React from "react"; void React', "undeclared_dependency"),
+ ("package_traversal", 'import "react/../../outside"', "forbidden_import"),
+ ("dynamic_undeclared", 'void import("react")', "compile_error"),
+ ]
+ )
+ def test_rejects_untrusted_imports(self, _name: str, source: str, expected_code: str) -> None:
+ result = _run_builder(project(source))
+
+ self.assertFalse(result["ok"])
+ self.assertIn(expected_code, [diagnostic["code"] for diagnostic in result["diagnostics"]])
+
+ def test_blocks_external_egress_even_when_declared_until_capability_approval_exists(self) -> None:
+ payload = project('fetch("https://example.com/data")')
+ payload["capabilities"]["network"]["origins"] = ["https://example.com"]
+
+ result = _run_builder(payload)
+
+ self.assertFalse(result["ok"])
+ self.assertIn("network_capability_unavailable", [item["code"] for item in result["diagnostics"]])
+
+ def test_builder_independently_rejects_an_unsupported_sdk(self) -> None:
+ payload = project("")
+ payload["canvasSdkVersion"] = "2.0.0"
+
+ result = _run_builder(payload)
+
+ self.assertFalse(result["ok"])
+ self.assertIn("unsupported_sdk", [item["code"] for item in result["diagnostics"]])
+
+ def test_rejects_artifact_content_that_does_not_match_manifest(self) -> None:
+ content = b"safe"
+ result = {
+ "artifactFiles": {"index.html": "tampered"},
+ "manifest": {
+ "entryHtml": "index.html",
+ "files": [
+ {
+ "path": "index.html",
+ "contentType": "text/html",
+ "bytes": len(content),
+ "sha256": hashlib.sha256(content).hexdigest(),
+ }
+ ],
+ },
+ }
+
+ with self.assertRaisesMessage(ValueError, "integrity"):
+ _validated_artifacts(result)
+
+ @parameterized.expand(
+ [
+ ("event_handler", '', "inline_event_handler"),
+ ("javascript_url", 'Go', "javascript_url"),
+ ]
+ )
+ def test_rejects_unsafe_html_execution_paths(self, _name: str, html: str, code: str) -> None:
+ payload = project("")
+ payload["files"] = {"index.html": html}
+
+ result = _run_builder(payload)
+
+ self.assertFalse(result["ok"])
+ self.assertIn(code, [item["code"] for item in result["diagnostics"]])
+
+ @parameterized.expand(
+ [
+ ("traversal", "../index.html"),
+ ("absolute", "/index.html"),
+ ("backslash", "src\\main.ts"),
+ ("control_character", "src/ma\n in.ts"),
+ ]
+ )
+ def test_source_serializer_rejects_unsafe_paths(self, _name: str, unsafe_path: str) -> None:
+ payload = project("")
+ payload["files"] = {unsafe_path: "", "index.html": ""}
+
+ serializer = CanvasSourceProjectSerializer(data=payload)
+
+ self.assertFalse(serializer.is_valid())
+ self.assertIn("files", serializer.errors)
+
+ @parameterized.expand(
+ [
+ ("range", "react", "^19.2.6"),
+ ("unknown", "left-pad", "1.3.0"),
+ ("wrong_admitted_version", "react", "19.2.5"),
+ ]
+ )
+ def test_source_serializer_rejects_unavailable_dependencies(
+ self, _name: str, dependency: str, version: str
+ ) -> None:
+ payload = project("")
+ payload["dependencies"] = {dependency: version}
+
+ serializer = CanvasSourceProjectSerializer(data=payload)
+
+ self.assertFalse(serializer.is_valid())
+ self.assertIn("dependencies", serializer.errors)
+
+ def test_source_serializer_accepts_all_admitted_dependencies(self) -> None:
+ payload = project("")
+ payload["dependencies"] = {
+ "@posthog/quill": "0.3.0-beta.24",
+ "d3": "7.9.0",
+ "date-fns": "4.1.0",
+ "echarts": "6.1.0",
+ "lodash-es": "4.18.1",
+ "react": "19.2.6",
+ "react-dom": "19.2.6",
+ "three": "0.183.2",
+ "zod": "4.4.3",
+ }
+
+ serializer = CanvasSourceProjectSerializer(data=payload)
+
+ self.assertTrue(serializer.is_valid(), serializer.errors)
+
+ def test_source_serializer_bounds_dependency_metadata(self) -> None:
+ payload = project("")
+ payload["dependencies"] = {f"package-{index}": "1.0.0" for index in range(65)}
+
+ serializer = CanvasSourceProjectSerializer(data=payload)
+
+ self.assertFalse(serializer.is_valid())
+ self.assertIn("dependencies", serializer.errors)
+
+ def test_source_serializer_rejects_unsupported_sdk_versions(self) -> None:
+ payload = project("")
+ payload["canvasSdkVersion"] = "2.0.0"
+
+ serializer = CanvasSourceProjectSerializer(data=payload)
+
+ self.assertFalse(serializer.is_valid())
+ self.assertIn("canvasSdkVersion", serializer.errors)
+
+ def test_rejects_duplicate_or_malformed_manifest_entries(self) -> None:
+ digest = hashlib.sha256(b"safe").hexdigest()
+ entry = {"path": "index.html", "contentType": "text/html", "bytes": 4, "sha256": digest}
+ result = {
+ "artifactFiles": {"index.html": "safe"},
+ "manifest": {"entryHtml": "index.html", "files": [entry, entry]},
+ }
+
+ with self.assertRaisesMessage(ValueError, "invalid file"):
+ _validated_artifacts(result)
+
+ def test_rejects_excessive_manifest_before_decoding_artifacts(self) -> None:
+ result = {
+ "artifactFiles": {},
+ "manifest": {"entryHtml": "index.html", "files": [{}] * (MAX_ARTIFACT_FILES + 1)},
+ }
+
+ with self.assertRaisesMessage(ValueError, "too many files"):
+ _validated_artifacts(result)
+
+ @patch("posthog.tasks.canvas_builds.subprocess.run")
+ def test_builder_has_bounded_process_resources(self, run: Any) -> None:
+ run.return_value.returncode = 0
+ run.return_value.stdout = "{}"
+
+ _run_builder(project(""))
+
+ args, kwargs = run.call_args
+ self.assertEqual(args[0][:2], ["node", "--max-old-space-size=256"])
+ self.assertEqual(kwargs["timeout"], 120)
+ self.assertEqual(kwargs["env"], {"PATH": "/usr/local/bin:/usr/bin:/bin", "NODE_ENV": "production"})
diff --git a/posthog/urls.py b/posthog/urls.py
index d8d7eafae097..0a3a4348ded6 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
@@ -745,6 +746,9 @@ def delete_events(request):
urlpatterns.append(
opt_slash_path("sign-up", RedirectView.as_view(url="/signup", permanent=True, query_string=True)),
)
+urlpatterns.append(
+ re_path(r"^canvas-artifacts/(?P[^/]+)/(?P.+)$", canvas_artifact, name="canvas-artifact")
+)
# Routes added individually to remove login requirement
frontend_unauthenticated_routes = [
diff --git a/products/posthog_ai/skills/building-canvases/SKILL.md b/products/posthog_ai/skills/building-canvases/SKILL.md
new file mode 100644
index 000000000000..4ad84e024fda
--- /dev/null
+++ b/products/posthog_ai/skills/building-canvases/SKILL.md
@@ -0,0 +1,61 @@
+---
+name: building-canvases
+description: >
+ Builds and edits PostHog canvases as arbitrary client-side browser applications. Use when any task needs to create,
+ update, validate, or publish a canvas using React, Quill, semantic HTML, browser APIs, WebGL, or Three.js. Covers the
+ source-project contract, capability declarations, deterministic builds, conflict-safe publishing, and build checks.
+---
+
+# Building canvases
+
+A canvas is a client-side browser application. It can be a document, dashboard, visualization, form, game, WebGL scene,
+or another browser experience. Framework choice is an implementation detail, not a user decision.
+
+## Choose an implementation
+
+- Use React and Quill for application state, forms, reusable components, and interfaces that should match PostHog.
+- Use semantic HTML, CSS, and direct browser APIs for documents, focused interactions, graphics, and programs where React
+ adds no useful structure.
+- Mix approaches when useful. React can own the interface around a Three.js scene or another browser API.
+
+Read [React and Quill](references/react-quill.md) when using React or PostHog UI primitives. Read
+[HTML and WebGL](references/html-webgl.md) for direct browser applications.
+
+## Work from the current source
+
+1. Resolve or create a desktop file-system dashboard item for the target canvas.
+2. Call `posthog:canvas-source-get`. If the canvas has no normalized source yet, convert its legacy single-file React
+ source into the standard project shape before publishing the first new version.
+3. Keep the returned version ID. It is the base for optimistic concurrency.
+4. Edit the source project in the task workspace. Use a guarded source patch for focused edits and a complete project
+ publish for initial creation or project-wide rewrites. Do not store generated source in a database field.
+
+The pinned browser catalog includes React, Quill, Three.js, D3, ECharts, date-fns, lodash-es, and Zod. Put images,
+fonts, and WebAssembly in the bounded base64 `assets` map with explicit media types. Import self-contained TypeScript
+module workers with the `?worker` suffix.
+
+Every requested edit belongs to a fresh task run. A run publishes at most one source version. Canvas history remains the
+canonical history even when several tasks edit the same canvas.
+
+## Declare capabilities
+
+Generated code has no PostHog credentials. It uses the `ph` runtime bridge and declares every data or side-effect
+capability in the source project. Read [Data and capabilities](references/data-and-capabilities.md) before accessing
+PostHog data or capturing events. Direct external network access is unavailable
+until capability approval exists.
+
+## Validate and publish
+
+Read [Validation and publishing](references/validation-and-publishing.md), then:
+
+1. Call `posthog:canvas-source-validate` until the authoritative build recipe
+ succeeds without errors.
+2. Call `posthog:canvas-source-patch` for focused changes or `posthog:canvas-source-publish` for complete projects,
+ always with the version ID read before editing. Sandbox attribution supplies the current task and run automatically.
+3. If publishing returns `version_conflict`, load the new current source and start a fresh run to reapply the change.
+4. Poll `posthog:canvas-build-get` or read `posthog:canvas-history-get` until the cloud build is ready or failed.
+5. Treat the cloud build as authoritative. A failed build leaves the previous successful artifact active.
+
+Do not claim completion until the cloud build is ready and the artifact loads without a runtime error.
+
+Hosted functions, secrets, databases, queues, and other server-side micro-app capabilities are not available yet.
diff --git a/products/posthog_ai/skills/building-canvases/references/data-and-capabilities.md b/products/posthog_ai/skills/building-canvases/references/data-and-capabilities.md
new file mode 100644
index 000000000000..6f0273cdc101
--- /dev/null
+++ b/products/posthog_ai/skills/building-canvases/references/data-and-capabilities.md
@@ -0,0 +1,28 @@
+# Data and capabilities
+
+Canvas applications call the host through the injected `ph` runtime:
+
+- `ph.loadInsight(shortId, options)` loads a declared insight.
+- `ph.query(query, params)` runs an inline query when `inlineQueries` is enabled.
+- `ph.capture(event, properties, distinctId)` captures a declared event name.
+- `ph.openExternal(url)` asks the host to open an external URL.
+- `ph.navigate.*` asks the host to navigate to a PostHog task or canvas.
+
+Declare the smallest capability set that supports the application:
+
+```json
+{
+ "posthog": {
+ "insights": ["insight-short-id"],
+ "inlineQueries": false,
+ "captureEvents": ["canvas action"]
+ },
+ "network": {
+ "origins": []
+ }
+}
+```
+
+Direct network origins are disabled until a user-facing capability approval flow exists. The host independently checks
+PostHog operations against the manifest. Never embed personal API keys, project tokens, cookies, or other credentials
+in source.
diff --git a/products/posthog_ai/skills/building-canvases/references/html-webgl.md b/products/posthog_ai/skills/building-canvases/references/html-webgl.md
new file mode 100644
index 000000000000..1ffc17a35550
--- /dev/null
+++ b/products/posthog_ai/skills/building-canvases/references/html-webgl.md
@@ -0,0 +1,15 @@
+# HTML, browser APIs, and WebGL
+
+Prefer semantic HTML and local CSS for documents and focused experiences that do not need a component framework. Put
+JavaScript or TypeScript in a local module file referenced by `index.html`; inline and remote scripts are rejected.
+
+Direct Canvas and WebGL APIs need no dependency. For Three.js, declare the admitted exact version:
+
+```json
+{
+ "three": "0.183.2"
+}
+```
+
+Use responsive sizing and release animation frames, observers, and event listeners during teardown when the application
+creates them. Keep all emitted assets inside the source project or import them through the module graph.
diff --git a/products/posthog_ai/skills/building-canvases/references/react-quill.md b/products/posthog_ai/skills/building-canvases/references/react-quill.md
new file mode 100644
index 000000000000..ddd601fefabf
--- /dev/null
+++ b/products/posthog_ai/skills/building-canvases/references/react-quill.md
@@ -0,0 +1,20 @@
+# React and Quill canvases
+
+Use React for stateful, application-like experiences. Use `@posthog/quill` for accessible controls and layouts that
+should match PostHog.
+
+Declare exact versions for every imported package. The admitted React starter dependencies are:
+
+```json
+{
+ "@posthog/quill": "0.3.0-beta.24",
+ "react": "19.2.6",
+ "react-dom": "19.2.6"
+}
+```
+
+Mount React from a local module entry referenced by `index.html`. Keep styles and components in separate project files
+when that makes the source easier to maintain. The build bundles dependencies, so do not load React, Tailwind, or other
+scripts from a CDN.
+
+Quill is optional. A React application can use semantic elements and local CSS when the design system adds no value.
diff --git a/products/posthog_ai/skills/building-canvases/references/validation-and-publishing.md b/products/posthog_ai/skills/building-canvases/references/validation-and-publishing.md
new file mode 100644
index 000000000000..e1a233ccacac
--- /dev/null
+++ b/products/posthog_ai/skills/building-canvases/references/validation-and-publishing.md
@@ -0,0 +1,31 @@
+# Validation and publishing
+
+The source project contains:
+
+```text
+schemaVersion: 1
+files: project-relative path to UTF-8 content
+assets: optional project-relative path to base64 binary content and media type
+entryHtml: index.html
+dependencies: package name to exact version
+canvasSdkVersion: exact version
+capabilities: PostHog and network declarations
+```
+
+Paths are normalized and project-relative. The project supports at most 128 files, 1 MB per source file, 5 MB total
+source, and 10 MB decoded assets.
+The build admits only preinstalled browser packages, never runs dependency lifecycle scripts, rejects Node built-ins,
+and produces immutable HTML, CSS, and JavaScript with a restrictive content security policy.
+
+Validate the complete project with `canvas-source-validate`. Publishing stores a deterministic private source archive
+and creates a separate authoritative cloud build. Never publish locally generated executable artifacts. The last
+successful cloud artifact remains active until a newer current source version builds successfully.
+
+Use `canvas-source-patch` for focused file or asset edits. It applies changes only to the exact source version supplied
+as `expectedCurrentVersionId`; a stale patch returns a conflict without creating a version or build.
+
+When a build fails, inspect its bounded diagnostics, repair the source in a fresh run, and publish against the latest
+version. Do not bypass dependency or capability checks to make a build pass.
+
+Keep `capabilities.network.origins` empty. Direct external network access is disabled until a user-facing capability
+approval flow exists; use the injected `ph` SDK for PostHog data and host-mediated actions.
diff --git a/products/tasks/backend/facade/api.py b/products/tasks/backend/facade/api.py
index cb5a8cd7b40f..54997aed76f1 100644
--- a/products/tasks/backend/facade/api.py
+++ b/products/tasks/backend/facade/api.py
@@ -5350,6 +5350,46 @@ def post_canvas_created_thread_update(
logger.exception("Failed to post canvas-created thread update", extra={"task_id": str(task_id)})
+def post_canvas_build_thread_update(
+ task_id: str | UUID,
+ team_id: int,
+ *,
+ acting_user_id: int | None,
+ canvas_name: str,
+ canvas_url: str | None,
+ build_id: str | UUID,
+ source_version_id: str | UUID,
+ build_status: Literal["ready", "failed"],
+) -> None:
+ try:
+ task = Task.objects.select_related("created_by").filter(id=task_id, team_id=team_id).first()
+ if task is None or task.created_by_id is None or task.created_by_id != acting_user_id:
+ return
+ if not _agent_thread_updates_enabled(task.created_by):
+ return
+ name = re.sub(r"[\[\]\n]", " ", canvas_name).strip() or "Canvas"
+ linked_name = f"[{name}]({canvas_url})" if canvas_url else name
+ content = (
+ f"{linked_name} is ready"
+ if build_status == "ready"
+ else f"{linked_name} could not be built. Open the canvas history to review the errors."
+ )
+ _create_agent_thread_message(
+ task,
+ content,
+ event=f"canvas_build_{build_status}",
+ payload={
+ "canvas_name": name,
+ "canvas_url": canvas_url,
+ "build_id": str(build_id),
+ "source_version_id": str(source_version_id),
+ "status": build_status,
+ },
+ )
+ except Exception:
+ logger.exception("Failed to post canvas build thread update", extra={"task_id": str(task_id)})
+
+
_GITHUB_PR_PATH_PATTERN = re.compile(r"/([^/]+)/([^/]+)/pull/(\d+)/?", re.IGNORECASE)
# Characters that could break out of a markdown [label](url) token or smuggle
diff --git a/products/tasks/backend/temporal/process_task/activities/send_followup_to_sandbox.py b/products/tasks/backend/temporal/process_task/activities/send_followup_to_sandbox.py
index 3086ef7cbdd0..bbd27f149327 100644
--- a/products/tasks/backend/temporal/process_task/activities/send_followup_to_sandbox.py
+++ b/products/tasks/backend/temporal/process_task/activities/send_followup_to_sandbox.py
@@ -356,6 +356,7 @@ def _refresh_sandbox_mcp(
scopes=scopes,
interaction_origin=(state or {}).get("interaction_origin"),
task_id=str(task_run.task_id),
+ task_run_id=str(task_run.id),
)
user_mcp_configs = get_user_mcp_server_configs(
token=access_token,
diff --git a/products/tasks/backend/temporal/process_task/activities/start_agent_server.py b/products/tasks/backend/temporal/process_task/activities/start_agent_server.py
index e2f86ba16cdf..bf22a19130e7 100644
--- a/products/tasks/backend/temporal/process_task/activities/start_agent_server.py
+++ b/products/tasks/backend/temporal/process_task/activities/start_agent_server.py
@@ -237,6 +237,7 @@ def _prepare_launch(ctx: TaskProcessingContext, scopes: PosthogMcpScopes) -> _La
scopes=scopes,
interaction_origin=ctx.interaction_origin,
task_id=str(ctx.task_id),
+ task_run_id=str(ctx.run_id),
)
include_personal = _include_personal_mcp_for_task(task)
user_mcp_configs = get_user_mcp_server_configs(
diff --git a/products/tasks/backend/temporal/process_task/tests/test_send_followup_to_sandbox.py b/products/tasks/backend/temporal/process_task/tests/test_send_followup_to_sandbox.py
index a5d7a67ef691..909b2a0ae718 100644
--- a/products/tasks/backend/temporal/process_task/tests/test_send_followup_to_sandbox.py
+++ b/products/tasks/backend/temporal/process_task/tests/test_send_followup_to_sandbox.py
@@ -90,7 +90,12 @@ def test_success_path_single_call(self, mock_oauth, mock_ph_configs, mock_user_c
mock_oauth.assert_called_once_with(task_run.task, task_run.state, scopes="read_only")
mock_ph_configs.assert_called_once_with(
- token="fresh-token", project_id=7, scopes="read_only", interaction_origin=None, task_id="task-1"
+ token="fresh-token",
+ project_id=7,
+ scopes="read_only",
+ interaction_origin=None,
+ task_id="task-1",
+ task_run_id="run-1",
)
mock_user_configs.assert_called_once_with(
token="fresh-token", team_id=7, user_id=42, interaction_origin=None, allowed_installation_ids=None
@@ -205,7 +210,12 @@ def test_scopes_propagate_to_oauth_and_configs(
mock_oauth.assert_called_once_with(mock_oauth.call_args.args[0], None, scopes="full")
mock_ph_configs.assert_called_once_with(
- token="fresh-token", project_id=7, scopes="full", interaction_origin=None, task_id="task-1"
+ token="fresh-token",
+ project_id=7,
+ scopes="full",
+ interaction_origin=None,
+ task_id="task-1",
+ task_run_id="run-1",
)
def test_transition_refresh_failure_reports_unsafe(
diff --git a/products/tasks/backend/temporal/process_task/tests/test_utils.py b/products/tasks/backend/temporal/process_task/tests/test_utils.py
index bfe800dbac81..c6eca89dc5a5 100644
--- a/products/tasks/backend/temporal/process_task/tests/test_utils.py
+++ b/products/tasks/backend/temporal/process_task/tests/test_utils.py
@@ -254,14 +254,20 @@ def test_returns_empty_list_when_no_site_url(self) -> None:
mock_settings.SITE_URL = ""
assert get_sandbox_ph_mcp_configs(self.TOKEN, self.PROJECT_ID) == []
- def test_task_id_adds_attribution_header(self) -> None:
+ def test_task_and_run_ids_add_attribution_headers(self) -> None:
with patch("products.tasks.backend.temporal.process_task.utils.settings") as mock_settings:
mock_settings.SANDBOX_MCP_URL = None
mock_settings.SITE_URL = "https://app.posthog.com"
- configs = get_sandbox_ph_mcp_configs(self.TOKEN, self.PROJECT_ID, task_id="task-uuid-123")
+ configs = get_sandbox_ph_mcp_configs(
+ self.TOKEN,
+ self.PROJECT_ID,
+ task_id="task-uuid-123",
+ task_run_id="run-uuid-456",
+ )
assert configs[0].headers == [
*self._expected_headers(),
{"name": "X-PostHog-Task-Id", "value": "task-uuid-123"},
+ {"name": "X-PostHog-Task-Run-Id", "value": "run-uuid-456"},
]
def test_no_task_id_omits_attribution_header(self) -> None:
@@ -270,6 +276,7 @@ def test_no_task_id_omits_attribution_header(self) -> None:
mock_settings.SITE_URL = "https://app.posthog.com"
configs = get_sandbox_ph_mcp_configs(self.TOKEN, self.PROJECT_ID)
assert all(h["name"] != "X-PostHog-Task-Id" for h in configs[0].headers)
+ assert all(h["name"] != "X-PostHog-Task-Run-Id" for h in configs[0].headers)
@parameterized.expand(
[
diff --git a/products/tasks/backend/temporal/process_task/utils.py b/products/tasks/backend/temporal/process_task/utils.py
index 70849e521b32..b0ada35dd4de 100644
--- a/products/tasks/backend/temporal/process_task/utils.py
+++ b/products/tasks/backend/temporal/process_task/utils.py
@@ -643,12 +643,13 @@ def get_sandbox_ph_mcp_configs(
scopes: PosthogMcpScopes = "read_only",
interaction_origin: str | None = None,
task_id: str | None = None,
+ task_run_id: str | None = None,
) -> list[McpServerConfig]:
"""Return PostHog MCP server configurations for sandbox agents.
- `task_id` is baked into an `X-PostHog-Task-Id` header so the MCP server (and through it the
- PostHog API) can deterministically attribute the agent's writes to its task — the LLM never
- handles its own task id.
+ Task and run IDs are baked into attribution headers so the MCP server (and
+ through it the PostHog API) can deterministically attribute writes without
+ exposing those IDs to the LLM.
Uses SANDBOX_MCP_URL if explicitly set, otherwise derives it from SITE_URL:
- app.posthog.com / us.posthog.com → https://mcp.posthog.com/mcp
@@ -669,6 +670,8 @@ def get_sandbox_ph_mcp_configs(
]
if task_id:
headers.append({"name": "X-PostHog-Task-Id", "value": str(task_id)})
+ if task_run_id:
+ headers.append({"name": "X-PostHog-Task-Run-Id", "value": str(task_run_id)})
return [McpServerConfig(type="http", name="posthog", url=url, headers=headers)]
diff --git a/products/tasks/backend/tests/test_thread_updates.py b/products/tasks/backend/tests/test_thread_updates.py
index 79de4d75874c..2d9eb4c21170 100644
--- a/products/tasks/backend/tests/test_thread_updates.py
+++ b/products/tasks/backend/tests/test_thread_updates.py
@@ -11,6 +11,7 @@
from products.tasks.backend.facade.api import (
list_mentions,
list_thread_messages,
+ post_canvas_build_thread_update,
post_canvas_created_thread_update,
post_pr_created_thread_update,
set_task_run_output,
@@ -233,6 +234,35 @@ def test_canvas_created_skips_when_flag_off(self, _flag) -> None:
self.assertEqual(self._messages(self.task), [])
+ @parameterized.expand(
+ [
+ ("ready", "[Canvas](https://us.posthog.com/canvas) is ready", "canvas_build_ready"),
+ (
+ "failed",
+ "[Canvas](https://us.posthog.com/canvas) could not be built. Open the canvas history to review the errors.",
+ "canvas_build_failed",
+ ),
+ ]
+ )
+ @patch(_FLAG_TARGET, return_value=True)
+ def test_canvas_build_message_content(self, build_status, expected, event, _flag) -> None:
+ post_canvas_build_thread_update(
+ self.task.id,
+ self.team.id,
+ acting_user_id=self.user.id,
+ canvas_name="Canvas",
+ canvas_url="https://us.posthog.com/canvas",
+ build_id="00000000-0000-0000-0000-000000000001",
+ source_version_id="00000000-0000-0000-0000-000000000002",
+ build_status=build_status,
+ )
+
+ messages = self._messages(self.task)
+ self.assertEqual(len(messages), 1)
+ self.assertEqual(messages[0].event, event)
+ self.assertEqual(messages[0].content, expected)
+ self.assertEqual(messages[0].payload["status"], build_status)
+
def test_list_thread_messages_excludes_legacy_turn_complete_rows(self) -> None:
# The thread is human-to-human plus artifacts: rows written back when the
# agent finished a turn (before that writeback was removed) must not
diff --git a/services/mcp/definitions/core.yaml b/services/mcp/definitions/core.yaml
index ca17ab3ef950..d15cfdffb6d0 100644
--- a/services/mcp/definitions/core.yaml
+++ b/services/mcp/definitions/core.yaml
@@ -31,6 +31,86 @@ tools:
add-product-intent-partial-update:
operation: organizations_projects_add_product_intent_partial_update
enabled: false
+ canvas-build-get:
+ operation: desktop_file_system_canvas_builds_retrieve
+ enabled: true
+ scopes:
+ - file_system:read
+ annotations:
+ readOnly: true
+ destructive: false
+ idempotent: true
+ title: Get canvas build
+ description: >
+ Get one authoritative cloud build, including diagnostics, its capability manifest, and the immutable
+ artifact URL when ready.
+ canvas-history-get:
+ operation: desktop_file_system_canvas_history_retrieve
+ enabled: true
+ scopes:
+ - file_system:read
+ annotations:
+ readOnly: true
+ destructive: false
+ idempotent: true
+ title: Get canvas history
+ description: >
+ Get immutable source versions and build attempts for a canvas, including its current source and
+ last-known-good active build.
+ canvas-source-get:
+ operation: desktop_file_system_canvas_source_retrieve
+ enabled: true
+ scopes:
+ - file_system:read
+ annotations:
+ readOnly: true
+ destructive: false
+ idempotent: true
+ title: Get canvas source
+ description: >
+ Get the complete current source project and immutable version metadata for a canvas. Use the returned
+ version ID as expectedCurrentVersionId when publishing an edit.
+ canvas-source-patch:
+ operation: desktop_file_system_canvas_source_partial_update
+ enabled: true
+ scopes:
+ - file_system:write
+ annotations:
+ readOnly: false
+ destructive: false
+ idempotent: false
+ title: Patch canvas source
+ description: >
+ Atomically apply file and asset changes to the source version read by the current task run, then queue an
+ authoritative cloud build. Prefer this over replacing the complete project for focused edits. The expected
+ current version is mandatory and stale patches return a conflict without changing source history.
+ canvas-source-publish:
+ operation: desktop_file_system_canvas_source_create
+ enabled: true
+ scopes:
+ - file_system:write
+ annotations:
+ readOnly: false
+ destructive: false
+ idempotent: false
+ title: Publish canvas source
+ description: >
+ Atomically publish a complete canvas source project from the current task run. This creates an immutable
+ source version and queues an authoritative cloud build. Always pass the version ID read before editing as
+ expectedCurrentVersionId so concurrent changes return a conflict instead of being overwritten.
+ canvas-source-validate:
+ operation: desktop_file_system_canvas_validate_create
+ enabled: true
+ scopes:
+ - file_system:read
+ annotations:
+ readOnly: true
+ destructive: false
+ idempotent: true
+ title: Validate canvas source
+ description: >
+ Compile and validate a complete candidate canvas source project with the authoritative pinned build recipe.
+ Returns structured diagnostics and a manifest without publishing source or changing the active canvas.
change-organization-create:
operation: organizations_projects_change_organization_create
enabled: false
diff --git a/services/mcp/schema/generated-tool-definitions.json b/services/mcp/schema/generated-tool-definitions.json
index 6810bb13ad09..2f2fb18700fb 100644
--- a/services/mcp/schema/generated-tool-definitions.json
+++ b/services/mcp/schema/generated-tool-definitions.json
@@ -1506,6 +1506,90 @@
},
"feature_flag": "product-business-knowledge"
},
+ "canvas-build-get": {
+ "description": "Get one authoritative cloud build, including diagnostics, its capability manifest, and the immutable artifact URL when ready.",
+ "category": "Core",
+ "feature": "core",
+ "summary": "Get canvas build",
+ "title": "Get canvas build",
+ "required_scopes": ["file_system:read"],
+ "annotations": {
+ "destructiveHint": false,
+ "idempotentHint": true,
+ "openWorldHint": true,
+ "readOnlyHint": true
+ }
+ },
+ "canvas-history-get": {
+ "description": "Get immutable source versions and build attempts for a canvas, including its current source and last-known-good active build.",
+ "category": "Core",
+ "feature": "core",
+ "summary": "Get canvas history",
+ "title": "Get canvas history",
+ "required_scopes": ["file_system:read"],
+ "annotations": {
+ "destructiveHint": false,
+ "idempotentHint": true,
+ "openWorldHint": true,
+ "readOnlyHint": true
+ }
+ },
+ "canvas-source-get": {
+ "description": "Get the complete current source project and immutable version metadata for a canvas. Use the returned version ID as expectedCurrentVersionId when publishing an edit.",
+ "category": "Core",
+ "feature": "core",
+ "summary": "Get canvas source",
+ "title": "Get canvas source",
+ "required_scopes": ["file_system:read"],
+ "annotations": {
+ "destructiveHint": false,
+ "idempotentHint": true,
+ "openWorldHint": true,
+ "readOnlyHint": true
+ }
+ },
+ "canvas-source-patch": {
+ "description": "Atomically apply file and asset changes to the source version read by the current task run, then queue an authoritative cloud build. Prefer this over replacing the complete project for focused edits. The expected current version is mandatory and stale patches return a conflict without changing source history.",
+ "category": "Core",
+ "feature": "core",
+ "summary": "Patch canvas source",
+ "title": "Patch canvas source",
+ "required_scopes": ["file_system:write"],
+ "annotations": {
+ "destructiveHint": false,
+ "idempotentHint": false,
+ "openWorldHint": true,
+ "readOnlyHint": false
+ }
+ },
+ "canvas-source-publish": {
+ "description": "Atomically publish a complete canvas source project from the current task run. This creates an immutable source version and queues an authoritative cloud build. Always pass the version ID read before editing as expectedCurrentVersionId so concurrent changes return a conflict instead of being overwritten.",
+ "category": "Core",
+ "feature": "core",
+ "summary": "Publish canvas source",
+ "title": "Publish canvas source",
+ "required_scopes": ["file_system:write"],
+ "annotations": {
+ "destructiveHint": false,
+ "idempotentHint": false,
+ "openWorldHint": true,
+ "readOnlyHint": false
+ }
+ },
+ "canvas-source-validate": {
+ "description": "Compile and validate a complete candidate canvas source project with the authoritative pinned build recipe. Returns structured diagnostics and a manifest without publishing source or changing the active canvas.",
+ "category": "Core",
+ "feature": "core",
+ "summary": "Validate canvas source",
+ "title": "Validate canvas source",
+ "required_scopes": ["file_system:read"],
+ "annotations": {
+ "destructiveHint": false,
+ "idempotentHint": true,
+ "openWorldHint": true,
+ "readOnlyHint": true
+ }
+ },
"cdp-function-templates-list": {
"description": "List available function templates. Templates are pre-built function configurations for common integrations (Slack, webhooks, email, etc.) and transformations (GeoIP, etc.). Filter by type (destination, site_destination, site_app, transformation, etc.) via the 'type' query parameter. Results are sorted by popularity (number of active functions using each template).",
"category": "Function templates",
diff --git a/services/mcp/schema/tool-definitions-all.json b/services/mcp/schema/tool-definitions-all.json
index e656ea88b0d5..7d815cac6e97 100644
--- a/services/mcp/schema/tool-definitions-all.json
+++ b/services/mcp/schema/tool-definitions-all.json
@@ -1535,6 +1535,90 @@
},
"feature_flag": "product-business-knowledge"
},
+ "canvas-build-get": {
+ "description": "Get one authoritative cloud build, including diagnostics, its capability manifest, and the immutable artifact URL when ready.",
+ "category": "Core",
+ "feature": "core",
+ "summary": "Get canvas build",
+ "title": "Get canvas build",
+ "required_scopes": ["file_system:read"],
+ "annotations": {
+ "destructiveHint": false,
+ "idempotentHint": true,
+ "openWorldHint": true,
+ "readOnlyHint": true
+ }
+ },
+ "canvas-history-get": {
+ "description": "Get immutable source versions and build attempts for a canvas, including its current source and last-known-good active build.",
+ "category": "Core",
+ "feature": "core",
+ "summary": "Get canvas history",
+ "title": "Get canvas history",
+ "required_scopes": ["file_system:read"],
+ "annotations": {
+ "destructiveHint": false,
+ "idempotentHint": true,
+ "openWorldHint": true,
+ "readOnlyHint": true
+ }
+ },
+ "canvas-source-get": {
+ "description": "Get the complete current source project and immutable version metadata for a canvas. Use the returned version ID as expectedCurrentVersionId when publishing an edit.",
+ "category": "Core",
+ "feature": "core",
+ "summary": "Get canvas source",
+ "title": "Get canvas source",
+ "required_scopes": ["file_system:read"],
+ "annotations": {
+ "destructiveHint": false,
+ "idempotentHint": true,
+ "openWorldHint": true,
+ "readOnlyHint": true
+ }
+ },
+ "canvas-source-patch": {
+ "description": "Atomically apply file and asset changes to the source version read by the current task run, then queue an authoritative cloud build. Prefer this over replacing the complete project for focused edits. The expected current version is mandatory and stale patches return a conflict without changing source history.",
+ "category": "Core",
+ "feature": "core",
+ "summary": "Patch canvas source",
+ "title": "Patch canvas source",
+ "required_scopes": ["file_system:write"],
+ "annotations": {
+ "destructiveHint": false,
+ "idempotentHint": false,
+ "openWorldHint": true,
+ "readOnlyHint": false
+ }
+ },
+ "canvas-source-publish": {
+ "description": "Atomically publish a complete canvas source project from the current task run. This creates an immutable source version and queues an authoritative cloud build. Always pass the version ID read before editing as expectedCurrentVersionId so concurrent changes return a conflict instead of being overwritten.",
+ "category": "Core",
+ "feature": "core",
+ "summary": "Publish canvas source",
+ "title": "Publish canvas source",
+ "required_scopes": ["file_system:write"],
+ "annotations": {
+ "destructiveHint": false,
+ "idempotentHint": false,
+ "openWorldHint": true,
+ "readOnlyHint": false
+ }
+ },
+ "canvas-source-validate": {
+ "description": "Compile and validate a complete candidate canvas source project with the authoritative pinned build recipe. Returns structured diagnostics and a manifest without publishing source or changing the active canvas.",
+ "category": "Core",
+ "feature": "core",
+ "summary": "Validate canvas source",
+ "title": "Validate canvas source",
+ "required_scopes": ["file_system:read"],
+ "annotations": {
+ "destructiveHint": false,
+ "idempotentHint": true,
+ "openWorldHint": true,
+ "readOnlyHint": true
+ }
+ },
"cdp-function-templates-list": {
"description": "List available function templates. Templates are pre-built function configurations for common integrations (Slack, webhooks, email, etc.) and transformations (GeoIP, etc.). Filter by type (destination, site_destination, site_app, transformation, etc.) via the 'type' query parameter. Results are sorted by popularity (number of active functions using each template).",
"category": "Function templates",
diff --git a/services/mcp/src/api/client.ts b/services/mcp/src/api/client.ts
index 0090b4c43bb1..b53394a92867 100644
--- a/services/mcp/src/api/client.ts
+++ b/services/mcp/src/api/client.ts
@@ -128,11 +128,10 @@ export interface ApiConfig {
mcpSessionId?: string | undefined
mcpConversationId?: string | undefined
/**
- * Sandbox-provisioned task id (from the inbound `x-posthog-task-id` MCP header). Forwarded
- * to the PostHog API as `X-PostHog-Task-Id` on every call so writes can be attributed to
- * the agent's task; the API validates it against the token's team.
+ * Sandbox-provisioned task attribution forwarded to the PostHog API.
*/
taskId?: string | undefined
+ taskRunId?: string | undefined
}
type Endpoint = Record
@@ -189,6 +188,7 @@ export class ApiClient {
: {}),
// Forward the sandbox task id so API writes are attributed to the agent's task.
...(this.config.taskId ? { 'X-PostHog-Task-Id': this.config.taskId } : {}),
+ ...(this.config.taskRunId ? { 'X-PostHog-Task-Run-Id': this.config.taskRunId } : {}),
'X-PostHog-Client': 'mcp',
}
if (options?.body) {
diff --git a/services/mcp/src/api/generated.ts b/services/mcp/src/api/generated.ts
index b3749a69ea51..31d9a1d7ecf3 100644
--- a/services/mcp/src/api/generated.ts
+++ b/services/mcp/src/api/generated.ts
@@ -13574,6 +13574,276 @@ export namespace Schemas {
created: string | null;
}
+ export interface CanvasApplicationConflict {
+ /** Always "version_conflict". */
+ readonly code: string;
+ /** How to recover from the conflicting edit. */
+ readonly detail: string;
+ /**
+ * Current source version that rejected the stale edit.
+ * @nullable
+ */
+ readonly currentVersionId: string | null;
+ }
+
+ export interface CanvasArtifactFile {
+ /** Normalized artifact path relative to this build. */
+ readonly path: string;
+ /** HTTP content type for this artifact file. */
+ readonly contentType: string;
+ /**
+ * UTF-8 artifact size in bytes.
+ * @minimum 0
+ */
+ readonly bytes: number;
+ /**
+ * Lowercase SHA-256 digest of the artifact content.
+ * @pattern ^[a-f0-9]{64}$
+ */
+ readonly sha256: string;
+ }
+
+ /**
+ * Exact package versions included in this build.
+ */
+ export type CanvasArtifactManifestDependencies = {[key: string]: string};
+
+ export interface CanvasPostHogCapabilities {
+ /**
+ * Insight short IDs that this canvas may load.
+ * @maxItems 256
+ * @items.minLength 1
+ * @items.maxLength 128
+ */
+ insights: string[];
+ /** Whether this canvas may execute inline PostHog queries. */
+ inlineQueries: boolean;
+ /**
+ * Event names that this canvas may capture.
+ * @maxItems 256
+ * @items.minLength 1
+ * @items.maxLength 200
+ */
+ captureEvents: string[];
+ }
+
+ export interface CanvasNetworkCapabilities {
+ /**
+ * HTTPS origins that the canvas may contact directly.
+ * @maxItems 64
+ * @items.maxLength 2048
+ */
+ origins: string[];
+ }
+
+ export interface CanvasCapabilities {
+ /** PostHog data and capture capabilities. */
+ posthog: CanvasPostHogCapabilities;
+ /** Direct network capabilities. */
+ network: CanvasNetworkCapabilities;
+ }
+
+ export interface CanvasArtifactManifest {
+ /** Artifact manifest schema version. */
+ readonly schemaVersion: number;
+ /** HTML entry file for this artifact. */
+ readonly entryHtml: string;
+ /** Immutable emitted artifact files. */
+ readonly files: readonly CanvasArtifactFile[];
+ /** Canvas runtime SDK version used by this build. */
+ readonly canvasSdkVersion: string;
+ /** Exact package versions included in this build. */
+ readonly dependencies: CanvasArtifactManifestDependencies;
+ /** Capabilities enforced for this artifact. */
+ readonly capabilities: CanvasCapabilities;
+ }
+
+ /**
+ * * `base64` - base64
+ */
+ export type EncodingEnum = typeof EncodingEnum[keyof typeof EncodingEnum];
+
+
+ export const EncodingEnum = {
+ Base64: 'base64',
+ } as const;
+
+ /**
+ * * `application/wasm` - application/wasm
+ * * `application/octet-stream` - application/octet-stream
+ * * `font/otf` - font/otf
+ * * `font/ttf` - font/ttf
+ * * `font/woff` - font/woff
+ * * `font/woff2` - font/woff2
+ * * `image/avif` - image/avif
+ * * `image/gif` - image/gif
+ * * `image/jpeg` - image/jpeg
+ * * `image/png` - image/png
+ * * `image/svg+xml` - image/svg+xml
+ * * `image/webp` - image/webp
+ */
+ export type ContentTypeEnum = typeof ContentTypeEnum[keyof typeof ContentTypeEnum];
+
+
+ export const ContentTypeEnum = {
+ ApplicationWasm: 'application/wasm',
+ ApplicationOctetStream: 'application/octet-stream',
+ FontOtf: 'font/otf',
+ FontTtf: 'font/ttf',
+ FontWoff: 'font/woff',
+ FontWoff2: 'font/woff2',
+ ImageAvif: 'image/avif',
+ ImageGif: 'image/gif',
+ ImageJpeg: 'image/jpeg',
+ ImagePng: 'image/png',
+ ImageSvgXml: 'image/svg+xml',
+ ImageWebp: 'image/webp',
+ } as const;
+
+ export interface CanvasAsset {
+ encoding: EncodingEnum;
+ contentType: ContentTypeEnum;
+ content: string;
+ }
+
+ /**
+ * * `queued` - Queued
+ * * `building` - Building
+ * * `ready` - Ready
+ * * `failed` - Failed
+ */
+ export type CanvasBuildStatusEnum = typeof CanvasBuildStatusEnum[keyof typeof CanvasBuildStatusEnum];
+
+
+ export const CanvasBuildStatusEnum = {
+ Queued: 'queued',
+ Building: 'building',
+ Ready: 'ready',
+ Failed: 'failed',
+ } as const;
+
+ /**
+ * * `error` - error
+ * * `warning` - warning
+ * * `info` - info
+ */
+ export type IngestionWarningSeverityEnum = typeof IngestionWarningSeverityEnum[keyof typeof IngestionWarningSeverityEnum];
+
+
+ export const IngestionWarningSeverityEnum = {
+ Error: 'error',
+ Warning: 'warning',
+ Info: 'info',
+ } as const;
+
+ export interface CanvasDiagnostic {
+ /** Diagnostic severity.
+ *
+ * * `error` - error
+ * * `warning` - warning
+ * * `info` - info */
+ severity: IngestionWarningSeverityEnum;
+ /**
+ * Stable diagnostic code.
+ * @maxLength 100
+ */
+ code: string;
+ /**
+ * Build diagnostic message.
+ * @maxLength 10000
+ */
+ message: string;
+ /** Project-relative source file. */
+ file?: string;
+ /**
+ * One-based source line.
+ * @minimum 1
+ */
+ line?: number;
+ /**
+ * Zero-based source column.
+ * @minimum 0
+ */
+ column?: number;
+ }
+
+ export interface CanvasBuild {
+ /** Immutable cloud build ID. */
+ readonly id: string;
+ /** Source version compiled by this build. */
+ readonly sourceVersionId: string;
+ /** Current build lifecycle status.
+ *
+ * * `queued` - Queued
+ * * `building` - Building
+ * * `ready` - Ready
+ * * `failed` - Failed */
+ readonly status: CanvasBuildStatusEnum;
+ /**
+ * Short-lived URL for the immutable artifact entry HTML.
+ * @nullable
+ */
+ readonly artifactUrl: string | null;
+ /**
+ * SHA-256 integrity value for entry HTML.
+ * @nullable
+ */
+ readonly integrity: string | null;
+ /** Bounded build diagnostics. */
+ readonly diagnostics: readonly CanvasDiagnostic[];
+ /** Immutable artifact and capability manifest when ready. */
+ readonly manifest: CanvasArtifactManifest | null;
+ /** Build creation time as Unix milliseconds. */
+ readonly createdAt: number;
+ /**
+ * Build completion time as Unix milliseconds, if complete.
+ * @nullable
+ */
+ readonly completedAt: number | null;
+ }
+
+ export interface CanvasSourceVersion {
+ /** Immutable source version ID. */
+ readonly id: string;
+ /**
+ * Source version edited to create this version.
+ * @nullable
+ */
+ readonly parentVersionId: string | null;
+ /** Task that produced this version. */
+ readonly taskId: string;
+ /** Fresh task run that produced this version. */
+ readonly taskRunId: string;
+ /** Canonical source SHA-256 digest. */
+ readonly sourceHash: string;
+ /** Canonical source size in bytes. */
+ readonly sourceSize: number;
+ /**
+ * Description of the requested canvas change.
+ * @nullable
+ */
+ readonly prompt: string | null;
+ /** Creation time as Unix milliseconds. */
+ readonly createdAt: number;
+ }
+
+ export interface CanvasHistory {
+ /**
+ * Current source version for this canvas.
+ * @nullable
+ */
+ readonly currentSourceVersionId: string | null;
+ /**
+ * Last-known-good build currently displayed by this canvas.
+ * @nullable
+ */
+ readonly activeBuildId: string | null;
+ /** Source versions in creation order. */
+ readonly versions: readonly CanvasSourceVersion[];
+ /** Build attempts in creation order. */
+ readonly builds: readonly CanvasBuild[];
+ }
+
/**
* 409 body for a guarded canvas publish based on a stale version.
*/
@@ -13589,6 +13859,101 @@ export namespace Schemas {
current_version_id: string | null;
}
+ /**
+ * Complete map of normalized project-relative paths to UTF-8 source files.
+ */
+ export type CanvasSourceProjectFiles = {[key: string]: string};
+
+ /**
+ * Binary assets mapped by normalized project-relative path.
+ */
+ export type CanvasSourceProjectAssets = {[key: string]: CanvasAsset};
+
+ /**
+ * Browser package names mapped to exact admitted semantic versions.
+ */
+ export type CanvasSourceProjectDependencies = {[key: string]: string};
+
+ export interface CanvasSourceProject {
+ /**
+ * Canvas source schema version.
+ * @minimum 1
+ * @maximum 1
+ */
+ schemaVersion: number;
+ /** Complete map of normalized project-relative paths to UTF-8 source files. */
+ files: CanvasSourceProjectFiles;
+ /** Binary assets mapped by normalized project-relative path. */
+ assets?: CanvasSourceProjectAssets;
+ /** HTML entry file. Must be "index.html". */
+ entryHtml: string;
+ /** Browser package names mapped to exact admitted semantic versions. */
+ dependencies: CanvasSourceProjectDependencies;
+ /** Exact canvas runtime SDK version. */
+ canvasSdkVersion: string;
+ /** Capabilities enforced by the build and runtime. */
+ capabilities: CanvasCapabilities;
+ }
+
+ export interface CanvasPublishRequest {
+ /** Complete canvas source project to publish. */
+ project: CanvasSourceProject;
+ /**
+ * Current source version that this edit is based on. Pass null for the first version.
+ * @nullable
+ */
+ expectedCurrentVersionId: string | null;
+ /** Task that produced this source version. Sandbox tasks are attributed automatically. */
+ taskId?: string;
+ /** Fresh task run that produced this source version. Sandbox tasks are attributed automatically. */
+ taskRunId?: string;
+ /**
+ * Short description of the requested canvas change.
+ * @maxLength 10000
+ */
+ prompt?: string;
+ }
+
+ export interface CanvasPublishResponse {
+ /** Published immutable source version metadata. */
+ readonly version: CanvasSourceVersion;
+ /** Queued authoritative cloud build. */
+ readonly build: CanvasBuild;
+ }
+
+ export type CanvasSourcePatchUpsertFiles = {[key: string]: string};
+
+ export type CanvasSourcePatchUpsertAssets = {[key: string]: CanvasAsset};
+
+ export type CanvasSourcePatchDependencies = {[key: string]: string};
+
+ export interface CanvasSourcePatch {
+ upsertFiles?: CanvasSourcePatchUpsertFiles;
+ /** @maxItems 128 */
+ deleteFiles?: string[];
+ upsertAssets?: CanvasSourcePatchUpsertAssets;
+ /** @maxItems 128 */
+ deleteAssets?: string[];
+ dependencies?: CanvasSourcePatchDependencies;
+ capabilities?: CanvasCapabilities;
+ }
+
+ export interface CanvasSourceSnapshot {
+ /** Current immutable source version metadata. */
+ readonly version: CanvasSourceVersion;
+ /** Complete current source project. */
+ readonly project: CanvasSourceProject;
+ }
+
+ export interface CanvasValidationResponse {
+ /** Whether the candidate produced a valid artifact. */
+ readonly ok: boolean;
+ /** Structured validation diagnostics. */
+ readonly diagnostics: readonly CanvasDiagnostic[];
+ /** Validated candidate manifest when successful. */
+ readonly manifest: CanvasArtifactManifest | null;
+ }
+
/**
* Supporting evidence
*/
@@ -46724,6 +47089,17 @@ export namespace Schemas {
readonly updated_at?: string | null;
}
+ export interface PatchedCanvasPatchPublishRequest {
+ /** File, asset, dependency, and capability changes to apply. */
+ patch?: CanvasSourcePatch;
+ /** Current source version that this patch is based on. */
+ expectedCurrentVersionId?: string;
+ taskId?: string;
+ taskRunId?: string;
+ /** @maxLength 10000 */
+ prompt?: string;
+ }
+
/**
* Payload for publishing a freeform canvas's React source via the agent.
*/
diff --git a/services/mcp/src/generated/core/api.ts b/services/mcp/src/generated/core/api.ts
index ea89d8b7edf0..220c896cc09a 100644
--- a/services/mcp/src/generated/core/api.ts
+++ b/services/mcp/src/generated/core/api.ts
@@ -3,7 +3,7 @@
* MCP service uses these Zod schemas for generated tool handlers.
* To regenerate: hogli build:openapi
*
- * PostHog API - MCP 10 enabled ops
+ * PostHog API - MCP 16 enabled ops
* OpenAPI spec version: 1.0.0
*/
import * as zod from 'zod'
@@ -747,6 +747,450 @@ export const DesktopFileSystemCanvasPartialUpdateBody = /* @__PURE__ */ zod
})
.describe("Payload for publishing a freeform canvas's React source via the agent.")
+/**
+ * The file tree for the desktop product surface. Reuses all FileSystemViewSet behaviour but is
+ * scoped to the "desktop" surface, so its tree is fully isolated from the default "web" tree.
+ *
+ * Adds per-folder, versioned markdown instructions describing the contents of a folder.
+ */
+export const DesktopFileSystemCanvasBuildsRetrieveParams = /* @__PURE__ */ zod.object({
+ build_id: zod.string().describe('Immutable canvas build ID.'),
+ id: zod.string().describe('A UUID string identifying this file system.'),
+ project_id: zod
+ .string()
+ .describe(
+ "Project ID of the project you're trying to access. To find the ID of the project, make a call to \/api\/projects\/."
+ ),
+})
+
+/**
+ * The file tree for the desktop product surface. Reuses all FileSystemViewSet behaviour but is
+ * scoped to the "desktop" surface, so its tree is fully isolated from the default "web" tree.
+ *
+ * Adds per-folder, versioned markdown instructions describing the contents of a folder.
+ */
+export const DesktopFileSystemCanvasHistoryRetrieveParams = /* @__PURE__ */ zod.object({
+ id: zod.string().describe('A UUID string identifying this file system.'),
+ project_id: zod
+ .string()
+ .describe(
+ "Project ID of the project you're trying to access. To find the ID of the project, make a call to \/api\/projects\/."
+ ),
+})
+
+/**
+ * The file tree for the desktop product surface. Reuses all FileSystemViewSet behaviour but is
+ * scoped to the "desktop" surface, so its tree is fully isolated from the default "web" tree.
+ *
+ * Adds per-folder, versioned markdown instructions describing the contents of a folder.
+ */
+export const DesktopFileSystemCanvasSourceRetrieveParams = /* @__PURE__ */ zod.object({
+ id: zod.string().describe('A UUID string identifying this file system.'),
+ project_id: zod
+ .string()
+ .describe(
+ "Project ID of the project you're trying to access. To find the ID of the project, make a call to \/api\/projects\/."
+ ),
+})
+
+/**
+ * The file tree for the desktop product surface. Reuses all FileSystemViewSet behaviour but is
+ * scoped to the "desktop" surface, so its tree is fully isolated from the default "web" tree.
+ *
+ * Adds per-folder, versioned markdown instructions describing the contents of a folder.
+ */
+export const DesktopFileSystemCanvasSourceCreateParams = /* @__PURE__ */ zod.object({
+ id: zod.string().describe('A UUID string identifying this file system.'),
+ project_id: zod
+ .string()
+ .describe(
+ "Project ID of the project you're trying to access. To find the ID of the project, make a call to \/api\/projects\/."
+ ),
+})
+
+export const desktopFileSystemCanvasSourceCreateBodyProjectOneSchemaVersionMax = 1
+
+export const desktopFileSystemCanvasSourceCreateBodyProjectOneCapabilitiesOnePosthogOneInsightsItemMax = 128
+
+export const desktopFileSystemCanvasSourceCreateBodyProjectOneCapabilitiesOnePosthogOneInsightsMax = 256
+
+export const desktopFileSystemCanvasSourceCreateBodyProjectOneCapabilitiesOnePosthogOneCaptureEventsItemMax = 200
+
+export const desktopFileSystemCanvasSourceCreateBodyProjectOneCapabilitiesOnePosthogOneCaptureEventsMax = 256
+
+export const desktopFileSystemCanvasSourceCreateBodyProjectOneCapabilitiesOneNetworkOneOriginsItemMax = 2048
+
+export const desktopFileSystemCanvasSourceCreateBodyProjectOneCapabilitiesOneNetworkOneOriginsMax = 64
+
+export const desktopFileSystemCanvasSourceCreateBodyPromptMax = 10000
+
+export const DesktopFileSystemCanvasSourceCreateBody = /* @__PURE__ */ zod.object({
+ project: zod
+ .object({
+ schemaVersion: zod
+ .number()
+ .min(1)
+ .max(desktopFileSystemCanvasSourceCreateBodyProjectOneSchemaVersionMax)
+ .describe('Canvas source schema version.'),
+ files: zod
+ .record(zod.string(), zod.string())
+ .describe('Complete map of normalized project-relative paths to UTF-8 source files.'),
+ assets: zod
+ .record(
+ zod.string(),
+ zod.object({
+ encoding: zod.enum(['base64']).describe('\* `base64` - base64'),
+ contentType: zod
+ .enum([
+ 'application/wasm',
+ 'application/octet-stream',
+ 'font/otf',
+ 'font/ttf',
+ 'font/woff',
+ 'font/woff2',
+ 'image/avif',
+ 'image/gif',
+ 'image/jpeg',
+ 'image/png',
+ 'image/svg+xml',
+ 'image/webp',
+ ])
+ .describe(
+ '\* `application\/wasm` - application\/wasm\n\* `application\/octet-stream` - application\/octet-stream\n\* `font\/otf` - font\/otf\n\* `font\/ttf` - font\/ttf\n\* `font\/woff` - font\/woff\n\* `font\/woff2` - font\/woff2\n\* `image\/avif` - image\/avif\n\* `image\/gif` - image\/gif\n\* `image\/jpeg` - image\/jpeg\n\* `image\/png` - image\/png\n\* `image\/svg+xml` - image\/svg+xml\n\* `image\/webp` - image\/webp'
+ ),
+ content: zod.string(),
+ })
+ )
+ .optional()
+ .describe('Binary assets mapped by normalized project-relative path.'),
+ entryHtml: zod.string().describe('HTML entry file. Must be \"index.html\".'),
+ dependencies: zod
+ .record(zod.string(), zod.string())
+ .describe('Browser package names mapped to exact admitted semantic versions.'),
+ canvasSdkVersion: zod.string().describe('Exact canvas runtime SDK version.'),
+ capabilities: zod
+ .object({
+ posthog: zod
+ .object({
+ insights: zod
+ .array(
+ zod
+ .string()
+ .min(1)
+ .max(
+ desktopFileSystemCanvasSourceCreateBodyProjectOneCapabilitiesOnePosthogOneInsightsItemMax
+ )
+ )
+ .max(
+ desktopFileSystemCanvasSourceCreateBodyProjectOneCapabilitiesOnePosthogOneInsightsMax
+ )
+ .describe('Insight short IDs that this canvas may load.'),
+ inlineQueries: zod
+ .boolean()
+ .describe('Whether this canvas may execute inline PostHog queries.'),
+ captureEvents: zod
+ .array(
+ zod
+ .string()
+ .min(1)
+ .max(
+ desktopFileSystemCanvasSourceCreateBodyProjectOneCapabilitiesOnePosthogOneCaptureEventsItemMax
+ )
+ )
+ .max(
+ desktopFileSystemCanvasSourceCreateBodyProjectOneCapabilitiesOnePosthogOneCaptureEventsMax
+ )
+ .describe('Event names that this canvas may capture.'),
+ })
+ .describe('PostHog data and capture capabilities.'),
+ network: zod
+ .object({
+ origins: zod
+ .array(
+ zod
+ .string()
+ .max(
+ desktopFileSystemCanvasSourceCreateBodyProjectOneCapabilitiesOneNetworkOneOriginsItemMax
+ )
+ )
+ .max(
+ desktopFileSystemCanvasSourceCreateBodyProjectOneCapabilitiesOneNetworkOneOriginsMax
+ )
+ .describe('HTTPS origins that the canvas may contact directly.'),
+ })
+ .describe('Direct network capabilities.'),
+ })
+ .describe('Capabilities enforced by the build and runtime.'),
+ })
+ .describe('Complete canvas source project to publish.'),
+ expectedCurrentVersionId: zod
+ .string()
+ .nullable()
+ .describe('Current source version that this edit is based on. Pass null for the first version.'),
+ taskId: zod
+ .string()
+ .optional()
+ .describe('Task that produced this source version. Sandbox tasks are attributed automatically.'),
+ taskRunId: zod
+ .string()
+ .optional()
+ .describe('Fresh task run that produced this source version. Sandbox tasks are attributed automatically.'),
+ prompt: zod
+ .string()
+ .max(desktopFileSystemCanvasSourceCreateBodyPromptMax)
+ .optional()
+ .describe('Short description of the requested canvas change.'),
+})
+
+/**
+ * The file tree for the desktop product surface. Reuses all FileSystemViewSet behaviour but is
+ * scoped to the "desktop" surface, so its tree is fully isolated from the default "web" tree.
+ *
+ * Adds per-folder, versioned markdown instructions describing the contents of a folder.
+ */
+export const DesktopFileSystemCanvasSourcePartialUpdateParams = /* @__PURE__ */ zod.object({
+ id: zod.string().describe('A UUID string identifying this file system.'),
+ project_id: zod
+ .string()
+ .describe(
+ "Project ID of the project you're trying to access. To find the ID of the project, make a call to \/api\/projects\/."
+ ),
+})
+
+export const desktopFileSystemCanvasSourcePartialUpdateBodyPatchOneDeleteFilesMax = 128
+
+export const desktopFileSystemCanvasSourcePartialUpdateBodyPatchOneDeleteAssetsMax = 128
+
+export const desktopFileSystemCanvasSourcePartialUpdateBodyPatchOneCapabilitiesPosthogOneInsightsItemMax = 128
+
+export const desktopFileSystemCanvasSourcePartialUpdateBodyPatchOneCapabilitiesPosthogOneInsightsMax = 256
+
+export const desktopFileSystemCanvasSourcePartialUpdateBodyPatchOneCapabilitiesPosthogOneCaptureEventsItemMax = 200
+
+export const desktopFileSystemCanvasSourcePartialUpdateBodyPatchOneCapabilitiesPosthogOneCaptureEventsMax = 256
+
+export const desktopFileSystemCanvasSourcePartialUpdateBodyPatchOneCapabilitiesNetworkOneOriginsItemMax = 2048
+
+export const desktopFileSystemCanvasSourcePartialUpdateBodyPatchOneCapabilitiesNetworkOneOriginsMax = 64
+
+export const desktopFileSystemCanvasSourcePartialUpdateBodyPromptMax = 10000
+
+export const DesktopFileSystemCanvasSourcePartialUpdateBody = /* @__PURE__ */ zod.object({
+ patch: zod
+ .object({
+ upsertFiles: zod.record(zod.string(), zod.string()).optional(),
+ deleteFiles: zod
+ .array(zod.string())
+ .max(desktopFileSystemCanvasSourcePartialUpdateBodyPatchOneDeleteFilesMax)
+ .optional(),
+ upsertAssets: zod
+ .record(
+ zod.string(),
+ zod.object({
+ encoding: zod.enum(['base64']).describe('\* `base64` - base64'),
+ contentType: zod
+ .enum([
+ 'application/wasm',
+ 'application/octet-stream',
+ 'font/otf',
+ 'font/ttf',
+ 'font/woff',
+ 'font/woff2',
+ 'image/avif',
+ 'image/gif',
+ 'image/jpeg',
+ 'image/png',
+ 'image/svg+xml',
+ 'image/webp',
+ ])
+ .describe(
+ '\* `application\/wasm` - application\/wasm\n\* `application\/octet-stream` - application\/octet-stream\n\* `font\/otf` - font\/otf\n\* `font\/ttf` - font\/ttf\n\* `font\/woff` - font\/woff\n\* `font\/woff2` - font\/woff2\n\* `image\/avif` - image\/avif\n\* `image\/gif` - image\/gif\n\* `image\/jpeg` - image\/jpeg\n\* `image\/png` - image\/png\n\* `image\/svg+xml` - image\/svg+xml\n\* `image\/webp` - image\/webp'
+ ),
+ content: zod.string(),
+ })
+ )
+ .optional(),
+ deleteAssets: zod
+ .array(zod.string())
+ .max(desktopFileSystemCanvasSourcePartialUpdateBodyPatchOneDeleteAssetsMax)
+ .optional(),
+ dependencies: zod.record(zod.string(), zod.string()).optional(),
+ capabilities: zod
+ .object({
+ posthog: zod
+ .object({
+ insights: zod
+ .array(
+ zod
+ .string()
+ .min(1)
+ .max(
+ desktopFileSystemCanvasSourcePartialUpdateBodyPatchOneCapabilitiesPosthogOneInsightsItemMax
+ )
+ )
+ .max(
+ desktopFileSystemCanvasSourcePartialUpdateBodyPatchOneCapabilitiesPosthogOneInsightsMax
+ )
+ .describe('Insight short IDs that this canvas may load.'),
+ inlineQueries: zod
+ .boolean()
+ .describe('Whether this canvas may execute inline PostHog queries.'),
+ captureEvents: zod
+ .array(
+ zod
+ .string()
+ .min(1)
+ .max(
+ desktopFileSystemCanvasSourcePartialUpdateBodyPatchOneCapabilitiesPosthogOneCaptureEventsItemMax
+ )
+ )
+ .max(
+ desktopFileSystemCanvasSourcePartialUpdateBodyPatchOneCapabilitiesPosthogOneCaptureEventsMax
+ )
+ .describe('Event names that this canvas may capture.'),
+ })
+ .describe('PostHog data and capture capabilities.'),
+ network: zod
+ .object({
+ origins: zod
+ .array(
+ zod
+ .string()
+ .max(
+ desktopFileSystemCanvasSourcePartialUpdateBodyPatchOneCapabilitiesNetworkOneOriginsItemMax
+ )
+ )
+ .max(
+ desktopFileSystemCanvasSourcePartialUpdateBodyPatchOneCapabilitiesNetworkOneOriginsMax
+ )
+ .describe('HTTPS origins that the canvas may contact directly.'),
+ })
+ .describe('Direct network capabilities.'),
+ })
+ .optional(),
+ })
+ .optional()
+ .describe('File, asset, dependency, and capability changes to apply.'),
+ expectedCurrentVersionId: zod.string().optional().describe('Current source version that this patch is based on.'),
+ taskId: zod.string().optional(),
+ taskRunId: zod.string().optional(),
+ prompt: zod.string().max(desktopFileSystemCanvasSourcePartialUpdateBodyPromptMax).optional(),
+})
+
+/**
+ * The file tree for the desktop product surface. Reuses all FileSystemViewSet behaviour but is
+ * scoped to the "desktop" surface, so its tree is fully isolated from the default "web" tree.
+ *
+ * Adds per-folder, versioned markdown instructions describing the contents of a folder.
+ */
+export const DesktopFileSystemCanvasValidateCreateParams = /* @__PURE__ */ zod.object({
+ id: zod.string().describe('A UUID string identifying this file system.'),
+ project_id: zod
+ .string()
+ .describe(
+ "Project ID of the project you're trying to access. To find the ID of the project, make a call to \/api\/projects\/."
+ ),
+})
+
+export const desktopFileSystemCanvasValidateCreateBodySchemaVersionMax = 1
+
+export const desktopFileSystemCanvasValidateCreateBodyCapabilitiesOnePosthogOneInsightsItemMax = 128
+
+export const desktopFileSystemCanvasValidateCreateBodyCapabilitiesOnePosthogOneInsightsMax = 256
+
+export const desktopFileSystemCanvasValidateCreateBodyCapabilitiesOnePosthogOneCaptureEventsItemMax = 200
+
+export const desktopFileSystemCanvasValidateCreateBodyCapabilitiesOnePosthogOneCaptureEventsMax = 256
+
+export const desktopFileSystemCanvasValidateCreateBodyCapabilitiesOneNetworkOneOriginsItemMax = 2048
+
+export const desktopFileSystemCanvasValidateCreateBodyCapabilitiesOneNetworkOneOriginsMax = 64
+
+export const DesktopFileSystemCanvasValidateCreateBody = /* @__PURE__ */ zod.object({
+ schemaVersion: zod
+ .number()
+ .min(1)
+ .max(desktopFileSystemCanvasValidateCreateBodySchemaVersionMax)
+ .describe('Canvas source schema version.'),
+ files: zod
+ .record(zod.string(), zod.string())
+ .describe('Complete map of normalized project-relative paths to UTF-8 source files.'),
+ assets: zod
+ .record(
+ zod.string(),
+ zod.object({
+ encoding: zod.enum(['base64']).describe('\* `base64` - base64'),
+ contentType: zod
+ .enum([
+ 'application/wasm',
+ 'application/octet-stream',
+ 'font/otf',
+ 'font/ttf',
+ 'font/woff',
+ 'font/woff2',
+ 'image/avif',
+ 'image/gif',
+ 'image/jpeg',
+ 'image/png',
+ 'image/svg+xml',
+ 'image/webp',
+ ])
+ .describe(
+ '\* `application\/wasm` - application\/wasm\n\* `application\/octet-stream` - application\/octet-stream\n\* `font\/otf` - font\/otf\n\* `font\/ttf` - font\/ttf\n\* `font\/woff` - font\/woff\n\* `font\/woff2` - font\/woff2\n\* `image\/avif` - image\/avif\n\* `image\/gif` - image\/gif\n\* `image\/jpeg` - image\/jpeg\n\* `image\/png` - image\/png\n\* `image\/svg+xml` - image\/svg+xml\n\* `image\/webp` - image\/webp'
+ ),
+ content: zod.string(),
+ })
+ )
+ .optional()
+ .describe('Binary assets mapped by normalized project-relative path.'),
+ entryHtml: zod.string().describe('HTML entry file. Must be \"index.html\".'),
+ dependencies: zod
+ .record(zod.string(), zod.string())
+ .describe('Browser package names mapped to exact admitted semantic versions.'),
+ canvasSdkVersion: zod.string().describe('Exact canvas runtime SDK version.'),
+ capabilities: zod
+ .object({
+ posthog: zod
+ .object({
+ insights: zod
+ .array(
+ zod
+ .string()
+ .min(1)
+ .max(desktopFileSystemCanvasValidateCreateBodyCapabilitiesOnePosthogOneInsightsItemMax)
+ )
+ .max(desktopFileSystemCanvasValidateCreateBodyCapabilitiesOnePosthogOneInsightsMax)
+ .describe('Insight short IDs that this canvas may load.'),
+ inlineQueries: zod.boolean().describe('Whether this canvas may execute inline PostHog queries.'),
+ captureEvents: zod
+ .array(
+ zod
+ .string()
+ .min(1)
+ .max(
+ desktopFileSystemCanvasValidateCreateBodyCapabilitiesOnePosthogOneCaptureEventsItemMax
+ )
+ )
+ .max(desktopFileSystemCanvasValidateCreateBodyCapabilitiesOnePosthogOneCaptureEventsMax)
+ .describe('Event names that this canvas may capture.'),
+ })
+ .describe('PostHog data and capture capabilities.'),
+ network: zod
+ .object({
+ origins: zod
+ .array(
+ zod
+ .string()
+ .max(desktopFileSystemCanvasValidateCreateBodyCapabilitiesOneNetworkOneOriginsItemMax)
+ )
+ .max(desktopFileSystemCanvasValidateCreateBodyCapabilitiesOneNetworkOneOriginsMax)
+ .describe('HTTPS origins that the canvas may contact directly.'),
+ })
+ .describe('Direct network capabilities.'),
+ })
+ .describe('Capabilities enforced by the build and runtime.'),
+})
+
/**
* Return the latest non-deleted instructions for this folder.
*/
diff --git a/services/mcp/src/hono/request-context.ts b/services/mcp/src/hono/request-context.ts
index b21dd4a05914..30a9ce670a4d 100644
--- a/services/mcp/src/hono/request-context.ts
+++ b/services/mcp/src/hono/request-context.ts
@@ -102,6 +102,7 @@ export class RequestContext {
mcpProtocolVersion: this.props.mcpProtocolVersion,
mcpConsumer: this.props.mcpConsumer,
taskId: this.props.taskId,
+ taskRunId: this.props.taskRunId,
})
}
return this.apiInstance
diff --git a/services/mcp/src/lib/request-properties.ts b/services/mcp/src/lib/request-properties.ts
index 5c4f9088787b..14a1280b7981 100644
--- a/services/mcp/src/lib/request-properties.ts
+++ b/services/mcp/src/lib/request-properties.ts
@@ -28,9 +28,9 @@ export type RequestProperties = {
mcpConversationId?: string | undefined
viaSseRedirect?: boolean | undefined
requestStartTime?: number | undefined
- // Sandbox-provisioned task id: forwarded to the PostHog API as `X-PostHog-Task-Id` on every
- // call so writes can be attributed to the agent's task (validated server-side per team).
+ // Sandbox-provisioned task and run ids are forwarded for server-side attribution.
taskId?: string | undefined
+ taskRunId?: string | undefined
// Dev/test-only per-request feature-flag overrides — a JSON object string from
// `?flag_overrides=` or the `x-posthog-flag-overrides` header. Parsed and gated
// to NODE_ENV development/test (fail-closed) in `resolveFeatureFlagOverrides`.
@@ -87,6 +87,7 @@ export function parseRequestProperties(
mcpVendorClient: vendorClient,
mode: parseMcpMode(header(request, 'x-posthog-mcp-mode') || params.get('mode')),
taskId: sanitizeHeaderValue(header(request, 'x-posthog-task-id')),
+ taskRunId: sanitizeHeaderValue(header(request, 'x-posthog-task-run-id')),
transport,
requestStartTime: Date.now(),
featureFlagOverrides: header(request, 'x-posthog-flag-overrides') || params.get('flag_overrides') || undefined,
diff --git a/services/mcp/src/tools/generated/core.ts b/services/mcp/src/tools/generated/core.ts
index 4f75c3a51080..7f439455623c 100644
--- a/services/mcp/src/tools/generated/core.ts
+++ b/services/mcp/src/tools/generated/core.ts
@@ -3,8 +3,17 @@ import { z } from 'zod'
import type { Schemas } from '@/api/generated'
import {
+ DesktopFileSystemCanvasBuildsRetrieveParams,
+ DesktopFileSystemCanvasHistoryRetrieveParams,
DesktopFileSystemCanvasPartialUpdateBody,
DesktopFileSystemCanvasPartialUpdateParams,
+ DesktopFileSystemCanvasSourceCreateBody,
+ DesktopFileSystemCanvasSourceCreateParams,
+ DesktopFileSystemCanvasSourcePartialUpdateBody,
+ DesktopFileSystemCanvasSourcePartialUpdateParams,
+ DesktopFileSystemCanvasSourceRetrieveParams,
+ DesktopFileSystemCanvasValidateCreateBody,
+ DesktopFileSystemCanvasValidateCreateParams,
DesktopFileSystemCreateBody,
DesktopFileSystemInstructionsPartialUpdateBody,
DesktopFileSystemInstructionsPartialUpdateParams,
@@ -22,6 +31,159 @@ import { castStringToInt } from '@/tools/cast-helpers'
import { omitResponseFields, pickResponseFields } from '@/tools/tool-utils'
import type { Context, ToolBase, ZodObjectAny } from '@/tools/types'
+const CanvasBuildGetSchema = DesktopFileSystemCanvasBuildsRetrieveParams.omit({ project_id: true })
+
+const canvasBuildGet = (): ToolBase => ({
+ name: 'canvas-build-get',
+ schema: CanvasBuildGetSchema,
+ handler: async (context: Context, params: z.infer) => {
+ const projectId = await context.stateManager.getProjectId()
+ const result = await context.api.request({
+ method: 'GET',
+ path: `/api/projects/${encodeURIComponent(String(projectId))}/desktop_file_system/${encodeURIComponent(String(params.id))}/canvas/builds/${encodeURIComponent(String(params.build_id))}/`,
+ })
+ return result
+ },
+})
+
+const CanvasHistoryGetSchema = DesktopFileSystemCanvasHistoryRetrieveParams.omit({ project_id: true })
+
+const canvasHistoryGet = (): ToolBase => ({
+ name: 'canvas-history-get',
+ schema: CanvasHistoryGetSchema,
+ handler: async (context: Context, params: z.infer) => {
+ const projectId = await context.stateManager.getProjectId()
+ const result = await context.api.request({
+ method: 'GET',
+ path: `/api/projects/${encodeURIComponent(String(projectId))}/desktop_file_system/${encodeURIComponent(String(params.id))}/canvas/history/`,
+ })
+ return result
+ },
+})
+
+const CanvasSourceGetSchema = DesktopFileSystemCanvasSourceRetrieveParams.omit({ project_id: true })
+
+const canvasSourceGet = (): ToolBase => ({
+ name: 'canvas-source-get',
+ schema: CanvasSourceGetSchema,
+ handler: async (context: Context, params: z.infer) => {
+ const projectId = await context.stateManager.getProjectId()
+ const result = await context.api.request({
+ method: 'GET',
+ path: `/api/projects/${encodeURIComponent(String(projectId))}/desktop_file_system/${encodeURIComponent(String(params.id))}/canvas/source/`,
+ })
+ return result
+ },
+})
+
+const CanvasSourcePatchSchema = DesktopFileSystemCanvasSourcePartialUpdateParams.omit({ project_id: true }).extend(
+ DesktopFileSystemCanvasSourcePartialUpdateBody.shape
+)
+
+const canvasSourcePatch = (): ToolBase => ({
+ name: 'canvas-source-patch',
+ schema: CanvasSourcePatchSchema,
+ handler: async (context: Context, params: z.infer) => {
+ const projectId = await context.stateManager.getProjectId()
+ const body: Record = {}
+ if (params.patch !== undefined) {
+ body['patch'] = params.patch
+ }
+ if (params.expectedCurrentVersionId !== undefined) {
+ body['expectedCurrentVersionId'] = params.expectedCurrentVersionId
+ }
+ if (params.taskId !== undefined) {
+ body['taskId'] = params.taskId
+ }
+ if (params.taskRunId !== undefined) {
+ body['taskRunId'] = params.taskRunId
+ }
+ if (params.prompt !== undefined) {
+ body['prompt'] = params.prompt
+ }
+ const result = await context.api.request({
+ method: 'PATCH',
+ path: `/api/projects/${encodeURIComponent(String(projectId))}/desktop_file_system/${encodeURIComponent(String(params.id))}/canvas/source/`,
+ body,
+ })
+ return result
+ },
+})
+
+const CanvasSourcePublishSchema = DesktopFileSystemCanvasSourceCreateParams.omit({ project_id: true }).extend(
+ DesktopFileSystemCanvasSourceCreateBody.shape
+)
+
+const canvasSourcePublish = (): ToolBase => ({
+ name: 'canvas-source-publish',
+ schema: CanvasSourcePublishSchema,
+ handler: async (context: Context, params: z.infer) => {
+ const projectId = await context.stateManager.getProjectId()
+ const body: Record = {}
+ if (params.project !== undefined) {
+ body['project'] = params.project
+ }
+ if (params.expectedCurrentVersionId !== undefined) {
+ body['expectedCurrentVersionId'] = params.expectedCurrentVersionId
+ }
+ if (params.taskId !== undefined) {
+ body['taskId'] = params.taskId
+ }
+ if (params.taskRunId !== undefined) {
+ body['taskRunId'] = params.taskRunId
+ }
+ if (params.prompt !== undefined) {
+ body['prompt'] = params.prompt
+ }
+ const result = await context.api.request({
+ method: 'POST',
+ path: `/api/projects/${encodeURIComponent(String(projectId))}/desktop_file_system/${encodeURIComponent(String(params.id))}/canvas/source/`,
+ body,
+ })
+ return result
+ },
+})
+
+const CanvasSourceValidateSchema = DesktopFileSystemCanvasValidateCreateParams.omit({ project_id: true }).extend(
+ DesktopFileSystemCanvasValidateCreateBody.shape
+)
+
+const canvasSourceValidate = (): ToolBase => ({
+ name: 'canvas-source-validate',
+ schema: CanvasSourceValidateSchema,
+ handler: async (context: Context, params: z.infer) => {
+ const projectId = await context.stateManager.getProjectId()
+ const body: Record = {}
+ if (params.schemaVersion !== undefined) {
+ body['schemaVersion'] = params.schemaVersion
+ }
+ if (params.files !== undefined) {
+ body['files'] = params.files
+ }
+ if (params.assets !== undefined) {
+ body['assets'] = params.assets
+ }
+ if (params.entryHtml !== undefined) {
+ body['entryHtml'] = params.entryHtml
+ }
+ if (params.dependencies !== undefined) {
+ body['dependencies'] = params.dependencies
+ }
+ if (params.canvasSdkVersion !== undefined) {
+ body['canvasSdkVersion'] = params.canvasSdkVersion
+ }
+ if (params.capabilities !== undefined) {
+ body['capabilities'] = params.capabilities
+ }
+ const result = await context.api.request({
+ method: 'POST',
+ path: `/api/projects/${encodeURIComponent(String(projectId))}/desktop_file_system/${encodeURIComponent(String(params.id))}/canvas/validate/`,
+ body,
+ })
+ return result
+ },
+})
+
const DesktopFileSystemCanvasPartialUpdateSchema = DesktopFileSystemCanvasPartialUpdateParams.omit({ project_id: true })
.extend(DesktopFileSystemCanvasPartialUpdateBody.shape)
.extend({
@@ -596,6 +758,12 @@ const userSettingsUpdate = (): ToolBase ToolBase> = {
+ 'canvas-build-get': canvasBuildGet,
+ 'canvas-history-get': canvasHistoryGet,
+ 'canvas-source-get': canvasSourceGet,
+ 'canvas-source-patch': canvasSourcePatch,
+ 'canvas-source-publish': canvasSourcePublish,
+ 'canvas-source-validate': canvasSourceValidate,
'desktop-file-system-canvas-partial-update': desktopFileSystemCanvasPartialUpdate,
'desktop-file-system-create': desktopFileSystemCreate,
'desktop-file-system-instructions-partial-update': desktopFileSystemInstructionsPartialUpdate,
diff --git a/services/mcp/tests/unit/__snapshots__/tool-schemas/canvas-build-get.json b/services/mcp/tests/unit/__snapshots__/tool-schemas/canvas-build-get.json
new file mode 100644
index 000000000000..592145ce7f75
--- /dev/null
+++ b/services/mcp/tests/unit/__snapshots__/tool-schemas/canvas-build-get.json
@@ -0,0 +1,15 @@
+{
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "properties": {
+ "build_id": {
+ "description": "Immutable canvas build ID.",
+ "type": "string"
+ },
+ "id": {
+ "description": "A UUID string identifying this file system.",
+ "type": "string"
+ }
+ },
+ "required": ["build_id", "id"],
+ "type": "object"
+}
diff --git a/services/mcp/tests/unit/__snapshots__/tool-schemas/canvas-history-get.json b/services/mcp/tests/unit/__snapshots__/tool-schemas/canvas-history-get.json
new file mode 100644
index 000000000000..3b94139ed2d7
--- /dev/null
+++ b/services/mcp/tests/unit/__snapshots__/tool-schemas/canvas-history-get.json
@@ -0,0 +1,11 @@
+{
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "properties": {
+ "id": {
+ "description": "A UUID string identifying this file system.",
+ "type": "string"
+ }
+ },
+ "required": ["id"],
+ "type": "object"
+}
diff --git a/services/mcp/tests/unit/__snapshots__/tool-schemas/canvas-source-get.json b/services/mcp/tests/unit/__snapshots__/tool-schemas/canvas-source-get.json
new file mode 100644
index 000000000000..3b94139ed2d7
--- /dev/null
+++ b/services/mcp/tests/unit/__snapshots__/tool-schemas/canvas-source-get.json
@@ -0,0 +1,11 @@
+{
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "properties": {
+ "id": {
+ "description": "A UUID string identifying this file system.",
+ "type": "string"
+ }
+ },
+ "required": ["id"],
+ "type": "object"
+}
diff --git a/services/mcp/tests/unit/__snapshots__/tool-schemas/canvas-source-patch.json b/services/mcp/tests/unit/__snapshots__/tool-schemas/canvas-source-patch.json
new file mode 100644
index 000000000000..5b16164629a0
--- /dev/null
+++ b/services/mcp/tests/unit/__snapshots__/tool-schemas/canvas-source-patch.json
@@ -0,0 +1,154 @@
+{
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "properties": {
+ "expectedCurrentVersionId": {
+ "description": "Current source version that this patch is based on.",
+ "type": "string"
+ },
+ "id": {
+ "description": "A UUID string identifying this file system.",
+ "type": "string"
+ },
+ "patch": {
+ "description": "File, asset, dependency, and capability changes to apply.",
+ "properties": {
+ "capabilities": {
+ "properties": {
+ "network": {
+ "description": "Direct network capabilities.",
+ "properties": {
+ "origins": {
+ "description": "HTTPS origins that the canvas may contact directly.",
+ "items": {
+ "maxLength": 2048,
+ "type": "string"
+ },
+ "maxItems": 64,
+ "type": "array"
+ }
+ },
+ "required": ["origins"],
+ "type": "object"
+ },
+ "posthog": {
+ "description": "PostHog data and capture capabilities.",
+ "properties": {
+ "captureEvents": {
+ "description": "Event names that this canvas may capture.",
+ "items": {
+ "maxLength": 200,
+ "minLength": 1,
+ "type": "string"
+ },
+ "maxItems": 256,
+ "type": "array"
+ },
+ "inlineQueries": {
+ "description": "Whether this canvas may execute inline PostHog queries.",
+ "type": "boolean"
+ },
+ "insights": {
+ "description": "Insight short IDs that this canvas may load.",
+ "items": {
+ "maxLength": 128,
+ "minLength": 1,
+ "type": "string"
+ },
+ "maxItems": 256,
+ "type": "array"
+ }
+ },
+ "required": ["insights", "inlineQueries", "captureEvents"],
+ "type": "object"
+ }
+ },
+ "required": ["posthog", "network"],
+ "type": "object"
+ },
+ "deleteAssets": {
+ "items": {
+ "type": "string"
+ },
+ "maxItems": 128,
+ "type": "array"
+ },
+ "deleteFiles": {
+ "items": {
+ "type": "string"
+ },
+ "maxItems": 128,
+ "type": "array"
+ },
+ "dependencies": {
+ "additionalProperties": {
+ "type": "string"
+ },
+ "propertyNames": {
+ "type": "string"
+ },
+ "type": "object"
+ },
+ "upsertAssets": {
+ "additionalProperties": {
+ "properties": {
+ "content": {
+ "type": "string"
+ },
+ "contentType": {
+ "description": "* `application/wasm` - application/wasm\n* `application/octet-stream` - application/octet-stream\n* `font/otf` - font/otf\n* `font/ttf` - font/ttf\n* `font/woff` - font/woff\n* `font/woff2` - font/woff2\n* `image/avif` - image/avif\n* `image/gif` - image/gif\n* `image/jpeg` - image/jpeg\n* `image/png` - image/png\n* `image/svg+xml` - image/svg+xml\n* `image/webp` - image/webp",
+ "enum": [
+ "application/wasm",
+ "application/octet-stream",
+ "font/otf",
+ "font/ttf",
+ "font/woff",
+ "font/woff2",
+ "image/avif",
+ "image/gif",
+ "image/jpeg",
+ "image/png",
+ "image/svg+xml",
+ "image/webp"
+ ],
+ "type": "string"
+ },
+ "encoding": {
+ "description": "* `base64` - base64",
+ "enum": ["base64"],
+ "type": "string"
+ }
+ },
+ "required": ["encoding", "contentType", "content"],
+ "type": "object"
+ },
+ "propertyNames": {
+ "type": "string"
+ },
+ "type": "object"
+ },
+ "upsertFiles": {
+ "additionalProperties": {
+ "type": "string"
+ },
+ "propertyNames": {
+ "type": "string"
+ },
+ "type": "object"
+ }
+ },
+ "type": "object"
+ },
+ "prompt": {
+ "maxLength": 10000,
+ "type": "string"
+ },
+ "taskId": {
+ "type": "string"
+ },
+ "taskRunId": {
+ "type": "string"
+ }
+ },
+ "required": ["id"],
+ "type": "object"
+}
diff --git a/services/mcp/tests/unit/__snapshots__/tool-schemas/canvas-source-publish.json b/services/mcp/tests/unit/__snapshots__/tool-schemas/canvas-source-publish.json
new file mode 100644
index 000000000000..3131f60783f2
--- /dev/null
+++ b/services/mcp/tests/unit/__snapshots__/tool-schemas/canvas-source-publish.json
@@ -0,0 +1,169 @@
+{
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "properties": {
+ "expectedCurrentVersionId": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "description": "Current source version that this edit is based on. Pass null for the first version."
+ },
+ "id": {
+ "description": "A UUID string identifying this file system.",
+ "type": "string"
+ },
+ "project": {
+ "description": "Complete canvas source project to publish.",
+ "properties": {
+ "assets": {
+ "additionalProperties": {
+ "properties": {
+ "content": {
+ "type": "string"
+ },
+ "contentType": {
+ "description": "* `application/wasm` - application/wasm\n* `application/octet-stream` - application/octet-stream\n* `font/otf` - font/otf\n* `font/ttf` - font/ttf\n* `font/woff` - font/woff\n* `font/woff2` - font/woff2\n* `image/avif` - image/avif\n* `image/gif` - image/gif\n* `image/jpeg` - image/jpeg\n* `image/png` - image/png\n* `image/svg+xml` - image/svg+xml\n* `image/webp` - image/webp",
+ "enum": [
+ "application/wasm",
+ "application/octet-stream",
+ "font/otf",
+ "font/ttf",
+ "font/woff",
+ "font/woff2",
+ "image/avif",
+ "image/gif",
+ "image/jpeg",
+ "image/png",
+ "image/svg+xml",
+ "image/webp"
+ ],
+ "type": "string"
+ },
+ "encoding": {
+ "description": "* `base64` - base64",
+ "enum": ["base64"],
+ "type": "string"
+ }
+ },
+ "required": ["encoding", "contentType", "content"],
+ "type": "object"
+ },
+ "description": "Binary assets mapped by normalized project-relative path.",
+ "propertyNames": {
+ "type": "string"
+ },
+ "type": "object"
+ },
+ "canvasSdkVersion": {
+ "description": "Exact canvas runtime SDK version.",
+ "type": "string"
+ },
+ "capabilities": {
+ "description": "Capabilities enforced by the build and runtime.",
+ "properties": {
+ "network": {
+ "description": "Direct network capabilities.",
+ "properties": {
+ "origins": {
+ "description": "HTTPS origins that the canvas may contact directly.",
+ "items": {
+ "maxLength": 2048,
+ "type": "string"
+ },
+ "maxItems": 64,
+ "type": "array"
+ }
+ },
+ "required": ["origins"],
+ "type": "object"
+ },
+ "posthog": {
+ "description": "PostHog data and capture capabilities.",
+ "properties": {
+ "captureEvents": {
+ "description": "Event names that this canvas may capture.",
+ "items": {
+ "maxLength": 200,
+ "minLength": 1,
+ "type": "string"
+ },
+ "maxItems": 256,
+ "type": "array"
+ },
+ "inlineQueries": {
+ "description": "Whether this canvas may execute inline PostHog queries.",
+ "type": "boolean"
+ },
+ "insights": {
+ "description": "Insight short IDs that this canvas may load.",
+ "items": {
+ "maxLength": 128,
+ "minLength": 1,
+ "type": "string"
+ },
+ "maxItems": 256,
+ "type": "array"
+ }
+ },
+ "required": ["insights", "inlineQueries", "captureEvents"],
+ "type": "object"
+ }
+ },
+ "required": ["posthog", "network"],
+ "type": "object"
+ },
+ "dependencies": {
+ "additionalProperties": {
+ "type": "string"
+ },
+ "description": "Browser package names mapped to exact admitted semantic versions.",
+ "propertyNames": {
+ "type": "string"
+ },
+ "type": "object"
+ },
+ "entryHtml": {
+ "description": "HTML entry file. Must be \"index.html\".",
+ "type": "string"
+ },
+ "files": {
+ "additionalProperties": {
+ "type": "string"
+ },
+ "description": "Complete map of normalized project-relative paths to UTF-8 source files.",
+ "propertyNames": {
+ "type": "string"
+ },
+ "type": "object"
+ },
+ "schemaVersion": {
+ "description": "Canvas source schema version.",
+ "maximum": 1,
+ "minimum": 1,
+ "type": "number"
+ }
+ },
+ "required": ["schemaVersion", "files", "entryHtml", "dependencies", "canvasSdkVersion", "capabilities"],
+ "type": "object"
+ },
+ "prompt": {
+ "description": "Short description of the requested canvas change.",
+ "maxLength": 10000,
+ "type": "string"
+ },
+ "taskId": {
+ "description": "Task that produced this source version. Sandbox tasks are attributed automatically.",
+ "type": "string"
+ },
+ "taskRunId": {
+ "description": "Fresh task run that produced this source version. Sandbox tasks are attributed automatically.",
+ "type": "string"
+ }
+ },
+ "required": ["id", "project", "expectedCurrentVersionId"],
+ "type": "object"
+}
diff --git a/services/mcp/tests/unit/__snapshots__/tool-schemas/canvas-source-validate.json b/services/mcp/tests/unit/__snapshots__/tool-schemas/canvas-source-validate.json
new file mode 100644
index 000000000000..70ee1fc957f6
--- /dev/null
+++ b/services/mcp/tests/unit/__snapshots__/tool-schemas/canvas-source-validate.json
@@ -0,0 +1,138 @@
+{
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "properties": {
+ "assets": {
+ "additionalProperties": {
+ "properties": {
+ "content": {
+ "type": "string"
+ },
+ "contentType": {
+ "description": "* `application/wasm` - application/wasm\n* `application/octet-stream` - application/octet-stream\n* `font/otf` - font/otf\n* `font/ttf` - font/ttf\n* `font/woff` - font/woff\n* `font/woff2` - font/woff2\n* `image/avif` - image/avif\n* `image/gif` - image/gif\n* `image/jpeg` - image/jpeg\n* `image/png` - image/png\n* `image/svg+xml` - image/svg+xml\n* `image/webp` - image/webp",
+ "enum": [
+ "application/wasm",
+ "application/octet-stream",
+ "font/otf",
+ "font/ttf",
+ "font/woff",
+ "font/woff2",
+ "image/avif",
+ "image/gif",
+ "image/jpeg",
+ "image/png",
+ "image/svg+xml",
+ "image/webp"
+ ],
+ "type": "string"
+ },
+ "encoding": {
+ "description": "* `base64` - base64",
+ "enum": ["base64"],
+ "type": "string"
+ }
+ },
+ "required": ["encoding", "contentType", "content"],
+ "type": "object"
+ },
+ "description": "Binary assets mapped by normalized project-relative path.",
+ "propertyNames": {
+ "type": "string"
+ },
+ "type": "object"
+ },
+ "canvasSdkVersion": {
+ "description": "Exact canvas runtime SDK version.",
+ "type": "string"
+ },
+ "capabilities": {
+ "description": "Capabilities enforced by the build and runtime.",
+ "properties": {
+ "network": {
+ "description": "Direct network capabilities.",
+ "properties": {
+ "origins": {
+ "description": "HTTPS origins that the canvas may contact directly.",
+ "items": {
+ "maxLength": 2048,
+ "type": "string"
+ },
+ "maxItems": 64,
+ "type": "array"
+ }
+ },
+ "required": ["origins"],
+ "type": "object"
+ },
+ "posthog": {
+ "description": "PostHog data and capture capabilities.",
+ "properties": {
+ "captureEvents": {
+ "description": "Event names that this canvas may capture.",
+ "items": {
+ "maxLength": 200,
+ "minLength": 1,
+ "type": "string"
+ },
+ "maxItems": 256,
+ "type": "array"
+ },
+ "inlineQueries": {
+ "description": "Whether this canvas may execute inline PostHog queries.",
+ "type": "boolean"
+ },
+ "insights": {
+ "description": "Insight short IDs that this canvas may load.",
+ "items": {
+ "maxLength": 128,
+ "minLength": 1,
+ "type": "string"
+ },
+ "maxItems": 256,
+ "type": "array"
+ }
+ },
+ "required": ["insights", "inlineQueries", "captureEvents"],
+ "type": "object"
+ }
+ },
+ "required": ["posthog", "network"],
+ "type": "object"
+ },
+ "dependencies": {
+ "additionalProperties": {
+ "type": "string"
+ },
+ "description": "Browser package names mapped to exact admitted semantic versions.",
+ "propertyNames": {
+ "type": "string"
+ },
+ "type": "object"
+ },
+ "entryHtml": {
+ "description": "HTML entry file. Must be \"index.html\".",
+ "type": "string"
+ },
+ "files": {
+ "additionalProperties": {
+ "type": "string"
+ },
+ "description": "Complete map of normalized project-relative paths to UTF-8 source files.",
+ "propertyNames": {
+ "type": "string"
+ },
+ "type": "object"
+ },
+ "id": {
+ "description": "A UUID string identifying this file system.",
+ "type": "string"
+ },
+ "schemaVersion": {
+ "description": "Canvas source schema version.",
+ "maximum": 1,
+ "minimum": 1,
+ "type": "number"
+ }
+ },
+ "required": ["id", "schemaVersion", "files", "entryHtml", "dependencies", "canvasSdkVersion", "capabilities"],
+ "type": "object"
+}
diff --git a/services/mcp/tests/unit/api-client.test.ts b/services/mcp/tests/unit/api-client.test.ts
index 8b433acfbb18..d15a5068d551 100644
--- a/services/mcp/tests/unit/api-client.test.ts
+++ b/services/mcp/tests/unit/api-client.test.ts
@@ -107,6 +107,26 @@ describe('ApiClient', () => {
vi.unstubAllGlobals()
})
+ it('forwards sandbox task and run attribution headers', async () => {
+ const mockFetch = vi.fn().mockResolvedValue(new Response('{}', { status: 200 }))
+ vi.stubGlobal('fetch', mockFetch)
+ const client = new ApiClient({
+ apiToken: 'test-token',
+ baseUrl: 'https://example.com',
+ taskId: 'task-1',
+ taskRunId: 'run-1',
+ })
+
+ await (client as any).fetch('https://example.com/api/test')
+
+ const [, options] = mockFetch.mock.calls[0]!
+ expect(options.headers).toMatchObject({
+ 'X-PostHog-Task-Id': 'task-1',
+ 'X-PostHog-Task-Run-Id': 'run-1',
+ })
+ vi.unstubAllGlobals()
+ })
+
it('should send x-posthog-mcp-user-agent header when clientUserAgent is provided', async () => {
const mockFetch = vi.fn().mockResolvedValue(new Response(JSON.stringify({}), { status: 200 }))
vi.stubGlobal('fetch', mockFetch)
diff --git a/services/mcp/tests/unit/url-routing.test.ts b/services/mcp/tests/unit/url-routing.test.ts
index 7ac8e19177e3..a8bbc24d6775 100644
--- a/services/mcp/tests/unit/url-routing.test.ts
+++ b/services/mcp/tests/unit/url-routing.test.ts
@@ -235,6 +235,21 @@ describe('URL Routing', () => {
})
})
+ it('extracts sandbox task and run attribution', () => {
+ const request = new Request('https://example.com/mcp', {
+ headers: {
+ Authorization: 'Bearer phx_test',
+ 'x-posthog-task-id': 'task-1',
+ 'x-posthog-task-run-id': 'run-1',
+ },
+ })
+
+ expect(parseRequestProperties(request, {})).toMatchObject({
+ taskId: 'task-1',
+ taskRunId: 'run-1',
+ })
+ })
+
describe('mcpVendorClient parsing', () => {
it('captures x-anthropic-client into mcpVendorClient', () => {
const request = new Request('https://example.com/mcp', {