diff --git a/.agents/skills/mobile-parity/references/control-sizing.md b/.agents/skills/mobile-parity/references/control-sizing.md index 6777003cf0..3728160528 100644 --- a/.agents/skills/mobile-parity/references/control-sizing.md +++ b/.agents/skills/mobile-parity/references/control-sizing.md @@ -34,6 +34,13 @@ Apply larger hit areas through a phone or coarse-pointer condition. Use the canonical 768px phone boundary for new shared sizing rules. Preserve existing overlay boundaries unless the task changes their composition. +Feature callers must not use a desktop repair class such as +`min-h-11 md:min-h-9`: it produces a 36px desktop control and bypasses the +28px standard. Leave Button, Input, and SelectTrigger defaults in place when +only layout or width needs changing, or use the shared sizing helper. If a +caller needs a custom touch target, scope it to the phone or coarse-pointer +condition instead of overriding the desktop base. + Do not add a global CSS rule that assigns one height to every HTML button. Do not change existing compact or large variant meanings to repair individual callers. Use the standard variant at ordinary action call sites. diff --git a/.github/scripts/pr-docs.cjs b/.github/scripts/pr-docs.cjs index 6b60764ff3..86911ea2b7 100644 --- a/.github/scripts/pr-docs.cjs +++ b/.github/scripts/pr-docs.cjs @@ -1206,8 +1206,12 @@ async function loadCoverageContents({ client, changedFiles, headSha }) { continue; } let system; + let designRequirements = []; try { - parseFrontmatter(designContent); + const design = parseFrontmatter(designContent); + designRequirements = Array.isArray(design.data.requirements) + ? design.data.requirements + : []; const match = /^docs\/specs\/([^/]+)\/system-design\/[^/]+\.md$/.exec(designPath); if (!match) { continue; @@ -1225,8 +1229,12 @@ async function loadCoverageContents({ client, changedFiles, headSha }) { } } const unresolvedRequirementIds = []; + const workOrderRequirements = new Set(workOrder.requirements ?? []); + const referencedRequirementIds = designRequirements.filter(requirementId => + workOrderRequirements.has(requirementId) + ); if (typeof client.searchCode === 'function') { - for (const requirementId of workOrder.requirements ?? []) { + for (const requirementId of referencedRequirementIds) { const searchKey = JSON.stringify([requirementDirectory, requirementId]); if (!requirementSearches.has(searchKey)) { requirementSearches.set( @@ -1243,7 +1251,7 @@ async function loadCoverageContents({ client, changedFiles, headSha }) { } } } else { - unresolvedRequirementIds.push(...(workOrder.requirements ?? [])); + unresolvedRequirementIds.push(...referencedRequirementIds); } if (unresolvedRequirementIds.length > 0 && typeof client.listDirectory === 'function') { if (!requirementDirectories.has(requirementDirectory)) { diff --git a/.github/scripts/pr-docs.test.cjs b/.github/scripts/pr-docs.test.cjs index 1279e9f0cc..bcd39ed162 100644 --- a/.github/scripts/pr-docs.test.cjs +++ b/.github/scripts/pr-docs.test.cjs @@ -407,6 +407,48 @@ test('multi-design work orders validate each requirement against its owning desi assert.deepEqual(result.errors, []); }); +// @covers AC-CI-PR-DOCS-001.4 +test('coverage loading searches only requirements declared by each design', async () => { + const contents = multiDesignFixtureContents(); + const changed = runtimeAndWorkOrderDiff(); + const searches = []; + const client = { + async getPullRequest() { + return pullRequest(42, SHA_B, [], changed.length); + }, + async listFiles() { + return changed; + }, + async getFile(pathname, ref) { + assert.equal(ref, SHA_B); + if (!Object.hasOwn(contents, pathname)) { + throw new Error('GitHub API request failed with HTTP 404: Not Found'); + } + return contents[pathname]; + }, + async searchCode(requirementId, directory) { + searches.push({ requirementId, directory }); + return [directory.includes('/platform/') + ? 'docs/specs/platform/requirements/diagnostic-logging.md' + : 'docs/specs/web/requirements/browser-retention.md']; + }, + }; + + const result = await validator.evaluatePullRequest({ client, pullNumber: 42 }); + + assert.equal(result.status, 'covered', result.errors.join('; ')); + assert.deepEqual(searches, [ + { + requirementId: 'REQ-PLATFORM-DIAGNOSTIC-LOGGING-001', + directory: 'docs/specs/platform/requirements', + }, + { + requirementId: 'REQ-WEB-BROWSER-RETENTION-001', + directory: 'docs/specs/web/requirements', + }, + ]); +}); + test('multi-design work orders fail when a requirement has no owning design', () => { const contents = multiDesignFixtureContents(); contents['docs/specs/web/system-design/retention.md'] = contents[ diff --git a/.github/workflows/plugin-registry-index.yml b/.github/workflows/plugin-registry-index.yml index f76726c614..5a15f9af39 100644 --- a/.github/workflows/plugin-registry-index.yml +++ b/.github/workflows/plugin-registry-index.yml @@ -57,7 +57,13 @@ jobs: with: node-version: "22" + - name: Build canvas package inspector + working-directory: apps/backend + run: go build -o bin/canvas-package ./cmd/canvas-package + - name: Test build script + env: + KANDEV_CANVAS_PACKAGE_INSPECTOR: apps/backend/bin/canvas-package run: node --test plugin-registry/build-index.test.mjs - name: Build index.json @@ -68,6 +74,7 @@ jobs: # this secret instead. env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + KANDEV_CANVAS_PACKAGE_INSPECTOR: apps/backend/bin/canvas-package run: node plugin-registry/build-index.mjs - name: Stage Pages site diff --git a/apps/backend/cmd/canvas-package/main.go b/apps/backend/cmd/canvas-package/main.go new file mode 100644 index 0000000000..087bb97fc8 --- /dev/null +++ b/apps/backend/cmd/canvas-package/main.go @@ -0,0 +1,118 @@ +// Command canvas-package inspects a portable canvas bundle without executing +// or extracting any package content. Registry tooling uses its JSON output as +// the trusted package descriptor. +package main + +import ( + "encoding/json" + "flag" + "fmt" + "io" + "os" + "sort" + + "github.com/kandev/kandev/internal/plugins/webapp" +) + +type packageDescriptor struct { + ID string `json:"id"` + Version string `json:"version"` + Kind string `json:"kind"` + DisplayName string `json:"display_name"` + Description string `json:"description"` + Author string `json:"author"` + License string `json:"license"` + SourceMode string `json:"source_mode"` + MinKandevVersion string `json:"min_kandev_version"` + RepoURL string `json:"repo_url,omitempty"` + Digest string `json:"sha256"` + CompressedBytes int64 `json:"compressed_bytes"` + ExpandedBytes int64 `json:"expanded_bytes"` + Files []string `json:"files"` + APIRead []string `json:"api_read,omitempty"` + APIWrite []string `json:"api_write,omitempty"` + Events []string `json:"events,omitempty"` + State bool `json:"state,omitempty"` + Secrets bool `json:"secrets,omitempty"` + NetworkOrigins []string `json:"network_origins,omitempty"` +} + +func main() { + os.Exit(run(os.Args[1:], os.Stdin, os.Stdout, os.Stderr)) +} + +func run(args []string, input io.Reader, output, errorOutput io.Writer) int { + flags := flag.NewFlagSet("canvas-package", flag.ContinueOnError) + flags.SetOutput(errorOutput) + fileName := flags.String("file", "-", "bundle path, or - for stdin") + if err := flags.Parse(args); err != nil { + return 2 + } + if flags.NArg() != 0 { + _, _ = fmt.Fprintln(errorOutput, "canvas-package: unexpected argument") + return 2 + } + + reader := input + var file *os.File + if *fileName != "-" { + var err error + file, err = os.Open(*fileName) + if err != nil { + _, _ = fmt.Fprintln(errorOutput, "canvas-package: unable to read package") + return 1 + } + defer func() { _ = file.Close() }() + reader = file + } + pkg, err := webapp.ValidateDistributionPackage(reader) + if err != nil { + // Package paths and parser details are deliberately not returned to + // registry callers. The inspector is an admission boundary. + _, _ = fmt.Fprintln(errorOutput, "canvas-package: invalid distribution package") + return 1 + } + descriptor := describePackage(pkg) + encoder := json.NewEncoder(output) + encoder.SetEscapeHTML(true) + if err := encoder.Encode(descriptor); err != nil { + _, _ = fmt.Fprintln(errorOutput, "canvas-package: unable to write descriptor") + return 1 + } + return 0 +} + +func describePackage(pkg *webapp.Package) packageDescriptor { + manifest := pkg.Manifest + files := make([]string, 0, len(pkg.Files)) + for name := range pkg.Files { + files = append(files, name) + } + sort.Strings(files) + networkOrigins := make([]string, 0) + if len(manifest.UI.WebApps) == 1 { + networkOrigins = append(networkOrigins, manifest.UI.WebApps[0].NetworkOrigins...) + } + return packageDescriptor{ + ID: manifest.ID, + Version: manifest.Version, + Kind: manifest.Distribution.Kind, + DisplayName: manifest.DisplayName, + Description: manifest.Description, + Author: manifest.Author, + License: manifest.Distribution.License, + SourceMode: manifest.Distribution.SourceMode, + MinKandevVersion: manifest.MinKandevVersion, + RepoURL: manifest.RepoURL, + Digest: pkg.Digest, + CompressedBytes: pkg.CompressedBytes, + ExpandedBytes: pkg.ExpandedBytes, + Files: files, + APIRead: append([]string(nil), manifest.Capabilities.APIRead...), + APIWrite: append([]string(nil), manifest.Capabilities.APIWrite...), + Events: append([]string(nil), manifest.Capabilities.Events...), + State: manifest.Capabilities.State, + Secrets: manifest.Capabilities.Secrets, + NetworkOrigins: networkOrigins, + } +} diff --git a/apps/backend/cmd/canvas-package/main_test.go b/apps/backend/cmd/canvas-package/main_test.go new file mode 100644 index 0000000000..6d670c0447 --- /dev/null +++ b/apps/backend/cmd/canvas-package/main_test.go @@ -0,0 +1,113 @@ +package main + +import ( + "archive/tar" + "bytes" + "compress/gzip" + "crypto/sha256" + "encoding/json" + "fmt" + "io" + "sort" + "testing" +) + +func TestRunInspectsDistributionPackageAsSafeJSON(t *testing.T) { + archive := testDistributionArchive(t) + var output bytes.Buffer + code := run([]string{"--file", "-"}, bytes.NewReader(archive), &output, &bytes.Buffer{}) + if code != 0 { + t.Fatalf("run() exit code = %d", code) + } + var descriptor packageDescriptor + if err := json.Unmarshal(output.Bytes(), &descriptor); err != nil { + t.Fatalf("decode descriptor: %v", err) + } + if descriptor.ID != "canvas-board" || descriptor.Version != "1.2.3" || descriptor.Kind != "canvas" { + t.Fatalf("descriptor = %+v", descriptor) + } + if descriptor.Digest == "" || len(descriptor.Files) == 0 { + t.Fatalf("descriptor lacks package metadata: %+v", descriptor) + } +} + +func TestRunRejectsInvalidDistributionWithoutLeakingDetails(t *testing.T) { + var output, errorOutput bytes.Buffer + code := run([]string{"--file", "-"}, bytes.NewReader([]byte("not an archive")), &output, &errorOutput) + if code == 0 || output.Len() != 0 { + t.Fatalf("run() code=%d output=%q", code, output.String()) + } + if bytes.Contains(errorOutput.Bytes(), []byte("/")) { + t.Fatalf("error output contains a path: %q", errorOutput.String()) + } +} + +func testDistributionArchive(t *testing.T) []byte { + t.Helper() + files := map[string]string{ + "manifest.yaml": `id: canvas-board +api_version: 2 +version: 1.2.3 +display_name: Canvas Board +description: A board +author: Kandev +categories: [canvas] +min_kandev_version: 1.0.0 +distribution: + schema_version: 1 + kind: canvas + license: MIT + source_mode: static +ui: + web_apps: + - key: main + title: Board + entry: ui/index.html + placements: [workspace-canvas] +`, + "README.md": "A board.\n", + "ui/index.html": "", + "checksums.txt": "", + } + files["checksums.txt"] = checksumFile(files) + var compressed bytes.Buffer + zw := gzip.NewWriter(&compressed) + tw := tar.NewWriter(zw) + names := make([]string, 0, len(files)) + for name := range files { + names = append(names, name) + } + sort.Strings(names) + for _, name := range names { + data := []byte(files[name]) + if err := tw.WriteHeader(&tar.Header{Name: name, Mode: 0o600, Size: int64(len(data)), Typeflag: tar.TypeReg}); err != nil { + t.Fatalf("tar header: %v", err) + } + if _, err := io.Copy(tw, bytes.NewReader(data)); err != nil { + t.Fatalf("tar data: %v", err) + } + } + if err := tw.Close(); err != nil { + t.Fatalf("tar close: %v", err) + } + if err := zw.Close(); err != nil { + t.Fatalf("gzip close: %v", err) + } + return compressed.Bytes() +} + +func checksumFile(files map[string]string) string { + names := make([]string, 0, len(files)) + for name := range files { + if name != "checksums.txt" { + names = append(names, name) + } + } + sort.Strings(names) + var output string + for _, name := range names { + sum := sha256.Sum256([]byte(files[name])) + output += fmt.Sprintf("%x %s\n", sum, name) + } + return output +} diff --git a/apps/backend/internal/agentctl/server/api/workspace_canvas_source.go b/apps/backend/internal/agentctl/server/api/workspace_canvas_source.go index aadea52e3a..b2a9429652 100644 --- a/apps/backend/internal/agentctl/server/api/workspace_canvas_source.go +++ b/apps/backend/internal/agentctl/server/api/workspace_canvas_source.go @@ -16,6 +16,7 @@ import ( "github.com/gin-gonic/gin" agentctltypes "github.com/kandev/kandev/internal/agentctl/types" + "github.com/kandev/kandev/internal/plugins/webapp" "go.uber.org/zap" ) @@ -185,6 +186,13 @@ func (c *canvasSourceCollector) visit(currentPath string, entry fs.DirEntry, wal if relativePath == ".canvas-root" { return nil } + if err := webapp.ValidateCanvasSourcePath(relativePath, info.IsDir()); err != nil { + code := "source_contains_unsupported_file" + if errors.Is(err, webapp.ErrUnsafeSource) { + code = "source_contains_excluded_file" + } + return newCanvasSourceError(code, "canvas source contains an excluded or unsupported file", err) + } if len([]byte(relativePath)) > 240 { return newCanvasSourceLimitError(errors.New("source path is too long")) } diff --git a/apps/backend/internal/agentctl/server/api/workspace_canvas_source_test.go b/apps/backend/internal/agentctl/server/api/workspace_canvas_source_test.go index a11f735bf3..40b8cbc8ae 100644 --- a/apps/backend/internal/agentctl/server/api/workspace_canvas_source_test.go +++ b/apps/backend/internal/agentctl/server/api/workspace_canvas_source_test.go @@ -87,6 +87,24 @@ func TestCanvasSourceTransfer_StreamsWorkspaceRelativeRoot(t *testing.T) { }, readCanvasTar(t, response.Body.Bytes())) } +func TestCanvasSourceTransfer_RetainsRuntimeAssets(t *testing.T) { + server, workDir := newCanvasSourceTestServer(t) + root := filepath.Join(workDir, "source") + require.NoError(t, os.MkdirAll(filepath.Join(root, "assets"), 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(root, "index.html"), []byte("
ok
"), 0o644)) + require.NoError(t, os.WriteFile(filepath.Join(root, "assets", "logo.svg"), []byte(""), 0o644)) + require.NoError(t, os.WriteFile(filepath.Join(root, "assets", "body.woff2"), []byte("font-bytes"), 0o644)) + + response := canvasSourceRequest(t, server, "source", true) + require.Equal(t, http.StatusOK, response.Code, response.Body.String()) + require.Equal(t, map[string]string{ + "index.html": "
ok
", + "assets/": "", + "assets/logo.svg": "", + "assets/body.woff2": "font-bytes", + }, readCanvasTar(t, response.Body.Bytes())) +} + func TestCanvasSourceTransfer_RejectsTraversalAndSymlinks(t *testing.T) { server, workDir := newCanvasSourceTestServer(t) require.NoError(t, os.Mkdir(filepath.Join(workDir, "source"), 0o755)) @@ -121,7 +139,7 @@ func TestCanvasSourceTransfer_RejectsFileDataLimit(t *testing.T) { server, workDir := newCanvasSourceTestServer(t) root := filepath.Join(workDir, "source") require.NoError(t, os.Mkdir(root, 0o755)) - file, err := os.Create(filepath.Join(root, "large.bin")) + file, err := os.Create(filepath.Join(root, "large.txt")) require.NoError(t, err) require.NoError(t, file.Truncate(int64(agentctltypes.MaxCanvasSourceFileData+1))) require.NoError(t, file.Close()) @@ -129,3 +147,26 @@ func TestCanvasSourceTransfer_RejectsFileDataLimit(t *testing.T) { response := canvasSourceRequest(t, server, "source", true) require.Equal(t, http.StatusRequestEntityTooLarge, response.Code) } + +func TestCanvasSourceTransfer_RejectsExcludedProjectSource(t *testing.T) { + server, workDir := newCanvasSourceTestServer(t) + root := filepath.Join(workDir, "source", "distribution", "source") + require.NoError(t, os.MkdirAll(root, 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(root, ".env"), []byte("TOKEN=secret"), 0o600)) + + response := canvasSourceRequest(t, server, "source", true) + require.Equal(t, http.StatusBadRequest, response.Code) + require.NotContains(t, response.Body.String(), "secret") +} + +func TestCanvasSourceTransfer_RejectsExcludedWorkspaceSourceFiles(t *testing.T) { + server, workDir := newCanvasSourceTestServer(t) + root := filepath.Join(workDir, "source") + require.NoError(t, os.MkdirAll(filepath.Join(root, "node_modules", "pkg"), 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(root, ".env"), []byte("TOKEN=secret"), 0o600)) + require.NoError(t, os.WriteFile(filepath.Join(root, "node_modules", "pkg", "index.js"), []byte("secret"), 0o600)) + + response := canvasSourceRequest(t, server, "source", true) + require.Equal(t, http.StatusBadRequest, response.Code) + require.NotContains(t, response.Body.String(), "secret") +} diff --git a/apps/backend/internal/backendapp/canvas_distribution_routes.go b/apps/backend/internal/backendapp/canvas_distribution_routes.go new file mode 100644 index 0000000000..4122b42f0d --- /dev/null +++ b/apps/backend/internal/backendapp/canvas_distribution_routes.go @@ -0,0 +1,288 @@ +package backendapp + +import ( + "errors" + "io" + "net/http" + "strings" + + "github.com/gin-gonic/gin" + + canvasservice "github.com/kandev/kandev/internal/canvas" + plugininstances "github.com/kandev/kandev/internal/plugins/instances" + "github.com/kandev/kandev/internal/plugins/webapp" +) + +func registerCanvasDistributionRoutes(router *gin.Engine, handler *canvasHTTPHandler) { + if router == nil || handler == nil || handler.distribution == nil { + return + } + router.POST("/api/v1/canvases/:canvasID/exports", handler.prepareCanvasExport) + router.GET("/api/v1/canvases/exports/:preparationID", handler.getCanvasExport) + router.GET("/api/v1/canvases/exports/:preparationID/bundle", handler.downloadCanvasBundle) + router.GET("/api/v1/canvases/exports/:preparationID/source", handler.downloadCanvasSource) + router.DELETE("/api/v1/canvases/exports/:preparationID", handler.cancelCanvasExport) + router.POST("/api/v1/canvases/install-preparations", handler.prepareCanvasInstall) + router.GET("/api/v1/canvases/install-preparations/:preparationID", handler.getCanvasInstall) + router.POST("/api/v1/canvases/install-preparations/:preparationID/confirm", handler.confirmCanvasInstall) + router.DELETE("/api/v1/canvases/install-preparations/:preparationID", handler.cancelCanvasInstall) +} + +type canvasExportRequest struct { + WorkspaceID string `json:"workspace_id"` + ExpectedReleaseID string `json:"expected_release_id"` + Metadata canvasservice.ExportMetadata `json:"metadata"` + PackageID string `json:"package_id,omitempty"` + Version string `json:"version,omitempty"` + DisplayName string `json:"display_name,omitempty"` + Description string `json:"description,omitempty"` + Author string `json:"author,omitempty"` + License string `json:"license,omitempty"` + SourceMode string `json:"source_mode,omitempty"` + MinKandevVersion string `json:"min_kandev_version,omitempty"` + RepoURL string `json:"repo_url,omitempty"` +} + +type canvasInstallRequest struct { + WorkspaceID string `json:"workspace_id"` + OriginKind string `json:"origin_kind,omitempty"` + SourceID string `json:"source_id,omitempty"` + RepositoryURL string `json:"repository_url,omitempty"` + PackageID string `json:"package_id,omitempty"` + ExpectedVersion string `json:"expected_version,omitempty"` + ExpectedDigest string `json:"expected_sha256,omitempty"` + BundleURL string `json:"bundle_url,omitempty"` +} + +func (r canvasInstallRequest) serviceRequest(userID string, bundle []byte) canvasservice.InstallRequest { + return canvasservice.InstallRequest{ + UserID: userID, WorkspaceID: strings.TrimSpace(r.WorkspaceID), OriginKind: strings.TrimSpace(r.OriginKind), + SourceID: strings.TrimSpace(r.SourceID), RepositoryURL: strings.TrimSpace(r.RepositoryURL), + PackageID: strings.TrimSpace(r.PackageID), ExpectedVersion: strings.TrimSpace(r.ExpectedVersion), + ExpectedArchiveDigest: strings.TrimSpace(r.ExpectedDigest), Bundle: bundle, + } +} + +type canvasInstallConfirmationRequest struct { + canvasservice.InstallConfirmation +} + +func (r canvasExportRequest) metadata() canvasservice.ExportMetadata { + metadata := r.Metadata + if metadata.PackageID == "" { + metadata = canvasservice.ExportMetadata{ + PackageID: r.PackageID, Version: r.Version, DisplayName: r.DisplayName, + Description: r.Description, Author: r.Author, License: r.License, + SourceMode: r.SourceMode, MinKandevVersion: r.MinKandevVersion, RepoURL: r.RepoURL, + } + } + return metadata +} + +func (h *canvasHTTPHandler) prepareCanvasExport(c *gin.Context) { + var request canvasExportRequest + if err := c.ShouldBindJSON(&request); err != nil { + writeCanvasError(c, http.StatusBadRequest, "invalid_request", nil) + return + } + request.WorkspaceID = strings.TrimSpace(request.WorkspaceID) + if request.WorkspaceID == "" || strings.TrimSpace(request.ExpectedReleaseID) == "" { + writeCanvasError(c, http.StatusBadRequest, "invalid_request", nil) + return + } + review, err := h.distribution.PrepareExport(c.Request.Context(), canvasservice.ExportRequest{ + UserID: canvasRuntimeUser(c), WorkspaceID: request.WorkspaceID, CanvasID: c.Param("canvasID"), + ExpectedReleaseID: request.ExpectedReleaseID, Metadata: request.metadata(), + }) + if err != nil { + writeCanvasDistributionError(c, err) + return + } + writeCanvasJSON(c, http.StatusOK, review) +} + +func (h *canvasHTTPHandler) getCanvasExport(c *gin.Context) { + review, err := h.distribution.GetExport(c.Request.Context(), canvasRuntimeUser(c), c.Param("preparationID")) + if err != nil { + writeCanvasDistributionError(c, err) + return + } + writeCanvasJSON(c, http.StatusOK, review) +} + +func (h *canvasHTTPHandler) downloadCanvasBundle(c *gin.Context) { + h.downloadCanvasExport(c, canvasservice.ExportBundle) +} + +func (h *canvasHTTPHandler) downloadCanvasSource(c *gin.Context) { + h.downloadCanvasExport(c, canvasservice.ExportSource) +} + +func (h *canvasHTTPHandler) downloadCanvasExport(c *gin.Context, kind canvasservice.ExportKind) { + download, err := h.distribution.DownloadExport(c.Request.Context(), canvasRuntimeUser(c), c.Param("preparationID"), kind) + if err != nil { + writeCanvasDistributionError(c, err) + return + } + c.Header("Cache-Control", "private, no-store") + c.Header("X-Content-Type-Options", "nosniff") + c.Header("Content-Disposition", `attachment; filename="`+download.Filename+`"`) + c.Data(http.StatusOK, download.ContentType, download.Data) +} + +func (h *canvasHTTPHandler) cancelCanvasExport(c *gin.Context) { + if err := h.distribution.CancelExport(c.Request.Context(), canvasRuntimeUser(c), c.Param("preparationID")); err != nil { + writeCanvasDistributionError(c, err) + return + } + c.Status(http.StatusNoContent) +} + +func (h *canvasHTTPHandler) prepareCanvasInstall(c *gin.Context) { + if strings.HasPrefix(c.ContentType(), "multipart/form-data") { + h.prepareCanvasInstallUpload(c) + return + } + var request canvasInstallRequest + if err := c.ShouldBindJSON(&request); err != nil || (strings.TrimSpace(request.BundleURL) == "" && (strings.TrimSpace(request.SourceID) == "" || strings.TrimSpace(request.PackageID) == "" || strings.TrimSpace(request.ExpectedVersion) == "" || strings.TrimSpace(request.ExpectedDigest) == "")) { + writeCanvasError(c, http.StatusBadRequest, "invalid_request", nil) + return + } + if strings.TrimSpace(request.WorkspaceID) == "" { + writeCanvasError(c, http.StatusBadRequest, "invalid_request", nil) + return + } + serviceRequest := request.serviceRequest(canvasRuntimeUser(c), nil) + var review canvasservice.InstallReview + var err error + if strings.TrimSpace(request.BundleURL) != "" { + review, err = h.distribution.PrepareInstallFromURL(c.Request.Context(), serviceRequest, request.BundleURL) + } else { + review, err = h.distribution.PrepareInstallFromCatalog(c.Request.Context(), serviceRequest) + } + if err != nil { + writeCanvasDistributionError(c, err) + return + } + writeCanvasJSON(c, http.StatusOK, review) +} + +func (h *canvasHTTPHandler) prepareCanvasInstallUpload(c *gin.Context) { + c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, webapp.MaxCompressedBytes+1<<20) + fileHeader, err := c.FormFile("package") + if err != nil || fileHeader.Size > webapp.MaxCompressedBytes { + writeCanvasDistributionError(c, webapp.ErrCompressedTooLarge) + return + } + file, err := fileHeader.Open() + if err != nil { + writeCanvasDistributionError(c, canvasservice.ErrInstallInvalid) + return + } + defer func() { _ = file.Close() }() + bundle, err := io.ReadAll(io.LimitReader(file, webapp.MaxCompressedBytes+1)) + if err != nil || int64(len(bundle)) > webapp.MaxCompressedBytes { + writeCanvasDistributionError(c, webapp.ErrCompressedTooLarge) + return + } + request := canvasInstallRequest{ + WorkspaceID: c.PostForm("workspace_id"), OriginKind: "upload", SourceID: c.PostForm("source_id"), + RepositoryURL: c.PostForm("repository_url"), PackageID: c.PostForm("package_id"), + ExpectedVersion: c.PostForm("expected_version"), ExpectedDigest: c.PostForm("expected_sha256"), + } + if strings.TrimSpace(request.WorkspaceID) == "" { + writeCanvasError(c, http.StatusBadRequest, "invalid_request", nil) + return + } + review, err := h.distribution.PrepareInstall(c.Request.Context(), request.serviceRequest(canvasRuntimeUser(c), bundle)) + if err != nil { + writeCanvasDistributionError(c, err) + return + } + writeCanvasJSON(c, http.StatusOK, review) +} + +func (h *canvasHTTPHandler) getCanvasInstall(c *gin.Context) { + review, err := h.distribution.GetInstallReview(c.Request.Context(), canvasRuntimeUser(c), c.Param("preparationID")) + if err != nil { + writeCanvasDistributionError(c, err) + return + } + writeCanvasJSON(c, http.StatusOK, review) +} + +func (h *canvasHTTPHandler) confirmCanvasInstall(c *gin.Context) { + var request canvasInstallConfirmationRequest + if err := c.ShouldBindJSON(&request); err != nil { + writeCanvasError(c, http.StatusBadRequest, "invalid_request", nil) + return + } + result, err := h.distribution.ConfirmInstall(c.Request.Context(), canvasRuntimeUser(c), c.Param("preparationID"), request.InstallConfirmation) + if err != nil { + writeCanvasDistributionError(c, err) + return + } + writeCanvasJSON(c, http.StatusOK, result) +} + +func (h *canvasHTTPHandler) cancelCanvasInstall(c *gin.Context) { + if err := h.distribution.CancelInstall(c.Request.Context(), canvasRuntimeUser(c), c.Param("preparationID")); err != nil { + writeCanvasDistributionError(c, err) + return + } + c.Status(http.StatusNoContent) +} + +func writeCanvasDistributionError(c *gin.Context, err error) { + status, code := canvasDistributionErrorStatus(err) + writeCanvasError(c, status, code, nil) +} + +func canvasDistributionErrorStatus(err error) (int, string) { + switch { + case errors.Is(err, canvasservice.ErrCanvasNotFound): + return http.StatusNotFound, "canvas_not_found" + case errors.Is(err, canvasservice.ErrPreparationNotFound): + return http.StatusNotFound, "review_not_found" + case errors.Is(err, canvasservice.ErrPreparationExpired): + return http.StatusConflict, "review_expired" + case errors.Is(err, canvasservice.ErrExportStale): + return http.StatusConflict, "review_stale" + case errors.Is(err, canvasservice.ErrExportInvalid): + return http.StatusBadRequest, "invalid_request" + case isCanvasInvalidInstallError(err): + return http.StatusBadRequest, "invalid_install" + case errors.Is(err, canvasservice.ErrInstallIncompatible): + return http.StatusConflict, "incompatible_install" + case errors.Is(err, canvasservice.ErrInstallURLBlocked): + return http.StatusBadRequest, "install_url_blocked" + case errors.Is(err, webapp.ErrCompressedTooLarge): + return http.StatusRequestEntityTooLarge, "package_too_large" + case errors.Is(err, canvasservice.ErrInstallUnavailable): + return http.StatusServiceUnavailable, "install_unavailable" + case errors.Is(err, canvasservice.ErrInstallReceiptNotFound): + return http.StatusNotFound, "install_not_found" + case errors.Is(err, canvasservice.ErrPreparationLimit), isCanvasStorageLimitError(err): + return http.StatusConflict, "storage_limit_reached" + case isCanvasSourceUnavailableError(err): + return http.StatusConflict, "source_unavailable" + default: + return http.StatusInternalServerError, "internal_error" + } +} + +func isCanvasStorageLimitError(err error) bool { + return errors.Is(err, plugininstances.ErrWorkspaceStorageLimit) || + errors.Is(err, plugininstances.ErrInstallationStorageLimit) +} + +func isCanvasInvalidInstallError(err error) bool { + return errors.Is(err, canvasservice.ErrInstallInvalid) || + errors.Is(err, canvasservice.ErrInstallNotApproved) || + errors.Is(err, canvasservice.ErrPackageDigestMismatch) || + errors.Is(err, canvasservice.ErrInstallURLInvalid) +} + +func isCanvasSourceUnavailableError(err error) bool { + return errors.Is(err, webapp.ErrSourceUnavailable) || errors.Is(err, webapp.ErrArtifactUnavailable) +} diff --git a/apps/backend/internal/backendapp/canvas_distribution_routes_test.go b/apps/backend/internal/backendapp/canvas_distribution_routes_test.go new file mode 100644 index 0000000000..f62973e9aa --- /dev/null +++ b/apps/backend/internal/backendapp/canvas_distribution_routes_test.go @@ -0,0 +1,23 @@ +package backendapp + +import ( + "errors" + "net/http" + "testing" + + canvasservice "github.com/kandev/kandev/internal/canvas" +) + +func TestCanvasDistributionErrorStatusUsesInternalErrorForUnexpectedFailures(t *testing.T) { + status, code := canvasDistributionErrorStatus(errors.New("database unavailable")) + if status != http.StatusInternalServerError || code != "internal_error" { + t.Fatalf("status = %d %q, want 500 internal_error", status, code) + } +} + +func TestCanvasDistributionErrorStatusMapsIncompatibleInstall(t *testing.T) { + status, code := canvasDistributionErrorStatus(canvasservice.ErrInstallIncompatible) + if status != http.StatusConflict || code != "incompatible_install" { + t.Fatalf("status = %d %q, want 409 incompatible_install", status, code) + } +} diff --git a/apps/backend/internal/backendapp/canvas_routes.go b/apps/backend/internal/backendapp/canvas_routes.go index ea9feb341c..00cbf927b6 100644 --- a/apps/backend/internal/backendapp/canvas_routes.go +++ b/apps/backend/internal/backendapp/canvas_routes.go @@ -30,10 +30,11 @@ import ( // and host operations. Source transfer and publishing stay on the agent MCP // path; browsers receive only metadata and short-lived runtime capabilities. type canvasHTTPHandler struct { - canvases *canvasservice.Service - plugins *plugins.Service - tasks *taskservice.Service - editor *canvasEditService + canvases *canvasservice.Service + plugins *plugins.Service + tasks *taskservice.Service + editor *canvasEditService + distribution *canvasservice.DistributionService } type canvasEditTaskStore interface { @@ -337,9 +338,10 @@ func registerCanvasRoutes(p routeParams) { return } h := &canvasHTTPHandler{ - canvases: p.services.Canvas, - plugins: p.services.Plugins, - tasks: p.taskSvc, + canvases: p.services.Canvas, + plugins: p.services.Plugins, + tasks: p.taskSvc, + distribution: p.services.CanvasDistribution, } if p.taskSvc != nil && p.taskRepo != nil && p.orchestratorSvc != nil && p.lifecycleMgr != nil { instanceStore := p.services.Plugins.Instances() @@ -361,6 +363,7 @@ func registerCanvasRoutes(p routeParams) { p.router.GET("/api/v1/workspaces/:id/canvases", h.listWorkspace) p.router.GET("/api/v1/canvases/workspace/:workspaceID", h.listWorkspace) p.router.GET("/api/v1/canvases/workspaces/:workspaceID", h.listWorkspace) + registerCanvasDistributionRoutes(p.router, h) p.router.GET("/api/v1/canvases/:canvasID", h.get) p.router.GET("/api/v1/canvases/:canvasID/releases", h.releases) p.router.GET("/api/v1/canvases/:canvasID/promotion-preview", h.promotionPreview) diff --git a/apps/backend/internal/backendapp/services.go b/apps/backend/internal/backendapp/services.go index a048706c50..a96f0a1e91 100644 --- a/apps/backend/internal/backendapp/services.go +++ b/apps/backend/internal/backendapp/services.go @@ -323,6 +323,26 @@ func provideServices(cfg *config.Config, log *logger.Logger, repos *Repositories if err != nil { return nil, nil, fmt.Errorf("initialize canvas service: %w", err) } + var canvasDistributionSvc *canvasservice.DistributionService + if cfg.Features.Canvases { + preparations, prepErr := canvasservice.NewPreparationStore(filepath.Join(cfg.ResolvedHomeDir(), "canvas-preparations")) + if prepErr != nil { + return nil, nil, fmt.Errorf("initialize canvas preparations: %w", prepErr) + } + canvasDistributionSvc = canvasservice.NewDistributionService( + canvasSvc, + pluginsSvc.Instances(), + pluginsSvc.WebArtifacts(), + taskSvc.AuthorizeWorkspaceAccess, + preparations, + ) + canvasDistributionSvc.SetKandevVersion(version) + canvasDistributionSvc.SetInstallReceiptStore(canvasRepo) + canvasDistributionSvc.SetArtifactQuota(pluginsSvc.Instances()) + if pluginsSvc != nil { + canvasDistributionSvc.SetCatalogResolver(pluginsSvc.Marketplace()) + } + } gitCredentialBroker := newGitCredentialBroker(githubSvc, pluginsSvc, repos.Task, cfg.GitHubCredentialBroker.ReissueSigningKey) if pluginsSvc != nil { pluginsSvc.SetGitCredentialLeaseRevoker(gitCredentialBroker.RevokeProvider) @@ -415,6 +435,7 @@ func provideServices(cfg *config.Config, log *logger.Logger, repos *Repositories Automation: automationComponents, Plugins: pluginsSvc, Canvas: canvasSvc, + CanvasDistribution: canvasDistributionSvc, GitCredentials: gitCredentialBroker, // Office is constructed later in initOfficeServices once all // of its dependencies (config loader, task integrations, etc.) are available. diff --git a/apps/backend/internal/backendapp/types.go b/apps/backend/internal/backendapp/types.go index 84878a81fd..7d7cfc87d6 100644 --- a/apps/backend/internal/backendapp/types.go +++ b/apps/backend/internal/backendapp/types.go @@ -135,6 +135,9 @@ type Services struct { // Canvas is the gated lifecycle service for agent-authored plugin web // applications. It is nil while features.canvases is disabled. Canvas *canvasservice.Service + // CanvasDistribution owns bounded, user-bound export and installation + // preparations. It is nil while features.canvases is disabled. + CanvasDistribution *canvasservice.DistributionService // GitCredentials is the shared provider-neutral lease broker used by the // GitHub HTTP endpoint and task executor helper leases. GitCredentials *gitcredentials.Broker diff --git a/apps/backend/internal/canvas/authoring.go b/apps/backend/internal/canvas/authoring.go index 43a0cabf67..a4827040f8 100644 --- a/apps/backend/internal/canvas/authoring.go +++ b/apps/backend/internal/canvas/authoring.go @@ -95,9 +95,11 @@ type authoringInstanceStore interface { type transactionalAuthoringStore interface { authoringInstanceStore WithTransaction(context.Context, func(*sqlx.Tx) error) error + CreateTx(context.Context, *sqlx.Tx, plugininstances.Instance) error CreateReleaseTx(context.Context, *sqlx.Tx, plugininstances.Release) error SetPluginIDTx(context.Context, *sqlx.Tx, string, string) error ActivateReleaseTx(context.Context, *sqlx.Tx, string, string) error + ApproveReleaseTx(context.Context, *sqlx.Tx, string, string, string, []plugininstances.Grant) error } type reviewedPromotionStore interface { @@ -174,6 +176,104 @@ func (s *Service) PublishPackage(ctx context.Context, request PublishRequest) (* return result, nil } +// InstallCanvasPackage commits the canvas instance, its first release, and +// the durable installation receipt in the same lifecycle transaction. The +// artifact is written before entering the transaction and is owned by the +// release row only after this method commits. +func (s *Service) InstallCanvasPackage(ctx context.Context, request InstallCanvasPackageRequest) (*InstallResult, error) { + if err := s.ready(); err != nil { + return nil, err + } + store, createRequest, err := s.prepareCanvasInstall(request) + if err != nil { + return nil, err + } + + s.mu.Lock() + result, event, err := s.installCanvasPackageLocked(ctx, store, createRequest, request) + publisher := s.publisher + s.mu.Unlock() + if err != nil { + return result, err + } + publishEvent(ctx, publisher, event) + return result, nil +} + +func (s *Service) prepareCanvasInstall(request InstallCanvasPackageRequest) (transactionalAuthoringStore, CreateCanvasRequest, error) { + if request.Package == nil || request.Package.Manifest == nil || request.Artifact.Digest == "" || s.repo == nil { + return nil, CreateCanvasRequest{}, fmt.Errorf("%w: package and artifact are required", ErrInvalidCanvas) + } + if !request.Approved || strings.TrimSpace(request.Receipt.UserID) == "" { + return nil, CreateCanvasRequest{}, ErrInstallNotApproved + } + store, ok := s.instances.(transactionalAuthoringStore) + if !ok { + return nil, CreateCanvasRequest{}, ErrCanvasNotConfigured + } + createRequest, err := normalizeCreateRequest(CreateCanvasRequest{WorkspaceID: request.WorkspaceID, Title: request.Title, PluginID: CanvasPluginID}) + if err != nil { + return nil, CreateCanvasRequest{}, err + } + return store, createRequest, nil +} + +func (s *Service) installCanvasPackageLocked(ctx context.Context, store transactionalAuthoringStore, createRequest CreateCanvasRequest, request InstallCanvasPackageRequest) (*InstallResult, LifecycleEvent, error) { + now := s.nowUTC() + metadata := CanvasMetadata{ID: uuid.NewString(), PluginInstanceID: uuid.NewString(), WorkspaceID: createRequest.WorkspaceID, Title: createRequest.Title, CreatedAt: now, UpdatedAt: now} + instance := plugininstances.Instance{ID: metadata.PluginInstanceID, PluginID: createRequest.PluginID, SourceKind: plugininstances.SourceLocalCanvas, ScopeKind: ScopeWorkspace, WorkspaceID: createRequest.WorkspaceID, Status: plugininstances.StatusPending, CreatedAt: now, UpdatedAt: now} + publishRequest := PublishRequest{CanvasID: metadata.ID, Package: request.Package, Artifact: request.Artifact, SourceActorKind: request.SourceActorKind, SourceUserID: request.SourceUserID} + preparedInstance, release, activated, err := s.preparePublishedReleaseForInstance(ctx, store, instance, publishRequest) + if err != nil { + return nil, LifecycleEvent{}, err + } + if preparedInstance.ID != instance.ID { + return nil, LifecycleEvent{}, ErrInvalidCanvas + } + receipt := request.Receipt + receipt.CanvasID = metadata.ID + receipt.WorkspaceID = metadata.WorkspaceID + if receipt.CreatedAt.IsZero() { + receipt.CreatedAt = now + } + if err := s.commitCanvasInstallation(ctx, store, instance, metadata, release, activated, receipt, request.Package.Manifest); err != nil { + return nil, LifecycleEvent{}, err + } + installed, err := s.Get(ctx, metadata.ID) + if err != nil { + return &InstallResult{Receipt: receipt}, LifecycleEvent{}, err + } + return &InstallResult{Canvas: installed, Receipt: receipt}, lifecycleEvent(EventReleaseActivated, *installed), nil +} + +func (s *Service) commitCanvasInstallation(ctx context.Context, store transactionalAuthoringStore, instance plugininstances.Instance, metadata CanvasMetadata, release plugininstances.Release, activated bool, receipt InstallReceipt, canvasManifest *manifest.Manifest) error { + return store.WithTransaction(ctx, func(tx *sqlx.Tx) error { + if err := store.CreateTx(ctx, tx, instance); err != nil { + return err + } + if err := s.repo.CreateTx(ctx, tx, metadata); err != nil { + return err + } + if err := store.CreateReleaseTx(ctx, tx, release); err != nil { + return err + } + if err := activateOrApproveCanvasRelease(ctx, store, tx, instance.ID, release, activated, receipt.UserID, canvasManifest); err != nil { + return err + } + return s.repo.CreateInstallReceiptTx(ctx, tx, receipt) + }) +} + +func activateOrApproveCanvasRelease(ctx context.Context, store transactionalAuthoringStore, tx *sqlx.Tx, instanceID string, release plugininstances.Release, activated bool, userID string, canvasManifest *manifest.Manifest) error { + if !activated { + return store.ApproveReleaseTx(ctx, tx, instanceID, release.ID, userID, grantsForManifest(ManifestPermissions(canvasManifest), userID, ScopeWorkspace)) + } + if err := store.SetPluginIDTx(ctx, tx, instanceID, release.PluginID); err != nil { + return err + } + return store.ActivateReleaseTx(ctx, tx, instanceID, release.ID) +} + func (s *Service) preparePublishedRelease(ctx context.Context, store authoringInstanceStore, request PublishRequest) (plugininstances.Instance, plugininstances.Release, bool, CreationAuthority, []plugininstances.Grant, error) { if err := validatePublishRequest(request); err != nil { return plugininstances.Instance{}, plugininstances.Release{}, false, CreationAuthority{}, nil, err @@ -205,6 +305,35 @@ func (s *Service) preparePublishedRelease(ctx context.Context, store authoringIn return instance, release, activated, creationAuthority, initialGrants, nil } +func (s *Service) preparePublishedReleaseForInstance(ctx context.Context, store authoringInstanceStore, instance plugininstances.Instance, request PublishRequest) (plugininstances.Instance, plugininstances.Release, bool, error) { + if request.Package == nil || request.Package.Manifest == nil || request.Artifact.Digest == "" { + return plugininstances.Instance{}, plugininstances.Release{}, false, fmt.Errorf("%w: package and artifact are required", ErrInvalidCanvas) + } + if instance.Status == StatusRemoved || instance.Status == StatusArchived { + return plugininstances.Instance{}, plugininstances.Release{}, false, fmt.Errorf("%w: canvas is %s", ErrInvalidCanvasState, instance.Status) + } + if !request.ExpectedAuthority.IsZero() && request.ExpectedAuthority != instance.PublishAuthority() { + return plugininstances.Instance{}, plugininstances.Release{}, false, ErrStaleCanvasPublish + } + if request.Artifact.Digest != request.Package.Digest { + return plugininstances.Instance{}, plugininstances.Release{}, false, fmt.Errorf("%w: artifact digest does not match package", ErrInvalidCanvas) + } + if err := request.Package.Manifest.Validate(); err != nil { + return plugininstances.Instance{}, plugininstances.Release{}, false, fmt.Errorf("%w: invalid manifest: %v", ErrInvalidCanvas, err) + } + permissions := ManifestPermissions(request.Package.Manifest) + grants, err := store.ListGrants(ctx, instance.ID) + if err != nil { + return plugininstances.Instance{}, plugininstances.Release{}, false, err + } + activated := permissionsFit(permissions, instance.ScopeKind, grants) + release, err := s.buildPublishedRelease(request, instance.ID, permissions, activated) + if err != nil { + return plugininstances.Instance{}, plugininstances.Release{}, false, err + } + return instance, release, activated, nil +} + func validatePublishRequest(request PublishRequest) error { if request.Package == nil || request.Package.Manifest == nil || request.Artifact.Digest == "" { return fmt.Errorf("%w: package and artifact are required", ErrInvalidCanvas) diff --git a/apps/backend/internal/canvas/authoring_test.go b/apps/backend/internal/canvas/authoring_test.go index c382a23ec2..3debf4dc9d 100644 --- a/apps/backend/internal/canvas/authoring_test.go +++ b/apps/backend/internal/canvas/authoring_test.go @@ -419,6 +419,48 @@ func TestCanvasCreationAuthorityRollsBackOnActivationFailure(t *testing.T) { } } +func TestInstallCanvasPackageCommitsReceiptWithCanvasLifecycle(t *testing.T) { + service, instanceStore, _ := newCanvasService(t) + ctx := context.Background() + result, err := service.InstallCanvasPackage(ctx, InstallCanvasPackageRequest{ + WorkspaceID: "workspace-install", + Title: "Installed board", + Package: testCanvasPackage("atomic-install", nil), + Artifact: webapp.Artifact{Digest: "atomic-install", RelativePath: "releases/atomic-install", Bytes: 1}, + SourceActorKind: "canvas-install:upload", + SourceUserID: "user-1", + Approved: true, + Receipt: InstallReceipt{ + PreparationID: "preparation-atomic", + UserID: "user-1", + PackageID: "canvas-board", + Version: "atomic-install", + Digest: "atomic-install", + OriginKind: "upload", + }, + }) + if err != nil { + t.Fatalf("InstallCanvasPackage() error = %v", err) + } + if result == nil || result.Canvas == nil || result.Receipt.CanvasID != result.Canvas.ID { + t.Fatalf("install result = %+v", result) + } + receipt, err := service.repo.GetInstallReceipt(ctx, "preparation-atomic", "user-1") + if err != nil { + t.Fatalf("GetInstallReceipt() error = %v", err) + } + if receipt.CanvasID != result.Canvas.ID || receipt.WorkspaceID != "workspace-install" { + t.Fatalf("receipt = %+v", receipt) + } + releases, err := instanceStore.ListReleases(ctx, result.Canvas.PluginInstanceID) + if err != nil { + t.Fatalf("ListReleases() error = %v", err) + } + if len(releases) != 1 || releases[0].PackageDigest != "atomic-install" { + t.Fatalf("releases = %+v", releases) + } +} + func TestFirstTaskReleaseCanBeReviewedAndApproved(t *testing.T) { service, instanceStore, _ := newCanvasService(t) canvas := createCanvas(t, service, CreateCanvasRequest{ diff --git a/apps/backend/internal/canvas/distribution_download.go b/apps/backend/internal/canvas/distribution_download.go new file mode 100644 index 0000000000..aa7b4332ac --- /dev/null +++ b/apps/backend/internal/canvas/distribution_download.go @@ -0,0 +1,169 @@ +package canvas + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "errors" + "fmt" + "io" + "net" + "net/http" + "net/netip" + "net/url" + "strings" + "time" + + "github.com/kandev/kandev/internal/plugins/webapp" +) + +func archiveDigest(data []byte) string { + digest := sha256.Sum256(data) + return hex.EncodeToString(digest[:]) +} + +var ( + ErrInstallURLInvalid = errors.New("invalid canvas install URL") + ErrInstallURLBlocked = errors.New("canvas install URL destination is not public") + ErrInstallDownload = errors.New("canvas install download failed") +) + +const ( + installDownloadTimeout = 2 * time.Minute + installMaxRedirects = 5 +) + +func newDistributionHTTPClient() *http.Client { + transport := &http.Transport{ + Proxy: nil, + DialContext: publicDistributionDialContext, + TLSHandshakeTimeout: 10 * time.Second, + ResponseHeaderTimeout: 20 * time.Second, + IdleConnTimeout: 30 * time.Second, + ForceAttemptHTTP2: true, + MaxIdleConnsPerHost: 2, + DisableCompression: false, + } + return &http.Client{ + Transport: transport, + Timeout: installDownloadTimeout, + CheckRedirect: func(req *http.Request, via []*http.Request) error { + if len(via) >= installMaxRedirects { + return ErrInstallURLInvalid + } + if _, err := validateInstallURL(req.URL.String()); err != nil { + return err + } + return nil + }, + } +} + +func (s *DistributionService) downloadInstallBundle(ctx context.Context, rawURL string) ([]byte, error) { + parsed, err := validateInstallURL(rawURL) + if err != nil { + return nil, err + } + s.installMu.Lock() + client := s.httpClient + s.installMu.Unlock() + if client == nil { + return nil, ErrInstallDownload + } + requestContext, cancel := context.WithTimeout(ctx, installDownloadTimeout) + defer cancel() + req, err := http.NewRequestWithContext(requestContext, http.MethodGet, parsed.String(), nil) + if err != nil { + return nil, fmt.Errorf("%w: request could not be created", ErrInstallURLInvalid) + } + req.Header.Set("Accept", "application/gzip, application/octet-stream") + resp, err := client.Do(req) + if err != nil { + return nil, fmt.Errorf("%w: %v", ErrInstallDownload, safeDownloadError(err)) + } + defer func() { _ = resp.Body.Close() }() + if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices { + return nil, fmt.Errorf("%w: server returned %s", ErrInstallDownload, resp.Status) + } + if resp.ContentLength > webapp.MaxCompressedBytes { + return nil, webapp.ErrCompressedTooLarge + } + data, err := io.ReadAll(io.LimitReader(resp.Body, webapp.MaxCompressedBytes+1)) + if err != nil { + return nil, fmt.Errorf("%w: response could not be read", ErrInstallDownload) + } + if int64(len(data)) > webapp.MaxCompressedBytes { + return nil, webapp.ErrCompressedTooLarge + } + return data, nil +} + +func validateInstallURL(raw string) (*url.URL, error) { + parsed, err := url.ParseRequestURI(strings.TrimSpace(raw)) + if err != nil || parsed == nil { + return nil, ErrInstallURLInvalid + } + if parsed.Scheme != "https" || parsed.Hostname() == "" || parsed.User != nil || parsed.Fragment != "" || parsed.Opaque != "" { + return nil, ErrInstallURLInvalid + } + if ip := net.ParseIP(parsed.Hostname()); ip != nil && !isPublicInstallIP(ip) { + return nil, ErrInstallURLBlocked + } + return parsed, nil +} + +func publicDistributionDialContext(ctx context.Context, network, address string) (net.Conn, error) { + host, port, err := net.SplitHostPort(address) + if err != nil { + return nil, ErrInstallURLBlocked + } + if ip := net.ParseIP(host); ip != nil { + if !isPublicInstallIP(ip) { + return nil, ErrInstallURLBlocked + } + return (&net.Dialer{}).DialContext(ctx, network, net.JoinHostPort(ip.String(), port)) + } + ips, err := net.LookupIP(host) + if err != nil || len(ips) == 0 { + return nil, ErrInstallURLBlocked + } + var lastErr error + for _, ip := range ips { + if !isPublicInstallIP(ip) { + continue + } + connection, dialErr := (&net.Dialer{}).DialContext(ctx, network, net.JoinHostPort(ip.String(), port)) + if dialErr == nil { + return connection, nil + } + lastErr = dialErr + } + if lastErr != nil { + return nil, lastErr + } + return nil, ErrInstallURLBlocked +} + +func isPublicInstallIP(value net.IP) bool { + parsed, err := netip.ParseAddr(value.String()) + if err != nil { + return false + } + if parsed.IsLoopback() || parsed.IsPrivate() || parsed.IsLinkLocalUnicast() || parsed.IsLinkLocalMulticast() || parsed.IsUnspecified() || parsed.IsMulticast() { + return false + } + if parsed.Is4() { + value4 := parsed.As4() + if value4[0] == 100 && value4[1] >= 64 && value4[1] <= 127 { + return false + } + } + return true +} + +func safeDownloadError(err error) error { + if err == nil { + return nil + } + return errors.New("remote download request failed") +} diff --git a/apps/backend/internal/canvas/distribution_download_test.go b/apps/backend/internal/canvas/distribution_download_test.go new file mode 100644 index 0000000000..25d9eef62e --- /dev/null +++ b/apps/backend/internal/canvas/distribution_download_test.go @@ -0,0 +1,60 @@ +package canvas + +import ( + "context" + "errors" + "io" + "net/http" + "testing" + "time" + + "github.com/kandev/kandev/internal/plugins/webapp" +) + +type blockingDownloadBody struct { + closed chan struct{} +} + +func (b *blockingDownloadBody) Read([]byte) (int, error) { + <-b.closed + return 0, io.EOF +} + +func (b *blockingDownloadBody) Close() error { + select { + case <-b.closed: + default: + close(b.closed) + } + return nil +} + +func TestDownloadInstallBundleClosesOversizedResponseWithoutDrainingIt(t *testing.T) { + body := &blockingDownloadBody{closed: make(chan struct{})} + service := NewDistributionService(nil, nil, nil, func(context.Context, string) error { return nil }, nil) + service.SetInstallHTTPClient(&http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) { + return &http.Response{StatusCode: http.StatusOK, ContentLength: webapp.MaxCompressedBytes + 1, Body: body}, nil + })}) + + result := make(chan error, 1) + go func() { + _, err := service.downloadInstallBundle(context.Background(), "https://example.test/too-large.tar.gz") + result <- err + }() + + select { + case err := <-result: + if !errors.Is(err, webapp.ErrCompressedTooLarge) { + t.Fatalf("download error = %v, want ErrCompressedTooLarge", err) + } + case <-time.After(2 * time.Second): + _ = body.Close() + t.Fatal("oversized response was drained instead of closed") + } +} + +type roundTripFunc func(*http.Request) (*http.Response, error) + +func (f roundTripFunc) RoundTrip(request *http.Request) (*http.Response, error) { + return f(request) +} diff --git a/apps/backend/internal/canvas/distribution_export.go b/apps/backend/internal/canvas/distribution_export.go new file mode 100644 index 0000000000..6e719e1c32 --- /dev/null +++ b/apps/backend/internal/canvas/distribution_export.go @@ -0,0 +1,413 @@ +package canvas + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "sort" + "strings" + "sync" + + plugininstances "github.com/kandev/kandev/internal/plugins/instances" + "github.com/kandev/kandev/internal/plugins/manifest" + "github.com/kandev/kandev/internal/plugins/webapp" +) + +var ( + ErrExportInvalid = errors.New("invalid canvas export") + ErrExportStale = errors.New("canvas export is stale") +) + +type CanvasReader interface { + Get(context.Context, string) (*Canvas, error) +} + +type ReleaseReader interface { + GetRelease(context.Context, string) (plugininstances.Release, error) +} + +type ArtifactReader interface { + ReadFiles(webapp.Artifact) (map[string][]byte, error) +} + +type WorkspaceAuthorizer func(context.Context, string) error + +// CatalogResolver resolves a server-selected catalog entry. It returns only +// trusted server metadata; the browser never supplies a package URL for this +// path. +type CatalogResolver interface { + ResolveCanvasPackage(context.Context, string, string, string, string) (string, string, error) +} + +type ExportMetadata struct { + PackageID string `json:"package_id"` + Version string `json:"version"` + DisplayName string `json:"display_name"` + Description string `json:"description"` + Author string `json:"author"` + License string `json:"license"` + SourceMode string `json:"source_mode"` + MinKandevVersion string `json:"min_kandev_version"` + RepoURL string `json:"repo_url,omitempty"` +} + +type ExportRequest struct { + UserID string + WorkspaceID string + CanvasID string + ExpectedReleaseID string + Metadata ExportMetadata +} + +type ExportReview struct { + PreparationID string `json:"preparation_id"` + CanvasID string `json:"canvas_id"` + WorkspaceID string `json:"workspace_id"` + ReleaseID string `json:"release_id"` + Metadata ExportMetadata `json:"metadata"` + Digest string `json:"sha256"` + Files []ExportFile `json:"files"` + BundleBytes int64 `json:"bundle_bytes"` + SourceBytes int64 `json:"source_bytes"` + ExpiresAt string `json:"expires_at"` + BundleDownload string `json:"bundle_download"` + SourceDownload string `json:"source_download"` +} + +type ExportDownload struct { + Data []byte + Filename string + ContentType string +} + +type DistributionService struct { + canvases CanvasReader + releases ReleaseReader + artifacts ArtifactReader + authorize WorkspaceAuthorizer + preparations *PreparationStore + installMu sync.Mutex + artifactQuota ArtifactQuota + receipts map[string]InstallReceipt + receiptStore InstallReceiptStore + httpClient *http.Client + catalog CatalogResolver + kandevVersion string +} + +// SetArtifactQuota wires the durable storage admission store used by +// marketplace installs. Production composition supplies the same instance +// store used by canvas authoring. +func (s *DistributionService) SetArtifactQuota(quota ArtifactQuota) { + if s == nil { + return + } + s.installMu.Lock() + s.artifactQuota = quota + s.installMu.Unlock() +} + +func NewDistributionService(canvases CanvasReader, releases ReleaseReader, artifacts ArtifactReader, authorize WorkspaceAuthorizer, preparations *PreparationStore) *DistributionService { + return &DistributionService{canvases: canvases, releases: releases, artifacts: artifacts, authorize: authorize, preparations: preparations, receipts: make(map[string]InstallReceipt), httpClient: newDistributionHTTPClient()} +} + +func (s *DistributionService) SetInstallReceiptStore(store InstallReceiptStore) { + if s == nil { + return + } + s.installMu.Lock() + s.receiptStore = store + s.installMu.Unlock() +} + +// SetKandevVersion supplies the stamped server version used by the same +// compatibility gate as native plugin installation. +func (s *DistributionService) SetKandevVersion(version string) { + if s == nil { + return + } + s.installMu.Lock() + s.kandevVersion = version + s.installMu.Unlock() +} + +// SetInstallHTTPClient is intended for deterministic local transport tests. +// Production composition leaves the destination-validating client installed. +func (s *DistributionService) SetInstallHTTPClient(client *http.Client) { + if s == nil || client == nil { + return + } + s.installMu.Lock() + s.httpClient = client + s.installMu.Unlock() +} + +func (s *DistributionService) SetCatalogResolver(resolver CatalogResolver) { + if s == nil { + return + } + s.installMu.Lock() + s.catalog = resolver + s.installMu.Unlock() +} + +func (s *DistributionService) PrepareExport(ctx context.Context, request ExportRequest) (ExportReview, error) { + if err := s.validateReady(request.UserID, request.WorkspaceID, request.CanvasID); err != nil { + return ExportReview{}, err + } + if err := s.authorizeWorkspace(ctx, request.WorkspaceID); err != nil { + return ExportReview{}, err + } + item, release, files, err := s.loadExportSnapshot(ctx, request) + if err != nil { + return ExportReview{}, err + } + metadata, err := normalizeExportMetadata(request.Metadata, releaseManifest(release)) + if err != nil { + return ExportReview{}, err + } + manifestValue, err := exportManifest(release.ManifestJSON, metadata) + if err != nil { + return ExportReview{}, err + } + if _, ok := files["README.md"]; !ok { + files["README.md"] = []byte(fmt.Sprintf("# %s\n\n%s\n\nBuild and publish the retained canvas files.\n", metadata.DisplayName, metadata.Description)) + } + pkg, err := webapp.BuildDistributionPackage(manifestValue, files) + if err != nil { + if errors.Is(err, webapp.ErrSourceUnavailable) { + return ExportReview{}, err + } + return ExportReview{}, fmt.Errorf("%w: %v", ErrExportInvalid, err) + } + archives, err := webapp.BuildDistributionArchives(pkg) + if err != nil { + return ExportReview{}, fmt.Errorf("%w: %v", ErrExportInvalid, err) + } + preparation, err := s.preparations.Create(ctx, Preparation{ + UserID: request.UserID, WorkspaceID: item.WorkspaceID, CanvasID: item.ID, ReleaseID: release.ID, + PackageID: metadata.PackageID, PackageVersion: metadata.Version, PackageDigest: pkg.Digest, + Metadata: metadata, + OriginKind: "export", Bundle: archives.Bundle, Source: archives.Source, + Files: distributionInventory(pkg.Files), + }) + if err != nil { + return ExportReview{}, err + } + return exportReview(preparation, metadata, pkg, len(archives.Bundle), len(archives.Source)), nil +} + +func (s *DistributionService) GetExport(ctx context.Context, userID, preparationID string) (ExportReview, error) { + preparation, err := s.preparations.Get(ctx, userID, preparationID) + if err != nil { + return ExportReview{}, err + } + if err := s.verifyCurrentRelease(ctx, preparation); err != nil { + return ExportReview{}, err + } + return preparationReview(preparation), nil +} + +func (s *DistributionService) DownloadExport(ctx context.Context, userID, preparationID string, kind ExportKind) (ExportDownload, error) { + preparation, err := s.preparations.Get(ctx, userID, preparationID) + if err != nil { + return ExportDownload{}, err + } + if err := s.verifyCurrentRelease(ctx, preparation); err != nil { + return ExportDownload{}, err + } + data, err := s.preparations.Open(ctx, userID, preparationID, kind) + if err != nil { + return ExportDownload{}, err + } + extension, contentType := ".tar.gz", "application/gzip" + if kind == ExportSource { + extension, contentType = ".zip", "application/zip" + } else if kind != ExportBundle { + return ExportDownload{}, ErrExportInvalid + } + return ExportDownload{Data: data, Filename: preparation.PackageID + "-" + preparation.PackageVersion + extension, ContentType: contentType}, nil +} + +func (s *DistributionService) CancelExport(ctx context.Context, userID, preparationID string) error { + return s.preparations.Delete(ctx, userID, preparationID) +} + +func (s *DistributionService) validateReady(userID, workspaceID, canvasID string) error { + if s == nil || s.canvases == nil || s.releases == nil || s.artifacts == nil || s.preparations == nil || + strings.TrimSpace(userID) == "" || strings.TrimSpace(workspaceID) == "" || strings.TrimSpace(canvasID) == "" { + return ErrExportInvalid + } + return nil +} + +func (s *DistributionService) authorizeWorkspace(ctx context.Context, workspaceID string) error { + if s.authorize == nil { + return nil + } + return s.authorize(ctx, workspaceID) +} + +func (s *DistributionService) loadExportSnapshot(ctx context.Context, request ExportRequest) (*Canvas, plugininstances.Release, map[string][]byte, error) { + item, err := s.canvases.Get(ctx, request.CanvasID) + if err != nil || !validExportCanvas(item, request) { + return nil, plugininstances.Release{}, nil, ErrCanvasNotFound + } + if !matchesExpectedRelease(item, request.ExpectedReleaseID) { + return nil, plugininstances.Release{}, nil, ErrExportStale + } + release, err := s.releases.GetRelease(ctx, item.ActiveReleaseID) + if err != nil || !validExportRelease(item, release, request.ExpectedReleaseID) { + return nil, plugininstances.Release{}, nil, ErrExportStale + } + files, err := s.artifacts.ReadFiles(webapp.Artifact{Digest: release.PackageDigest, RelativePath: release.ArtifactPath, Bytes: release.ArtifactBytes, Available: true}) + if err != nil { + return nil, plugininstances.Release{}, nil, webapp.ErrArtifactUnavailable + } + return item, release, cloneFiles(files), nil +} + +func validExportCanvas(item *Canvas, request ExportRequest) bool { + return item != nil && item.WorkspaceID == request.WorkspaceID && + (item.ScopeKind == ScopeWorkspace || item.ScopeKind == ScopeTask) && item.Status == StatusActive && + item.ActiveReleaseID != "" && item.ActiveReleaseStatus == ValidationValid +} + +func matchesExpectedRelease(item *Canvas, expectedReleaseID string) bool { + return expectedReleaseID != "" && item != nil && expectedReleaseID == item.ActiveReleaseID +} + +func validExportRelease(item *Canvas, release plugininstances.Release, expectedReleaseID string) bool { + if release.ID != expectedReleaseID { + return false + } + return release.InstanceID == "" || item.PluginInstanceID == "" || release.InstanceID == item.PluginInstanceID +} + +func (s *DistributionService) verifyCurrentRelease(ctx context.Context, preparation Preparation) error { + if err := s.authorizeWorkspace(ctx, preparation.WorkspaceID); err != nil { + return err + } + item, err := s.canvases.Get(ctx, preparation.CanvasID) + if err != nil || item == nil || item.WorkspaceID != preparation.WorkspaceID || item.ActiveReleaseID != preparation.ReleaseID || item.ActiveReleaseStatus != ValidationValid || item.Status != StatusActive { + return ErrExportStale + } + return nil +} + +func releaseManifest(release plugininstances.Release) manifest.Manifest { + var value manifest.Manifest + _ = json.Unmarshal(release.ManifestJSON, &value) + return value +} + +func normalizeExportMetadata(input ExportMetadata, current manifest.Manifest) (ExportMetadata, error) { + input = fillExportMetadata(input, current) + if exportMetadataIncomplete(input) { + return ExportMetadata{}, fmt.Errorf("%w: package identity, description, author, license, and compatibility are required", ErrExportInvalid) + } + return input, nil +} + +func fillExportMetadata(input ExportMetadata, current manifest.Manifest) ExportMetadata { + if input.PackageID == "" { + input.PackageID = current.ID + } + if input.Version == "" { + input.Version = current.Version + } + if input.DisplayName == "" { + input.DisplayName = current.DisplayName + } + if input.Description == "" { + input.Description = current.Description + } + if input.Author == "" { + input.Author = current.Author + } + if input.License == "" && current.Distribution != nil { + input.License = current.Distribution.License + } + if input.SourceMode == "" { + input.SourceMode = manifest.SourceModeStatic + if current.Distribution != nil { + input.SourceMode = current.Distribution.SourceMode + } + } + if input.MinKandevVersion == "" { + input.MinKandevVersion = current.MinKandevVersion + } + if input.RepoURL == "" { + input.RepoURL = current.RepoURL + } + return input +} + +func exportMetadataIncomplete(input ExportMetadata) bool { + return input.PackageID == "" || input.Version == "" || input.DisplayName == "" || + input.Description == "" || input.Author == "" || input.License == "" || input.MinKandevVersion == "" +} + +func exportManifest(data json.RawMessage, metadata ExportMetadata) (*manifest.Manifest, error) { + var value manifest.Manifest + if err := json.Unmarshal(data, &value); err != nil { + return nil, fmt.Errorf("%w: release manifest is invalid", ErrExportInvalid) + } + value.ID, value.Version = metadata.PackageID, metadata.Version + value.DisplayName, value.Description, value.Author = metadata.DisplayName, metadata.Description, metadata.Author + value.MinKandevVersion, value.RepoURL = metadata.MinKandevVersion, metadata.RepoURL + if len(value.UI.WebApps) > 0 && !containsString(value.UI.WebApps[0].Placements, manifest.WebAppPlacementWorkspace) { + value.UI.WebApps[0].Placements = append(value.UI.WebApps[0].Placements, manifest.WebAppPlacementWorkspace) + } + value.Distribution = &manifest.Distribution{SchemaVersion: manifest.DistributionSchemaVersion, Kind: manifest.DistributionKindCanvas, License: metadata.License, SourceMode: metadata.SourceMode} + return &value, nil +} + +func cloneFiles(input map[string][]byte) map[string][]byte { + output := make(map[string][]byte, len(input)) + for name, data := range input { + output[name] = append([]byte(nil), data...) + } + return output +} + +func distributionInventory(files map[string][]byte) []ExportFile { + names := make([]string, 0, len(files)) + for name := range files { + names = append(names, name) + } + sort.Strings(names) + result := make([]ExportFile, 0, len(names)) + for _, name := range names { + result = append(result, ExportFile{Path: name, Bytes: int64(len(files[name]))}) + } + return result +} + +func exportReview(preparation Preparation, metadata ExportMetadata, pkg *webapp.Package, bundleBytes, sourceBytes int) ExportReview { + review := preparationReview(preparation) + review.Metadata, review.Digest, review.Files = metadata, pkg.Digest, distributionInventory(pkg.Files) + review.BundleBytes, review.SourceBytes = int64(bundleBytes), int64(sourceBytes) + review.BundleDownload = "/api/v1/canvases/exports/" + preparation.ID + "/bundle" + review.SourceDownload = "/api/v1/canvases/exports/" + preparation.ID + "/source" + return review +} + +func preparationReview(preparation Preparation) ExportReview { + metadata := preparation.Metadata + if metadata.PackageID == "" { + metadata.PackageID, metadata.Version = preparation.PackageID, preparation.PackageVersion + } + return ExportReview{PreparationID: preparation.ID, CanvasID: preparation.CanvasID, WorkspaceID: preparation.WorkspaceID, ReleaseID: preparation.ReleaseID, Metadata: metadata, Digest: preparation.PackageDigest, Files: append([]ExportFile(nil), preparation.Files...), ExpiresAt: preparation.ExpiresAt.UTC().Format("2006-01-02T15:04:05.000Z07:00")} +} + +func (s *DistributionService) preparationsCount() int { + if s == nil || s.preparations == nil { + return 0 + } + s.preparations.mu.Lock() + defer s.preparations.mu.Unlock() + return len(s.preparations.items) +} diff --git a/apps/backend/internal/canvas/distribution_export_test.go b/apps/backend/internal/canvas/distribution_export_test.go new file mode 100644 index 0000000000..4a36df9538 --- /dev/null +++ b/apps/backend/internal/canvas/distribution_export_test.go @@ -0,0 +1,393 @@ +package canvas + +import ( + "archive/zip" + "bytes" + "context" + "encoding/json" + "errors" + "testing" + "time" + + plugininstances "github.com/kandev/kandev/internal/plugins/instances" + "github.com/kandev/kandev/internal/plugins/manifest" + "github.com/kandev/kandev/internal/plugins/webapp" +) + +func TestPrepareExportBuildsOneReleaseBoundBundleAndSource(t *testing.T) { + service, releases, _ := newDistributionExportTestService(t) + review, err := service.PrepareExport(context.Background(), ExportRequest{ + UserID: "user-1", WorkspaceID: "workspace-1", CanvasID: "canvas-1", ExpectedReleaseID: "release-1", + Metadata: ExportMetadata{PackageID: "canvas-board", Version: "1.2.3", DisplayName: "Board", Description: "A board", Author: "Kandev", License: "MIT", SourceMode: manifest.SourceModeStatic, MinKandevVersion: "1.0.0"}, + }) + if err != nil { + t.Fatalf("PrepareExport() error = %v", err) + } + if review.PreparationID == "" || review.Digest == "" || review.BundleBytes == 0 || review.SourceBytes == 0 { + t.Fatalf("review = %+v", review) + } + if review.Metadata.SourceMode != manifest.SourceModeStatic { + t.Fatalf("metadata = %+v", review.Metadata) + } + if !hasExportFile(review.Files, "ui/logo.svg") || !hasExportFile(review.Files, "ui/body.woff2") { + t.Fatalf("runtime assets missing from export inventory: %+v", review.Files) + } + if releases.lastID != "release-1" { + t.Fatalf("release lookup = %q, want release-1", releases.lastID) + } + bundle, err := service.DownloadExport(context.Background(), "user-1", review.PreparationID, ExportBundle) + if err != nil { + t.Fatalf("DownloadExport(bundle) error = %v", err) + } + if _, err := webapp.ValidateDistributionPackage(bytes.NewReader(bundle.Data)); err != nil { + t.Fatalf("downloaded bundle validation error = %v", err) + } + source, err := service.DownloadExport(context.Background(), "user-1", review.PreparationID, ExportSource) + if err != nil { + t.Fatalf("DownloadExport(source) error = %v", err) + } + if _, err := zip.NewReader(bytes.NewReader(source.Data), int64(len(source.Data))); err != nil { + t.Fatalf("downloaded source validation error = %v", err) + } + if !sameReleaseID(review, "release-1") { + t.Fatal("review is not bound to the expected release") + } +} + +func TestPrepareExportAllowsTaskCanvasWithPortableWorkspacePlacement(t *testing.T) { + service, releases, canvases := newDistributionExportTestService(t) + var sourceManifest manifest.Manifest + if err := json.Unmarshal(releases.release.ManifestJSON, &sourceManifest); err != nil { + t.Fatalf("unmarshal source manifest: %v", err) + } + sourceManifest.UI.WebApps[0].Placements = []string{manifest.WebAppPlacementTask} + if err := sourceManifest.Validate(); err != nil { + t.Fatalf("task source manifest should remain locally valid: %v", err) + } + releases.release.ManifestJSON, _ = json.Marshal(sourceManifest) + canvases.canvas.ScopeKind = ScopeTask + + review, err := service.PrepareExport(context.Background(), ExportRequest{ + UserID: "user-1", WorkspaceID: "workspace-1", CanvasID: "canvas-1", ExpectedReleaseID: "release-1", + Metadata: ExportMetadata{PackageID: "canvas-board", Version: "1.2.3", DisplayName: "Board", Description: "A board", Author: "Kandev", License: "MIT", SourceMode: manifest.SourceModeStatic, MinKandevVersion: "1.0.0"}, + }) + if err != nil { + t.Fatalf("PrepareExport() error = %v", err) + } + if canvases.canvas.ScopeKind != ScopeTask { + t.Fatalf("task canvas scope changed to %q during export", canvases.canvas.ScopeKind) + } + bundle, err := service.DownloadExport(context.Background(), "user-1", review.PreparationID, ExportBundle) + if err != nil { + t.Fatalf("DownloadExport() error = %v", err) + } + pkg, err := webapp.ValidateDistributionPackage(bytes.NewReader(bundle.Data)) + if err != nil { + t.Fatalf("downloaded task bundle validation error = %v", err) + } + placements := pkg.Manifest.UI.WebApps[0].Placements + if !containsString(placements, manifest.WebAppPlacementWorkspace) { + t.Fatalf("exported placements = %v, want workspace-canvas", placements) + } +} + +func TestPrepareExportRejectsStaleReleaseWithoutCreatingPreparation(t *testing.T) { + service, _, canvases := newDistributionExportTestService(t) + canvases.canvas.ActiveReleaseID = "release-new" + _, err := service.PrepareExport(context.Background(), ExportRequest{ + UserID: "user-1", WorkspaceID: "workspace-1", CanvasID: "canvas-1", ExpectedReleaseID: "release-1", + Metadata: ExportMetadata{PackageID: "canvas-board", Version: "1.2.3", DisplayName: "Board", Description: "A board", Author: "Kandev", License: "MIT", SourceMode: manifest.SourceModeStatic, MinKandevVersion: "1.0.0"}, + }) + if !errors.Is(err, ErrExportStale) { + t.Fatalf("PrepareExport() error = %v, want ErrExportStale", err) + } + if service.preparationsCount() != 0 { + t.Fatal("stale export created a preparation") + } +} + +func TestPrepareInstallInspectsExactBundleAndReturnsPermissions(t *testing.T) { + service, _, _ := newDistributionExportTestService(t) + export, err := service.PrepareExport(context.Background(), ExportRequest{ + UserID: "user-1", WorkspaceID: "workspace-1", CanvasID: "canvas-1", ExpectedReleaseID: "release-1", + Metadata: ExportMetadata{PackageID: "canvas-board", Version: "1.2.3", DisplayName: "Board", Description: "A board", Author: "Kandev", License: "MIT", SourceMode: manifest.SourceModeStatic, MinKandevVersion: "1.0.0"}, + }) + if err != nil { + t.Fatalf("PrepareExport() error = %v", err) + } + bundle, err := service.DownloadExport(context.Background(), "user-1", export.PreparationID, ExportBundle) + if err != nil { + t.Fatalf("DownloadExport() error = %v", err) + } + review, err := service.PrepareInstall(context.Background(), InstallRequest{ + UserID: "user-1", WorkspaceID: "workspace-1", OriginKind: "upload", Bundle: bundle.Data, + }) + if err != nil { + t.Fatalf("PrepareInstall() error = %v", err) + } + if review.Metadata.PackageID != "canvas-board" || review.Metadata.License != "MIT" || review.PermissionSummary.Reads == nil { + t.Fatalf("install review = %+v", review) + } +} + +func TestPrepareInstallRejectsExpectedDigestMismatch(t *testing.T) { + service, _, _ := newDistributionExportTestService(t) + export, err := service.PrepareExport(context.Background(), ExportRequest{ + UserID: "user-1", WorkspaceID: "workspace-1", CanvasID: "canvas-1", ExpectedReleaseID: "release-1", + Metadata: ExportMetadata{PackageID: "canvas-board", Version: "1.2.3", DisplayName: "Board", Description: "A board", Author: "Kandev", License: "MIT", SourceMode: manifest.SourceModeStatic, MinKandevVersion: "1.0.0"}, + }) + if err != nil { + t.Fatalf("PrepareExport() error = %v", err) + } + bundle, err := service.DownloadExport(context.Background(), "user-1", export.PreparationID, ExportBundle) + if err != nil { + t.Fatalf("DownloadExport() error = %v", err) + } + _, err = service.PrepareInstall(context.Background(), InstallRequest{UserID: "user-1", WorkspaceID: "workspace-1", OriginKind: "upload", ExpectedDigest: "wrong", Bundle: bundle.Data}) + if !errors.Is(err, ErrPackageDigestMismatch) { + t.Fatalf("PrepareInstall() error = %v, want ErrPackageDigestMismatch", err) + } +} + +func TestConfirmInstallChecksDurableArtifactQuotaBeforeWriting(t *testing.T) { + exportService, _, _ := newDistributionExportTestService(t) + export, err := exportService.PrepareExport(context.Background(), ExportRequest{ + UserID: "user-1", WorkspaceID: "workspace-1", CanvasID: "canvas-1", ExpectedReleaseID: "release-1", + Metadata: ExportMetadata{PackageID: "canvas-board", Version: "1.2.3", DisplayName: "Board", Description: "A board", Author: "Kandev", License: "MIT", SourceMode: manifest.SourceModeStatic, MinKandevVersion: "1.0.0"}, + }) + if err != nil { + t.Fatalf("PrepareExport() error = %v", err) + } + bundle, err := exportService.DownloadExport(context.Background(), "user-1", export.PreparationID, ExportBundle) + if err != nil { + t.Fatalf("DownloadExport() error = %v", err) + } + installer := &fakeDistributionInstaller{} + artifactWriter := &fakeDistributionArtifactWriter{} + quota := &fakeDistributionQuota{reserveErr: plugininstances.ErrWorkspaceStorageLimit} + service := NewDistributionService(installer, &fakeDistributionReleaseReader{}, artifactWriter, func(context.Context, string) error { return nil }, exportService.preparations) + service.SetArtifactQuota(quota) + review, err := service.PrepareInstall(context.Background(), InstallRequest{UserID: "user-1", WorkspaceID: "workspace-1", OriginKind: "upload", Bundle: bundle.Data}) + if err != nil { + t.Fatalf("PrepareInstall() error = %v", err) + } + _, err = service.ConfirmInstall(context.Background(), "user-1", review.PreparationID, InstallConfirmation{ExpectedDigest: review.Digest, Approved: true}) + if !errors.Is(err, plugininstances.ErrWorkspaceStorageLimit) { + t.Fatalf("ConfirmInstall() error = %v, want workspace storage limit", err) + } + if artifactWriter.next != 0 || installer.created != 0 || quota.released != 0 { + t.Fatalf("quota failure left writes behind: artifacts=%d installs=%d releases=%d", artifactWriter.next, installer.created, quota.released) + } +} + +func TestConfirmInstallReleasesDurableArtifactReservationAfterInstall(t *testing.T) { + exportService, _, _ := newDistributionExportTestService(t) + export, err := exportService.PrepareExport(context.Background(), ExportRequest{ + UserID: "user-1", WorkspaceID: "workspace-1", CanvasID: "canvas-1", ExpectedReleaseID: "release-1", + Metadata: ExportMetadata{PackageID: "canvas-board", Version: "1.2.3", DisplayName: "Board", Description: "A board", Author: "Kandev", License: "MIT", SourceMode: manifest.SourceModeStatic, MinKandevVersion: "1.0.0"}, + }) + if err != nil { + t.Fatalf("PrepareExport() error = %v", err) + } + bundle, err := exportService.DownloadExport(context.Background(), "user-1", export.PreparationID, ExportBundle) + if err != nil { + t.Fatalf("DownloadExport() error = %v", err) + } + quota := &fakeDistributionQuota{} + installer := &fakeDistributionInstaller{} + writer := &fakeDistributionArtifactWriter{} + service := NewDistributionService(installer, &fakeDistributionReleaseReader{}, writer, func(context.Context, string) error { return nil }, exportService.preparations) + service.SetArtifactQuota(quota) + review, err := service.PrepareInstall(context.Background(), InstallRequest{UserID: "user-1", WorkspaceID: "workspace-1", OriginKind: "upload", Bundle: bundle.Data}) + if err != nil { + t.Fatalf("PrepareInstall() error = %v", err) + } + if _, err := service.ConfirmInstall(context.Background(), "user-1", review.PreparationID, InstallConfirmation{ExpectedDigest: review.Digest, Approved: true}); err != nil { + t.Fatalf("ConfirmInstall() error = %v", err) + } + if quota.released != 1 || writer.next != 1 || installer.created != 1 { + t.Fatalf("reservation cleanup = released %d, artifacts %d, installs %d", quota.released, writer.next, installer.created) + } +} + +func TestPrepareInstallRejectsPackageRequiringNewerKandev(t *testing.T) { + service, _, _ := newDistributionExportTestService(t) + service.SetKandevVersion("0.9.0") + export, err := service.PrepareExport(context.Background(), ExportRequest{ + UserID: "user-1", WorkspaceID: "workspace-1", CanvasID: "canvas-1", ExpectedReleaseID: "release-1", + Metadata: ExportMetadata{PackageID: "canvas-board", Version: "1.2.3", DisplayName: "Board", Description: "A board", Author: "Kandev", License: "MIT", SourceMode: manifest.SourceModeStatic, MinKandevVersion: "1.0.0"}, + }) + if err != nil { + t.Fatalf("PrepareExport() error = %v", err) + } + bundle, err := service.DownloadExport(context.Background(), "user-1", export.PreparationID, ExportBundle) + if err != nil { + t.Fatalf("DownloadExport() error = %v", err) + } + _, err = service.PrepareInstall(context.Background(), InstallRequest{UserID: "user-1", WorkspaceID: "workspace-1", OriginKind: "upload", Bundle: bundle.Data}) + if !errors.Is(err, ErrInstallIncompatible) { + t.Fatalf("PrepareInstall() error = %v, want ErrInstallIncompatible", err) + } +} + +func TestConfirmInstallIsIdempotentForOnePreparation(t *testing.T) { + exportService, _, _ := newDistributionExportTestService(t) + export, err := exportService.PrepareExport(context.Background(), ExportRequest{ + UserID: "user-1", WorkspaceID: "workspace-1", CanvasID: "canvas-1", ExpectedReleaseID: "release-1", + Metadata: ExportMetadata{PackageID: "canvas-board", Version: "1.2.3", DisplayName: "Board", Description: "A board", Author: "Kandev", License: "MIT", SourceMode: manifest.SourceModeStatic, MinKandevVersion: "1.0.0"}, + }) + if err != nil { + t.Fatalf("PrepareExport() error = %v", err) + } + bundle, err := exportService.DownloadExport(context.Background(), "user-1", export.PreparationID, ExportBundle) + if err != nil { + t.Fatalf("DownloadExport() error = %v", err) + } + installer := &fakeDistributionInstaller{} + artifactWriter := &fakeDistributionArtifactWriter{} + service := NewDistributionService(installer, &fakeDistributionReleaseReader{}, artifactWriter, func(context.Context, string) error { return nil }, exportService.preparations) + review, err := service.PrepareInstall(context.Background(), InstallRequest{UserID: "user-1", WorkspaceID: "workspace-1", OriginKind: "upload", Bundle: bundle.Data}) + if err != nil { + t.Fatalf("PrepareInstall() error = %v", err) + } + first, err := service.ConfirmInstall(context.Background(), "user-1", review.PreparationID, InstallConfirmation{ExpectedDigest: review.Digest, Approved: true}) + if err != nil { + t.Fatalf("ConfirmInstall(first) error = %v", err) + } + second, err := service.ConfirmInstall(context.Background(), "user-1", review.PreparationID, InstallConfirmation{ExpectedDigest: review.Digest, Approved: true}) + if err != nil { + t.Fatalf("ConfirmInstall(retry) error = %v", err) + } + if first.Canvas == nil || second.Canvas == nil || first.Canvas.ID != second.Canvas.ID || installer.created != 1 { + t.Fatalf("first=%+v second=%+v created=%d", first, second, installer.created) + } +} + +type fakeDistributionCanvasReader struct{ canvas *Canvas } + +func (f *fakeDistributionCanvasReader) Get(context.Context, string) (*Canvas, error) { + return f.canvas, nil +} + +type fakeDistributionReleaseReader struct { + release plugininstances.Release + lastID string +} + +func (f *fakeDistributionReleaseReader) GetRelease(_ context.Context, id string) (plugininstances.Release, error) { + f.lastID = id + return f.release, nil +} + +type fakeDistributionArtifactReader struct{ files map[string][]byte } + +func (f *fakeDistributionArtifactReader) ReadFiles(webapp.Artifact) (map[string][]byte, error) { + return f.files, nil +} + +type fakeDistributionArtifactWriter struct{ next int } + +func (f *fakeDistributionArtifactWriter) ReadFiles(webapp.Artifact) (map[string][]byte, error) { + return nil, nil +} + +func (f *fakeDistributionArtifactWriter) Put(pkg *webapp.Package) (webapp.Artifact, error) { + f.next++ + return webapp.Artifact{Digest: pkg.Digest, RelativePath: "releases/" + pkg.Digest, Bytes: pkg.ExpandedBytes, Available: true}, nil +} + +type fakeDistributionQuota struct { + reserveErr error + released int +} + +func (f *fakeDistributionQuota) ReserveBytes(context.Context, string, int64, int64, int64) (plugininstances.Reservation, error) { + if f.reserveErr != nil { + return plugininstances.Reservation{}, f.reserveErr + } + return plugininstances.Reservation{ID: "reservation-1"}, nil +} + +func (f *fakeDistributionQuota) ReleaseBytes(context.Context, string) error { + f.released++ + return nil +} + +type fakeDistributionInstaller struct { + created int + canvas *Canvas +} + +func (f *fakeDistributionInstaller) InstallCanvasPackage(_ context.Context, request InstallCanvasPackageRequest) (*InstallResult, error) { + f.created++ + f.canvas = &Canvas{ID: "installed-canvas", PluginInstanceID: "installed-instance", WorkspaceID: request.WorkspaceID, ScopeKind: ScopeWorkspace, Status: StatusActive, Title: request.Title} + receipt := request.Receipt + receipt.CanvasID = f.canvas.ID + return &InstallResult{Canvas: f.canvas, Receipt: receipt}, nil +} + +func (f *fakeDistributionInstaller) Get(context.Context, string) (*Canvas, error) { + if f.canvas == nil { + return nil, ErrCanvasNotFound + } + return f.canvas, nil +} + +func (f *fakeDistributionInstaller) CreateCanvas(_ context.Context, request CreateCanvasRequest) (*Canvas, error) { + f.created++ + f.canvas = &Canvas{ID: "installed-canvas", PluginInstanceID: "installed-instance", WorkspaceID: request.WorkspaceID, ScopeKind: ScopeWorkspace, Status: StatusActive, Title: request.Title} + return f.canvas, nil +} + +func (f *fakeDistributionInstaller) PublishPackage(_ context.Context, request PublishRequest) (*PublishResult, error) { + return &PublishResult{Canvas: f.canvas, Release: plugininstances.Release{ID: "installed-release", InstanceID: f.canvas.PluginInstanceID}, Activated: false}, nil +} + +func (f *fakeDistributionInstaller) ApproveRelease(context.Context, string, string, string) (*Canvas, error) { + f.canvas.ActiveReleaseID = "installed-release" + f.canvas.ActiveReleaseStatus = ValidationValid + return f.canvas, nil +} + +func newDistributionExportTestService(t *testing.T) (*DistributionService, *fakeDistributionReleaseReader, *fakeDistributionCanvasReader) { + t.Helper() + manifestValue := manifest.Manifest{ + ID: "legacy-board", APIVersion: manifest.CurrentAPIVersion, Version: "0.1.0", DisplayName: "Board", Description: "A board", Author: "Author", + UI: manifest.UISection{WebApps: []manifest.WebApp{{Key: "main", Title: "Board", Entry: "ui/index.html", Placements: []string{manifest.WebAppPlacementWorkspace}}}}, + Capabilities: manifest.Capabilities{APIRead: []string{"tasks"}}, + } + manifestJSON, err := json.Marshal(manifestValue) + if err != nil { + t.Fatalf("marshal manifest: %v", err) + } + releases := &fakeDistributionReleaseReader{release: plugininstances.Release{ + ID: "release-1", InstanceID: "instance-1", PackageDigest: "old-digest", ArtifactPath: "releases/old-digest", ArtifactBytes: 10, + ManifestJSON: manifestJSON, ValidationStatus: plugininstances.ValidationValid, CreatedAt: time.Now().UTC(), + }} + canvases := &fakeDistributionCanvasReader{canvas: &Canvas{ID: "canvas-1", WorkspaceID: "workspace-1", ScopeKind: ScopeWorkspace, Status: StatusActive, ActiveReleaseID: "release-1", ActiveReleaseStatus: ValidationValid}} + files := map[string][]byte{ + "manifest.yaml": []byte("legacy"), + "ui/index.html": []byte(""), + "ui/logo.svg": []byte(""), + "ui/body.woff2": []byte("font-bytes"), + "ui/theme.css": []byte("body { color: red; }"), + "ui/runtime.json": []byte(`{"ready":true}`), + } + preparations, err := NewPreparationStore(t.TempDir()) + if err != nil { + t.Fatalf("NewPreparationStore: %v", err) + } + service := NewDistributionService(canvases, releases, &fakeDistributionArtifactReader{files: files}, func(context.Context, string) error { return nil }, preparations) + return service, releases, canvases +} + +func sameReleaseID(review ExportReview, id string) bool { return review.ReleaseID == id } + +func hasExportFile(files []ExportFile, path string) bool { + for _, file := range files { + if file.Path == path { + return true + } + } + return false +} diff --git a/apps/backend/internal/canvas/distribution_install.go b/apps/backend/internal/canvas/distribution_install.go new file mode 100644 index 0000000000..a36b22f8ad --- /dev/null +++ b/apps/backend/internal/canvas/distribution_install.go @@ -0,0 +1,511 @@ +package canvas + +import ( + "bytes" + "context" + "errors" + "fmt" + "strings" + "time" + + plugininstances "github.com/kandev/kandev/internal/plugins/instances" + "github.com/kandev/kandev/internal/plugins/manifest" + "github.com/kandev/kandev/internal/plugins/webapp" +) + +var ( + ErrInstallInvalid = errors.New("invalid canvas installation") + ErrPackageDigestMismatch = errors.New("canvas package digest mismatch") + ErrInstallNotApproved = errors.New("canvas installation approval is required") + ErrInstallUnavailable = errors.New("canvas installation is unavailable") + ErrInstallReceiptNotFound = errors.New("canvas installation receipt not found") + ErrInstallIncompatible = errors.New("canvas package requires a newer Kandev version") +) + +const ( + installOriginUpload = "upload" + installOriginURL = "url" + installOriginRegistry = "registry" +) + +type ArtifactWriter interface { + Put(*webapp.Package) (webapp.Artifact, error) +} + +type artifactWriterWithCreated interface { + PutWithCreated(*webapp.Package) (webapp.Artifact, bool, error) +} + +type artifactRemover interface { + Remove(webapp.Artifact) error +} + +// ArtifactQuota is the shared durable admission mechanism used by both +// authoring and marketplace installation. Reservations are held until the +// install transaction has either committed or failed. +type ArtifactQuota interface { + ReserveBytes(context.Context, string, int64, int64, int64) (plugininstances.Reservation, error) + ReleaseBytes(context.Context, string) error +} + +type CanvasInstaller interface { + CanvasReader + InstallCanvasPackage(context.Context, InstallCanvasPackageRequest) (*InstallResult, error) +} + +// InstallCanvasPackageRequest is the trusted, already-inspected input for the +// single database transaction used by the production canvas service. +type InstallCanvasPackageRequest struct { + WorkspaceID string + Title string + Package *webapp.Package + Artifact webapp.Artifact + SourceActorKind string + SourceUserID string + Approved bool + Receipt InstallReceipt +} + +type InstallReceiptStore interface { + GetInstallReceipt(context.Context, string, string) (InstallReceipt, error) + CreateInstallReceipt(context.Context, InstallReceipt) error +} + +type InstallConfirmation struct { + ExpectedDigest string `json:"expected_digest"` + Approved bool `json:"approved"` +} + +type InstallReceipt struct { + PreparationID string `json:"preparation_id"` + UserID string `json:"user_id"` + CanvasID string `json:"canvas_id"` + WorkspaceID string `json:"workspace_id"` + PackageID string `json:"package_id"` + Version string `json:"version"` + Digest string `json:"sha256"` + SourceID string `json:"source_id,omitempty"` + RepositoryURL string `json:"repository_url,omitempty"` + OriginKind string `json:"origin_kind"` + CreatedAt time.Time `json:"created_at"` +} + +type InstallResult struct { + Canvas *Canvas `json:"canvas"` + Receipt InstallReceipt `json:"receipt"` +} + +type InstallRequest struct { + UserID string + WorkspaceID string + OriginKind string + SourceID string + RepositoryURL string + PackageID string + ExpectedVersion string + ExpectedDigest string + ExpectedArchiveDigest string + Bundle []byte +} + +// GetInstallReview returns the authoritative inspected metadata for a staged +// install. It reopens the staged bytes so permissions are never reconstructed +// from browser-supplied fields. +func (s *DistributionService) GetInstallReview(ctx context.Context, userID, preparationID string) (InstallReview, error) { + if s == nil || s.preparations == nil || strings.TrimSpace(userID) == "" || strings.TrimSpace(preparationID) == "" { + return InstallReview{}, ErrInstallInvalid + } + preparation, err := s.preparations.Get(ctx, userID, preparationID) + if err != nil { + return InstallReview{}, err + } + if err := s.authorizeWorkspace(ctx, preparation.WorkspaceID); err != nil { + return InstallReview{}, err + } + bundle, err := s.preparations.Open(ctx, userID, preparationID, ExportBundle) + if err != nil { + return InstallReview{}, err + } + pkg, err := webapp.ValidateDistributionPackage(bytes.NewReader(bundle)) + if err != nil { + return InstallReview{}, fmt.Errorf("%w: %v", ErrInstallInvalid, err) + } + if pkg.Digest != preparation.PackageDigest { + return InstallReview{}, ErrPackageDigestMismatch + } + return installReview(preparation, pkg), nil +} + +// CancelInstall removes an unconfirmed install review. +func (s *DistributionService) CancelInstall(ctx context.Context, userID, preparationID string) error { + if s == nil || s.preparations == nil { + return ErrInstallInvalid + } + return s.preparations.Delete(ctx, userID, preparationID) +} + +type InstallReview struct { + PreparationID string `json:"preparation_id"` + WorkspaceID string `json:"workspace_id"` + Metadata InstallMetadata `json:"metadata"` + Digest string `json:"sha256"` + ArchiveDigest string `json:"archive_sha256"` + PermissionSummary PermissionSummary `json:"permissions"` + OriginKind string `json:"origin_kind"` + SourceID string `json:"source_id,omitempty"` + RepositoryURL string `json:"repository_url,omitempty"` + ExpiresAt string `json:"expires_at"` +} + +type InstallMetadata struct { + PackageID string `json:"package_id"` + Version string `json:"version"` + DisplayName string `json:"display_name"` + Description string `json:"description"` + Author string `json:"author"` + License string `json:"license"` + SourceMode string `json:"source_mode"` + MinKandevVersion string `json:"min_kandev_version"` + RepoURL string `json:"repo_url,omitempty"` +} + +func (s *DistributionService) PrepareInstall(ctx context.Context, request InstallRequest) (InstallReview, error) { + if err := s.validateInstallRequest(ctx, request); err != nil { + return InstallReview{}, err + } + origin, err := normalizeInstallOrigin(request.OriginKind) + if err != nil { + return InstallReview{}, err + } + pkg, archive, err := inspectInstallBundle(request, origin) + if err != nil { + return InstallReview{}, err + } + if err := s.validateInstallCompatibility(pkg); err != nil { + return InstallReview{}, err + } + return s.stageInstall(ctx, request, origin, archive, pkg) +} + +func (s *DistributionService) validateInstallCompatibility(pkg *webapp.Package) error { + if pkg == nil || pkg.Manifest == nil { + return ErrInstallInvalid + } + s.installMu.Lock() + runningVersion := s.kandevVersion + s.installMu.Unlock() + if err := manifest.CheckMinimumKandevVersion(pkg.Manifest.MinKandevVersion, runningVersion); err != nil { + return fmt.Errorf("%w: %v", ErrInstallIncompatible, err) + } + return nil +} + +func (s *DistributionService) validateInstallRequest(ctx context.Context, request InstallRequest) error { + if s == nil || s.preparations == nil || strings.TrimSpace(request.UserID) == "" || strings.TrimSpace(request.WorkspaceID) == "" || len(request.Bundle) == 0 { + return ErrInstallInvalid + } + return s.authorizeWorkspace(ctx, request.WorkspaceID) +} + +func normalizeInstallOrigin(origin string) (string, error) { + origin = strings.TrimSpace(origin) + if origin == "" { + return installOriginUpload, nil + } + if origin != installOriginUpload && origin != installOriginURL && origin != installOriginRegistry { + return "", ErrInstallInvalid + } + return origin, nil +} + +func inspectInstallBundle(request InstallRequest, origin string) (*webapp.Package, string, error) { + pkg, err := webapp.ValidateDistributionPackage(bytes.NewReader(request.Bundle)) + if err != nil { + return nil, "", fmt.Errorf("%w: %v", ErrInstallInvalid, err) + } + archive := archiveDigest(request.Bundle) + if err := validateInstallExpectations(request, origin, pkg, archive); err != nil { + return nil, "", err + } + return pkg, archive, nil +} + +func validateInstallExpectations(request InstallRequest, origin string, pkg *webapp.Package, archive string) error { + if request.PackageID != "" && request.PackageID != pkg.Manifest.ID { + return ErrPackageDigestMismatch + } + if request.ExpectedVersion != "" && request.ExpectedVersion != pkg.Manifest.Version { + return ErrPackageDigestMismatch + } + if request.ExpectedDigest != "" && request.ExpectedDigest != pkg.Digest { + return ErrPackageDigestMismatch + } + if request.ExpectedArchiveDigest != "" && request.ExpectedArchiveDigest != archive { + return ErrPackageDigestMismatch + } + return nil +} + +func (s *DistributionService) stageInstall(ctx context.Context, request InstallRequest, origin, archive string, pkg *webapp.Package) (InstallReview, error) { + metadata := installMetadata(pkg.Manifest) + repositoryURL := sanitizeRepositoryURL(request.RepositoryURL) + preparation, err := s.preparations.Create(ctx, Preparation{ + UserID: request.UserID, WorkspaceID: request.WorkspaceID, PackageID: pkg.Manifest.ID, + PackageVersion: pkg.Manifest.Version, PackageDigest: pkg.Digest, PackageArchiveDigest: archive, OriginKind: origin, + SourceID: request.SourceID, RepositoryURL: repositoryURL, + Metadata: ExportMetadata{PackageID: pkg.Manifest.ID, Version: pkg.Manifest.Version, DisplayName: pkg.Manifest.DisplayName, Description: pkg.Manifest.Description, Author: pkg.Manifest.Author, License: pkg.Manifest.Distribution.License, SourceMode: pkg.Manifest.Distribution.SourceMode, MinKandevVersion: pkg.Manifest.MinKandevVersion, RepoURL: pkg.Manifest.RepoURL}, + Bundle: append([]byte(nil), request.Bundle...), Files: distributionInventory(pkg.Files), + }) + if err != nil { + return InstallReview{}, err + } + return InstallReview{PreparationID: preparation.ID, WorkspaceID: request.WorkspaceID, Metadata: metadata, Digest: pkg.Digest, ArchiveDigest: archive, PermissionSummary: ManifestPermissions(pkg.Manifest), OriginKind: origin, SourceID: request.SourceID, RepositoryURL: repositoryURL, ExpiresAt: preparation.ExpiresAt.UTC().Format("2006-01-02T15:04:05.000Z07:00")}, nil +} + +// PrepareInstallFromURL downloads one bounded HTTPS bundle and routes the +// resulting bytes through the same inspection path as uploads. +func (s *DistributionService) PrepareInstallFromURL(ctx context.Context, request InstallRequest, rawURL string) (InstallReview, error) { + if s == nil || strings.TrimSpace(request.UserID) == "" || strings.TrimSpace(request.WorkspaceID) == "" { + return InstallReview{}, ErrInstallInvalid + } + if err := s.authorizeWorkspace(ctx, request.WorkspaceID); err != nil { + return InstallReview{}, err + } + bundle, err := s.downloadInstallBundle(ctx, rawURL) + if err != nil { + return InstallReview{}, err + } + request.Bundle = bundle + if strings.TrimSpace(request.OriginKind) == "" { + request.OriginKind = installOriginURL + } + if strings.TrimSpace(request.RepositoryURL) == "" { + request.RepositoryURL = sanitizeRepositoryURL(rawURL) + } + return s.PrepareInstall(ctx, request) +} + +// PrepareInstallFromCatalog resolves and downloads the exact server-selected +// canvas listing before entering the normal inspected-byte review path. +func (s *DistributionService) PrepareInstallFromCatalog(ctx context.Context, request InstallRequest) (InstallReview, error) { + if s == nil || strings.TrimSpace(request.UserID) == "" || strings.TrimSpace(request.WorkspaceID) == "" || strings.TrimSpace(request.SourceID) == "" || strings.TrimSpace(request.PackageID) == "" || strings.TrimSpace(request.ExpectedVersion) == "" || strings.TrimSpace(request.ExpectedArchiveDigest) == "" { + return InstallReview{}, ErrInstallInvalid + } + if err := s.authorizeWorkspace(ctx, request.WorkspaceID); err != nil { + return InstallReview{}, err + } + s.installMu.Lock() + resolver := s.catalog + s.installMu.Unlock() + if resolver == nil { + return InstallReview{}, ErrInstallUnavailable + } + packageURL, repositoryURL, err := resolver.ResolveCanvasPackage(ctx, request.SourceID, request.PackageID, request.ExpectedVersion, request.ExpectedArchiveDigest) + if err != nil { + return InstallReview{}, err + } + request.OriginKind = installOriginRegistry + request.RepositoryURL = repositoryURL + request.ExpectedDigest = "" + return s.PrepareInstallFromURL(ctx, request, packageURL) +} + +func (s *DistributionService) ConfirmInstall(ctx context.Context, userID, preparationID string, confirmation InstallConfirmation) (InstallResult, error) { + if s == nil || s.preparations == nil || strings.TrimSpace(userID) == "" || strings.TrimSpace(preparationID) == "" { + return InstallResult{}, ErrInstallInvalid + } + if !confirmation.Approved { + return InstallResult{}, ErrInstallNotApproved + } + if result, ok, err := s.lookupReceipt(ctx, userID, preparationID); err != nil { + return InstallResult{}, err + } else if ok { + return s.authorizeInstallReceipt(ctx, result) + } + s.installMu.Lock() + defer s.installMu.Unlock() + if result, ok, err := s.lookupReceiptLocked(ctx, userID, preparationID); err != nil { + return InstallResult{}, err + } else if ok { + return s.authorizeInstallReceipt(ctx, result) + } + preparation, err := s.preparations.Get(ctx, userID, preparationID) + if err != nil { + return InstallResult{}, err + } + if err := s.authorizeWorkspace(ctx, preparation.WorkspaceID); err != nil { + return InstallResult{}, err + } + pkg, err := s.openInstallPackage(ctx, userID, preparation, confirmation.ExpectedDigest) + if err != nil { + return InstallResult{}, err + } + receipt := InstallReceipt{PreparationID: preparation.ID, UserID: userID, WorkspaceID: preparation.WorkspaceID, PackageID: preparation.PackageID, Version: preparation.PackageVersion, Digest: preparation.PackageDigest, SourceID: preparation.SourceID, RepositoryURL: sanitizeRepositoryURL(preparation.RepositoryURL), OriginKind: preparation.OriginKind, CreatedAt: time.Now().UTC()} + return s.installPreparedPackage(ctx, userID, preparation, pkg, receipt) +} + +func (s *DistributionService) authorizeInstallReceipt(ctx context.Context, result InstallResult) (InstallResult, error) { + if err := s.authorizeWorkspace(ctx, result.Receipt.WorkspaceID); err != nil { + return InstallResult{}, err + } + return result, nil +} + +func (s *DistributionService) installPreparedPackage(ctx context.Context, userID string, preparation Preparation, pkg *webapp.Package, receipt InstallReceipt) (InstallResult, error) { + installer, ok := s.canvases.(CanvasInstaller) + if !ok { + return InstallResult{}, ErrInstallUnavailable + } + if err := s.authorizeWorkspace(ctx, preparation.WorkspaceID); err != nil { + return InstallResult{}, err + } + writer, ok := s.artifacts.(ArtifactWriter) + if !ok { + return InstallResult{}, ErrInstallUnavailable + } + quota := s.artifactQuota + var reservation plugininstances.Reservation + if quota != nil { + var err error + reservation, err = quota.ReserveBytes(ctx, preparation.WorkspaceID, pkg.ExpandedBytes, plugininstances.WorkspaceArtifactLimitBytes, plugininstances.InstallationArtifactLimitBytes) + if err != nil { + return InstallResult{}, err + } + defer func() { _ = quota.ReleaseBytes(context.WithoutCancel(ctx), reservation.ID) }() + } + artifact, created, err := putInstallArtifact(writer, pkg) + if err != nil { + return InstallResult{}, err + } + result, err := installer.InstallCanvasPackage(ctx, InstallCanvasPackageRequest{WorkspaceID: preparation.WorkspaceID, Title: pkg.Manifest.DisplayName, Package: pkg, Artifact: artifact, SourceActorKind: "canvas-install:" + preparation.OriginKind, SourceUserID: userID, Approved: true, Receipt: receipt}) + if err != nil { + return s.handleInstallFailure(ctx, userID, preparation.ID, writer, artifact, created, result, err) + } + if !validInstallResult(result) { + return InstallResult{}, ErrInstallUnavailable + } + if err := s.cacheInstallReceipt(ctx, userID, preparation.ID, result.Receipt); err != nil { + return InstallResult{}, err + } + return *result, nil +} + +func (s *DistributionService) handleInstallFailure(ctx context.Context, userID, preparationID string, writer ArtifactWriter, artifact webapp.Artifact, created bool, result *InstallResult, installErr error) (InstallResult, error) { + existing, found, lookupErr := s.lookupReceiptLocked(ctx, userID, preparationID) + if lookupErr != nil { + return InstallResult{}, errors.Join(installErr, lookupErr) + } + if found { + return s.authorizeInstallReceipt(ctx, existing) + } + if !created || validInstallResult(result) { + return InstallResult{}, installErr + } + remover, ok := writer.(artifactRemover) + if !ok { + return InstallResult{}, installErr + } + if cleanupErr := remover.Remove(artifact); cleanupErr != nil { + return InstallResult{}, errors.Join(installErr, cleanupErr) + } + return InstallResult{}, installErr +} + +func validInstallResult(result *InstallResult) bool { + return result != nil && result.Canvas != nil && result.Receipt.CanvasID != "" +} + +func (s *DistributionService) openInstallPackage(ctx context.Context, userID string, preparation Preparation, expectedDigest string) (*webapp.Package, error) { + if expectedDigest == "" || (expectedDigest != preparation.PackageDigest && expectedDigest != preparation.PackageArchiveDigest) { + return nil, ErrPackageDigestMismatch + } + bundle, err := s.preparations.Open(ctx, userID, preparation.ID, ExportBundle) + if err != nil { + return nil, err + } + pkg, err := webapp.ValidateDistributionPackage(bytes.NewReader(bundle)) + if err != nil { + return nil, fmt.Errorf("%w: %v", ErrInstallInvalid, err) + } + if pkg.Digest != preparation.PackageDigest { + return nil, ErrPackageDigestMismatch + } + return pkg, nil +} + +func (s *DistributionService) cacheInstallReceipt(ctx context.Context, userID, preparationID string, receipt InstallReceipt) error { + if s.receipts == nil { + s.receipts = make(map[string]InstallReceipt) + } + s.receipts[preparationID] = receipt + return s.preparations.Delete(ctx, userID, preparationID) +} + +func (s *DistributionService) lookupReceipt(ctx context.Context, userID, preparationID string) (InstallResult, bool, error) { + s.installMu.Lock() + defer s.installMu.Unlock() + return s.lookupReceiptLocked(ctx, userID, preparationID) +} + +func (s *DistributionService) lookupReceiptLocked(ctx context.Context, userID, preparationID string) (InstallResult, bool, error) { + receipt, ok := s.receipts[preparationID] + if !ok && s.receiptStore != nil { + stored, err := s.receiptStore.GetInstallReceipt(ctx, preparationID, userID) + if err == nil { + receipt, ok = stored, true + s.receipts[preparationID] = stored + } else if !errors.Is(err, ErrInstallReceiptNotFound) { + return InstallResult{}, false, err + } + } + if !ok || receipt.UserID != userID { + return InstallResult{}, false, nil + } + item, err := s.canvases.Get(ctx, receipt.CanvasID) + if errors.Is(err, ErrCanvasNotFound) || item == nil || item.WorkspaceID != receipt.WorkspaceID { + return InstallResult{}, false, nil + } + if err != nil { + return InstallResult{}, false, err + } + return InstallResult{Canvas: item, Receipt: receipt}, true, nil +} + +func putInstallArtifact(writer ArtifactWriter, pkg *webapp.Package) (webapp.Artifact, bool, error) { + if withCreated, ok := writer.(artifactWriterWithCreated); ok { + return withCreated.PutWithCreated(pkg) + } + artifact, err := writer.Put(pkg) + return artifact, false, err +} + +func installMetadata(m *manifest.Manifest) InstallMetadata { + if m == nil || m.Distribution == nil { + return InstallMetadata{} + } + return InstallMetadata{PackageID: m.ID, Version: m.Version, DisplayName: m.DisplayName, Description: m.Description, Author: m.Author, License: m.Distribution.License, SourceMode: m.Distribution.SourceMode, MinKandevVersion: m.MinKandevVersion, RepoURL: m.RepoURL} +} + +func sanitizeRepositoryURL(value string) string { + value = strings.TrimSpace(value) + if value == "" { + return "" + } + // Provenance is public metadata only. Queries and fragments can contain + // signed-download credentials, so they are never persisted. + if index := strings.IndexAny(value, "?#"); index >= 0 { + value = value[:index] + } + return value +} + +func installReview(preparation Preparation, pkg *webapp.Package) InstallReview { + metadata := installMetadata(pkg.Manifest) + return InstallReview{ + PreparationID: preparation.ID, WorkspaceID: preparation.WorkspaceID, + Metadata: metadata, Digest: pkg.Digest, ArchiveDigest: preparation.PackageArchiveDigest, + PermissionSummary: ManifestPermissions(pkg.Manifest), OriginKind: preparation.OriginKind, + SourceID: preparation.SourceID, RepositoryURL: sanitizeRepositoryURL(preparation.RepositoryURL), + ExpiresAt: preparation.ExpiresAt.UTC().Format("2006-01-02T15:04:05.000Z07:00"), + } +} diff --git a/apps/backend/internal/canvas/distribution_preparation.go b/apps/backend/internal/canvas/distribution_preparation.go new file mode 100644 index 0000000000..0e800f8603 --- /dev/null +++ b/apps/backend/internal/canvas/distribution_preparation.go @@ -0,0 +1,347 @@ +package canvas + +import ( + "context" + "errors" + "fmt" + "os" + "path/filepath" + "strings" + "sync" + "time" + + "github.com/google/uuid" +) + +const ( + PreparationTTL = 15 * time.Minute + MaxPreparationsPerUser = 2 + MaxStagedBytes = int64(256 << 20) +) + +var ( + ErrPreparationNotFound = errors.New("canvas preparation not found") + ErrPreparationExpired = errors.New("canvas preparation expired") + ErrPreparationLimit = errors.New("canvas preparation limit reached") + ErrPreparationInvalid = errors.New("invalid canvas preparation") +) + +type ExportKind string + +const ( + ExportBundle ExportKind = "bundle" + ExportSource ExportKind = "source" +) + +// Preparation is temporary, user-bound distribution state. Bundle and Source +// are accepted only by Create and are not kept in the store's metadata map. +type Preparation struct { + ID string + UserID string + WorkspaceID string + CanvasID string + ReleaseID string + PackageID string + PackageVersion string + PackageDigest string + PackageArchiveDigest string + Metadata ExportMetadata + SourceID string + OriginKind string + RepositoryURL string + Bundle []byte + Source []byte + Files []ExportFile + CreatedAt time.Time + ExpiresAt time.Time +} + +type ExportFile struct { + Path string `json:"path"` + Bytes int64 `json:"bytes"` +} + +type preparationRecord struct { + Preparation + BundleBytes int64 + SourceBytes int64 + leases int +} + +// PreparationStore stages files below a dedicated recovery boundary and keeps +// only bounded metadata in memory. It is intentionally process-local: startup +// cleanup invalidates every uncommitted preparation. +type PreparationStore struct { + mu sync.Mutex + root string + clock func() time.Time + items map[string]*preparationRecord +} + +func NewPreparationStore(root string) (*PreparationStore, error) { + if strings.TrimSpace(root) == "" { + return nil, ErrPreparationInvalid + } + if err := os.MkdirAll(root, 0o700); err != nil { + return nil, fmt.Errorf("canvas preparations: create root: %w", err) + } + entries, err := os.ReadDir(root) + if err != nil { + return nil, fmt.Errorf("canvas preparations: inspect root: %w", err) + } + for _, entry := range entries { + if err := os.RemoveAll(filepath.Join(root, entry.Name())); err != nil { + return nil, fmt.Errorf("canvas preparations: clear stale staging: %w", err) + } + } + return &PreparationStore{root: root, clock: time.Now, items: make(map[string]*preparationRecord)}, nil +} + +func (s *PreparationStore) SetClock(clock func() time.Time) { + if s == nil || clock == nil { + return + } + s.mu.Lock() + s.clock = clock + s.mu.Unlock() +} + +func (s *PreparationStore) Create(ctx context.Context, input Preparation) (Preparation, error) { + if err := validatePreparationCreate(ctx, s, input); err != nil { + return Preparation{}, err + } + input, err := preparePreparationIdentity(input) + if err != nil { + return Preparation{}, err + } + s.mu.Lock() + defer s.mu.Unlock() + now := s.nowLocked() + if err := s.removeExpiredLocked(now); err != nil { + return Preparation{}, fmt.Errorf("canvas preparations: remove expired: %w", err) + } + if err := s.validateCreateAdmissionLocked(input); err != nil { + return Preparation{}, err + } + input, err = setPreparationTimes(input, now) + if err != nil { + return Preparation{}, err + } + if err := s.stagePreparation(input); err != nil { + return Preparation{}, err + } + record := &preparationRecord{Preparation: input, BundleBytes: int64(len(input.Bundle)), SourceBytes: int64(len(input.Source))} + record.Bundle = nil + record.Source = nil + s.items[input.ID] = record + return input, nil +} + +func validatePreparationCreate(ctx context.Context, store *PreparationStore, input Preparation) error { + if err := ctx.Err(); err != nil { + return err + } + if store == nil || strings.TrimSpace(input.UserID) == "" || strings.TrimSpace(input.WorkspaceID) == "" { + return ErrPreparationInvalid + } + if len(input.Bundle) == 0 && len(input.Source) == 0 { + return ErrPreparationInvalid + } + return nil +} + +func preparePreparationIdentity(input Preparation) (Preparation, error) { + if strings.TrimSpace(input.ID) == "" { + input.ID = uuid.NewString() + } + if !safePreparationID(input.ID) { + return Preparation{}, ErrPreparationInvalid + } + return input, nil +} + +func (s *PreparationStore) validateCreateAdmissionLocked(input Preparation) error { + if _, exists := s.items[input.ID]; exists { + return ErrPreparationInvalid + } + if s.userCountLocked(input.UserID) >= MaxPreparationsPerUser || s.stagedBytesLocked()+int64(len(input.Bundle))+int64(len(input.Source)) > MaxStagedBytes { + return ErrPreparationLimit + } + return nil +} + +func setPreparationTimes(input Preparation, now time.Time) (Preparation, error) { + if input.CreatedAt.IsZero() { + input.CreatedAt = now + } + if input.ExpiresAt.IsZero() { + input.ExpiresAt = now.Add(PreparationTTL) + } + if !input.ExpiresAt.After(now) { + return Preparation{}, ErrPreparationExpired + } + return input, nil +} + +func (s *PreparationStore) stagePreparation(input Preparation) error { + directory := filepath.Join(s.root, input.ID) + if err := os.MkdirAll(directory, 0o700); err != nil { + return fmt.Errorf("canvas preparations: create staging directory: %w", err) + } + if err := writePreparationFile(directory, "bundle", input.Bundle); err != nil { + _ = os.RemoveAll(directory) + return err + } + if err := writePreparationFile(directory, "source", input.Source); err != nil { + _ = os.RemoveAll(directory) + return err + } + return nil +} + +func writePreparationFile(directory, name string, data []byte) error { + if len(data) == 0 { + return nil + } + path := filepath.Join(directory, name) + if err := os.WriteFile(path, data, 0o600); err != nil { + return fmt.Errorf("canvas preparations: stage %s: %w", name, err) + } + return nil +} + +func (s *PreparationStore) Get(ctx context.Context, userID, id string) (Preparation, error) { + if err := ctx.Err(); err != nil { + return Preparation{}, err + } + s.mu.Lock() + defer s.mu.Unlock() + record, err := s.lookupLocked(userID, id) + if err != nil { + return Preparation{}, err + } + return record.Preparation, nil +} + +func (s *PreparationStore) Open(ctx context.Context, userID, id string, kind ExportKind) ([]byte, error) { + if err := ctx.Err(); err != nil { + return nil, err + } + s.mu.Lock() + record, err := s.lookupLocked(userID, id) + if err != nil { + s.mu.Unlock() + return nil, err + } + record.leases++ + root := s.root + s.mu.Unlock() + defer s.releaseLease(id) + name := string(kind) + if kind != ExportBundle && kind != ExportSource { + return nil, ErrPreparationInvalid + } + data, err := os.ReadFile(filepath.Join(root, id, name)) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return nil, ErrPreparationNotFound + } + return nil, err + } + return data, nil +} + +func (s *PreparationStore) Delete(ctx context.Context, userID, id string) error { + if err := ctx.Err(); err != nil { + return err + } + s.mu.Lock() + defer s.mu.Unlock() + record, exists := s.items[id] + if !exists || record.UserID != userID { + return nil + } + if record.leases > 0 { + return nil + } + delete(s.items, id) + return os.RemoveAll(filepath.Join(s.root, id)) +} + +func (s *PreparationStore) Cleanup(ctx context.Context) error { + if err := ctx.Err(); err != nil { + return err + } + s.mu.Lock() + defer s.mu.Unlock() + return s.removeExpiredLocked(s.nowLocked()) +} + +func (s *PreparationStore) releaseLease(id string) { + s.mu.Lock() + defer s.mu.Unlock() + if record, exists := s.items[id]; exists && record.leases > 0 { + record.leases-- + } +} + +func (s *PreparationStore) lookupLocked(userID, id string) (*preparationRecord, error) { + if !safePreparationID(id) { + return nil, ErrPreparationNotFound + } + record, exists := s.items[id] + if !exists || record.UserID != userID { + return nil, ErrPreparationNotFound + } + if !record.ExpiresAt.After(s.nowLocked()) { + if record.leases == 0 { + delete(s.items, id) + _ = os.RemoveAll(filepath.Join(s.root, id)) + } + return nil, ErrPreparationExpired + } + return record, nil +} + +func (s *PreparationStore) removeExpiredLocked(now time.Time) error { + var firstErr error + for id, record := range s.items { + if record.leases > 0 || record.ExpiresAt.After(now) { + continue + } + delete(s.items, id) + if err := os.RemoveAll(filepath.Join(s.root, id)); err != nil && firstErr == nil { + firstErr = err + } + } + return firstErr +} + +func (s *PreparationStore) userCountLocked(userID string) int { + count := 0 + for _, record := range s.items { + if record.UserID == userID { + count++ + } + } + return count +} + +func (s *PreparationStore) stagedBytesLocked() int64 { + var total int64 + for _, record := range s.items { + total += record.BundleBytes + record.SourceBytes + } + return total +} + +func (s *PreparationStore) nowLocked() time.Time { + if s.clock == nil { + return time.Now().UTC() + } + return s.clock().UTC() +} + +func safePreparationID(id string) bool { + return id != "" && filepath.Base(id) == id && !strings.ContainsAny(id, "/\\\x00") +} diff --git a/apps/backend/internal/canvas/distribution_preparation_test.go b/apps/backend/internal/canvas/distribution_preparation_test.go new file mode 100644 index 0000000000..d34de5755f --- /dev/null +++ b/apps/backend/internal/canvas/distribution_preparation_test.go @@ -0,0 +1,59 @@ +package canvas + +import ( + "context" + "errors" + "testing" + "time" +) + +func TestPreparationStoreExpiresAndReleasesStagedFiles(t *testing.T) { + store, err := NewPreparationStore(t.TempDir()) + if err != nil { + t.Fatalf("NewPreparationStore() error = %v", err) + } + now := time.Date(2026, 9, 10, 12, 0, 0, 0, time.UTC) + store.SetClock(func() time.Time { return now }) + created, err := store.Create(context.Background(), Preparation{ + ID: "prep-1", UserID: "user-1", WorkspaceID: "workspace-1", CanvasID: "canvas-1", + ReleaseID: "release-1", PackageDigest: "digest-1", Bundle: []byte("bundle"), Source: []byte("source"), + }) + if err != nil { + t.Fatalf("Create() error = %v", err) + } + if created.ExpiresAt.Sub(now) != PreparationTTL { + t.Fatalf("expires in %s, want %s", created.ExpiresAt.Sub(now), PreparationTTL) + } + if got, err := store.Open(context.Background(), "user-1", "prep-1", ExportBundle); err != nil || string(got) != "bundle" { + t.Fatalf("Open(bundle) = %q, %v", got, err) + } + now = now.Add(PreparationTTL + time.Second) + if _, err := store.Get(context.Background(), "user-1", "prep-1"); !errors.Is(err, ErrPreparationExpired) { + t.Fatalf("Get(expired) error = %v, want ErrPreparationExpired", err) + } + if err := store.Cleanup(context.Background()); err != nil { + t.Fatalf("Cleanup() error = %v", err) + } + if _, err := store.Get(context.Background(), "user-1", "prep-1"); !errors.Is(err, ErrPreparationNotFound) { + t.Fatalf("Get(cleaned) error = %v, want ErrPreparationNotFound", err) + } +} + +func TestPreparationStoreLimitsPerUserAndStagedBytes(t *testing.T) { + store, err := NewPreparationStore(t.TempDir()) + if err != nil { + t.Fatalf("NewPreparationStore() error = %v", err) + } + for i := 0; i < MaxPreparationsPerUser; i++ { + _, err := store.Create(context.Background(), Preparation{ + ID: string(rune('a' + i)), UserID: "user-1", WorkspaceID: "workspace-1", + Bundle: []byte("bundle"), Source: []byte("source"), + }) + if err != nil { + t.Fatalf("Create(%d) error = %v", i, err) + } + } + if _, err := store.Create(context.Background(), Preparation{ID: "overflow", UserID: "user-1", WorkspaceID: "workspace-1", Bundle: []byte("bundle")}); !errors.Is(err, ErrPreparationLimit) { + t.Fatalf("Create(per-user overflow) error = %v, want ErrPreparationLimit", err) + } +} diff --git a/apps/backend/internal/canvas/release_projection.go b/apps/backend/internal/canvas/release_projection.go index ade674e2bb..95efd024a2 100644 --- a/apps/backend/internal/canvas/release_projection.go +++ b/apps/backend/internal/canvas/release_projection.go @@ -1,15 +1,27 @@ package canvas import ( + "encoding/json" "sort" plugininstances "github.com/kandev/kandev/internal/plugins/instances" + "github.com/kandev/kandev/internal/plugins/manifest" ) func releaseMetadata(release plugininstances.Release, scope string, grants []plugininstances.Grant) *ReleaseMetadata { permissions := ReleasePermissionSummary(release) + seed := releaseManifestSeed(release.ManifestJSON) return &ReleaseMetadata{ ID: release.ID, + PackageID: seed.PackageID, + Version: seed.Version, + DisplayName: seed.DisplayName, + Description: seed.Description, + Author: seed.Author, + License: seed.License, + SourceMode: seed.SourceMode, + MinKandevVersion: seed.MinKandevVersion, + RepoURL: seed.RepoURL, PackageDigest: release.PackageDigest, ValidationStatus: release.ValidationStatus, ValidationError: release.ValidationError, @@ -25,6 +37,39 @@ func releaseMetadata(release plugininstances.Release, scope string, grants []plu } } +type manifestSeed struct { + PackageID string + Version string + DisplayName string + Description string + Author string + License string + SourceMode string + MinKandevVersion string + RepoURL string +} + +func releaseManifestSeed(data json.RawMessage) manifestSeed { + var value manifest.Manifest + if err := json.Unmarshal(data, &value); err != nil { + return manifestSeed{} + } + seed := manifestSeed{ + PackageID: value.ID, + Version: value.Version, + DisplayName: value.DisplayName, + Description: value.Description, + Author: value.Author, + MinKandevVersion: value.MinKandevVersion, + RepoURL: value.RepoURL, + } + if value.Distribution != nil { + seed.License = value.Distribution.License + seed.SourceMode = value.Distribution.SourceMode + } + return seed +} + func effectiveGrantProjection(instance plugininstances.Instance, summary PermissionSummary, grants []plugininstances.Grant) []GrantProjection { if instance.ActiveReleaseID == "" { return nil diff --git a/apps/backend/internal/canvas/release_projection_test.go b/apps/backend/internal/canvas/release_projection_test.go new file mode 100644 index 0000000000..be21f59cb6 --- /dev/null +++ b/apps/backend/internal/canvas/release_projection_test.go @@ -0,0 +1,25 @@ +package canvas + +import ( + "encoding/json" + "testing" + "time" + + plugininstances "github.com/kandev/kandev/internal/plugins/instances" + "github.com/kandev/kandev/internal/plugins/manifest" +) + +func TestReleaseMetadataIncludesPortableManifestSeed(t *testing.T) { + manifestJSON, err := json.Marshal(manifest.Manifest{ + ID: "canvas-one", Version: "1.0.0", DisplayName: "Canvas One", Description: "A canvas", + Author: "Author", MinKandevVersion: "0.94.0", RepoURL: "https://example.test/canvas", + Distribution: &manifest.Distribution{License: "MIT", SourceMode: manifest.SourceModeStatic}, + }) + if err != nil { + t.Fatalf("marshal manifest: %v", err) + } + seed := releaseMetadata(plugininstances.Release{ID: "release-1", ManifestJSON: manifestJSON, CreatedAt: time.Now()}, ScopeWorkspace, nil) + if seed.PackageID != "canvas-one" || seed.Version != "1.0.0" || seed.Author != "Author" || seed.License != "MIT" || seed.SourceMode != manifest.SourceModeStatic { + t.Fatalf("release metadata seed = %+v", seed) + } +} diff --git a/apps/backend/internal/canvas/repository.go b/apps/backend/internal/canvas/repository.go index aa93c25dd7..7b75addbb8 100644 --- a/apps/backend/internal/canvas/repository.go +++ b/apps/backend/internal/canvas/repository.go @@ -57,8 +57,100 @@ CREATE TABLE IF NOT EXISTS canvas_lifecycle_admission ( ); INSERT INTO canvas_lifecycle_admission (id, version) VALUES (1, 1) ON CONFLICT (id) DO NOTHING; +CREATE TABLE IF NOT EXISTS canvas_install_receipts ( + preparation_id TEXT PRIMARY KEY, + user_id TEXT NOT NULL, + canvas_id TEXT NOT NULL UNIQUE, + workspace_id TEXT NOT NULL, + package_id TEXT NOT NULL, + package_version TEXT NOT NULL, + package_digest TEXT NOT NULL, + source_id TEXT NOT NULL DEFAULT '', + repository_url TEXT NOT NULL DEFAULT '', + origin_kind TEXT NOT NULL, + created_at TEXT NOT NULL +); +CREATE INDEX IF NOT EXISTS idx_canvas_install_receipts_workspace + ON canvas_install_receipts(workspace_id, created_at, preparation_id); ` +func (r *Repository) GetInstallReceipt(ctx context.Context, preparationID, userID string) (InstallReceipt, error) { + if strings.TrimSpace(preparationID) == "" || strings.TrimSpace(userID) == "" { + return InstallReceipt{}, ErrInstallReceiptNotFound + } + var row struct { + PreparationID string `db:"preparation_id"` + UserID string `db:"user_id"` + CanvasID string `db:"canvas_id"` + WorkspaceID string `db:"workspace_id"` + PackageID string `db:"package_id"` + PackageVersion string `db:"package_version"` + PackageDigest string `db:"package_digest"` + SourceID string `db:"source_id"` + RepositoryURL string `db:"repository_url"` + OriginKind string `db:"origin_kind"` + CreatedAt string `db:"created_at"` + } + err := r.ro.GetContext(ctx, &row, r.ro.Rebind(`SELECT preparation_id, user_id, canvas_id, workspace_id, package_id, package_version, package_digest, source_id, repository_url, origin_kind, created_at FROM canvas_install_receipts WHERE preparation_id = ? AND user_id = ?`), preparationID, userID) + if errors.Is(err, sql.ErrNoRows) { + return InstallReceipt{}, ErrInstallReceiptNotFound + } + if err != nil { + return InstallReceipt{}, err + } + createdAt, err := time.Parse(time.RFC3339Nano, row.CreatedAt) + if err != nil { + return InstallReceipt{}, err + } + return InstallReceipt{PreparationID: row.PreparationID, UserID: row.UserID, CanvasID: row.CanvasID, WorkspaceID: row.WorkspaceID, PackageID: row.PackageID, Version: row.PackageVersion, Digest: row.PackageDigest, SourceID: row.SourceID, RepositoryURL: row.RepositoryURL, OriginKind: row.OriginKind, CreatedAt: createdAt}, nil +} + +func (r *Repository) CreateInstallReceiptTx(ctx context.Context, tx *sqlx.Tx, receipt InstallReceipt) error { + if strings.TrimSpace(receipt.PreparationID) == "" || strings.TrimSpace(receipt.UserID) == "" || strings.TrimSpace(receipt.CanvasID) == "" || strings.TrimSpace(receipt.WorkspaceID) == "" || strings.TrimSpace(receipt.PackageID) == "" || strings.TrimSpace(receipt.Version) == "" || strings.TrimSpace(receipt.Digest) == "" || strings.TrimSpace(receipt.OriginKind) == "" { + return ErrInstallInvalid + } + createdAt := receipt.CreatedAt.UTC() + if createdAt.IsZero() { + createdAt = time.Now().UTC() + } + _, err := tx.ExecContext(ctx, tx.Rebind(`INSERT INTO canvas_install_receipts (preparation_id, user_id, canvas_id, workspace_id, package_id, package_version, package_digest, source_id, repository_url, origin_kind, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`), receipt.PreparationID, receipt.UserID, receipt.CanvasID, receipt.WorkspaceID, receipt.PackageID, receipt.Version, receipt.Digest, receipt.SourceID, receipt.RepositoryURL, receipt.OriginKind, createdAt.Format(time.RFC3339Nano)) + return err +} + +func (r *Repository) CreateInstallReceipt(ctx context.Context, receipt InstallReceipt) error { + tx, err := r.db.BeginTxx(ctx, nil) + if err != nil { + return err + } + defer func() { _ = tx.Rollback() }() + if err := r.CreateInstallReceiptTx(ctx, tx, receipt); err != nil { + return err + } + return tx.Commit() +} + +func (r *Repository) DeleteInstallReceiptTx(ctx context.Context, tx *sqlx.Tx, canvasID string) error { + if strings.TrimSpace(canvasID) == "" { + return ErrInvalidCanvas + } + _, err := tx.ExecContext(ctx, tx.Rebind(`DELETE FROM canvas_install_receipts WHERE canvas_id = ?`), canvasID) + return err +} + +// DeleteInstallReceipt removes the durable retry receipt for a canvas after +// the canvas authority and plugin instance have been removed. +func (r *Repository) DeleteInstallReceipt(ctx context.Context, canvasID string) error { + tx, err := r.db.BeginTxx(ctx, nil) + if err != nil { + return err + } + defer func() { _ = tx.Rollback() }() + if err := r.DeleteInstallReceiptTx(ctx, tx, canvasID); err != nil { + return err + } + return tx.Commit() +} + // NewRepository constructs the canvas metadata repository on an existing // application database pool. Plugin instance schema initialization remains // owned by internal/plugins/instances. diff --git a/apps/backend/internal/canvas/service.go b/apps/backend/internal/canvas/service.go index dcbb61379e..0a33cfe7aa 100644 --- a/apps/backend/internal/canvas/service.go +++ b/apps/backend/internal/canvas/service.go @@ -379,6 +379,9 @@ func (s *Service) removeLocked(ctx context.Context, id string) (LifecycleEvent, func (s *Service) removeAuthority(ctx context.Context, metadata CanvasMetadata, instance plugininstances.Instance) error { if instance.Status == StatusRemoved { + if err := s.repo.DeleteInstallReceipt(ctx, metadata.ID); err != nil { + return err + } err := s.repo.Delete(ctx, metadata.ID) if errors.Is(err, ErrCanvasNotFound) { return nil @@ -391,12 +394,18 @@ func (s *Service) removeAuthority(ctx context.Context, metadata CanvasMetadata, if err := transactional.RemoveInstanceTx(ctx, tx, instance.ID); err != nil { return err } + if err := s.repo.DeleteInstallReceiptTx(ctx, tx, metadata.ID); err != nil { + return err + } return s.repo.DeleteTx(ctx, tx, metadata.ID) }) } if err := s.instances.RemoveInstance(ctx, instance.ID); err != nil { return err } + if err := s.repo.DeleteInstallReceipt(ctx, metadata.ID); err != nil { + return err + } return s.repo.Delete(ctx, metadata.ID) } @@ -578,6 +587,9 @@ func (s *Service) removeMetadataLocked(ctx context.Context, metadata CanvasMetad return LifecycleEvent{}, err } } + if err := s.repo.DeleteInstallReceipt(ctx, metadata.ID); err != nil { + return LifecycleEvent{}, err + } if err := s.repo.Delete(ctx, metadata.ID); err != nil && !errors.Is(err, ErrCanvasNotFound) { return LifecycleEvent{}, err } diff --git a/apps/backend/internal/canvas/types.go b/apps/backend/internal/canvas/types.go index ea128f3dc1..62e4aa84cf 100644 --- a/apps/backend/internal/canvas/types.go +++ b/apps/backend/internal/canvas/types.go @@ -126,10 +126,19 @@ type Canvas struct { } // ReleaseMetadata deliberately omits manifest and artifact contents. The host -// receives only the review-safe declaration, provenance, and current grant +// receives only the review-safe package identity, provenance, and current grant // diff needed to choose recovery actions. type ReleaseMetadata struct { ID string `json:"id"` + PackageID string `json:"package_id,omitempty"` + Version string `json:"version,omitempty"` + DisplayName string `json:"display_name,omitempty"` + Description string `json:"description,omitempty"` + Author string `json:"author,omitempty"` + License string `json:"license,omitempty"` + SourceMode string `json:"source_mode,omitempty"` + MinKandevVersion string `json:"min_kandev_version,omitempty"` + RepoURL string `json:"repo_url,omitempty"` PackageDigest string `json:"package_digest"` ValidationStatus string `json:"validation_status"` ValidationError string `json:"validation_error,omitempty"` diff --git a/apps/backend/internal/mcp/canvasskill/files/SKILL.md b/apps/backend/internal/mcp/canvasskill/files/SKILL.md index aaa793c10b..8a92426152 100644 --- a/apps/backend/internal/mcp/canvasskill/files/SKILL.md +++ b/apps/backend/internal/mcp/canvasskill/files/SKILL.md @@ -99,3 +99,25 @@ Read a supporting reference only when its topic is needed: - `references/events-and-recovery.md` for events, reconnect, and retries. - `references/security.md` for opaque-origin and source safety rules. - `references/ui-patterns.md` for responsive and accessible UI patterns. + +## Distribution checklist + +When the user asks for a portable canvas, keep the distribution boundary +separate from authoring and runtime state: + +1. Add `distribution.schema_version: 1`, `distribution.kind: canvas`, a + license, and `source_mode: static` or `source_mode: project`. +2. Keep `README.md`, the manifest, the application entry, and every local asset + in the package. Use project mode only when the retained project is complete + and bounded below `distribution/source/`. +3. Publish a valid release before offering a bundle or source download. The + host prepares both archives from that immutable release and does not include + screenshots. +4. Add screenshots later as ordered `previews` objects in a registry entry. + The first preview is the cover, canvas entries require one to eight images, + and plugin entries may omit images. + +The authoring tools do not create repositories, releases, registry entries, or +pull requests. Report those manual follow-up steps to the user. Do not claim +that a local archive or build is published until the Kandev release flow +confirms it. diff --git a/apps/backend/internal/mcp/canvasskill/files/references/manifest.md b/apps/backend/internal/mcp/canvasskill/files/references/manifest.md index 7c308cd734..68db109f4b 100644 --- a/apps/backend/internal/mcp/canvasskill/files/references/manifest.md +++ b/apps/backend/internal/mcp/canvasskill/files/references/manifest.md @@ -15,6 +15,7 @@ version: 1.0.0 display_name: Example canvas description: A small task canvas. author: Canvas author +min_kandev_version: "0.94.0" ui: web_apps: - key: main @@ -35,6 +36,12 @@ capabilities: events: - task.updated state: true + +distribution: + schema_version: 1 + kind: canvas + license: MIT + source_mode: static ``` The host parses YAML and calls the same manifest validator used for plugin @@ -74,3 +81,21 @@ authored scripts. It checks document startup and relative context access. Keep the entry valid HTML so the host can insert the bootstrap without changing the stored package. The host waits up to 15 seconds for the startup acknowledgement and exposes retry controls outside the frame when startup fails. + +## Portable distribution + +Use the `distribution` block when the canvas is intended for bundle, source, or +registry sharing. `kind` must be `canvas`, `schema_version` must be `1`, and +`source_mode` must be `static` or `project`. Include a non-empty license value +and a compatible `min_kandev_version` in the manifest. + +The package must include `README.md` and generated `checksums.txt`. A project +source mode package retains the editable project in +`distribution/source/manifest.yaml` and `distribution/source/README.md`. +Static source mode does not include that subtree. Keep source files bounded and +free of secrets. The host rejects native plugin contributions, backend +executables, unsafe paths, and unsupported files in a portable canvas. + +Registry screenshots are not package inputs. Add ordered preview objects to the +registry entry later. The first preview is the cover, canvas entries require +one to eight previews, and plugin entries may omit previews. diff --git a/apps/backend/internal/persistence/requiredstores/catalog.go b/apps/backend/internal/persistence/requiredstores/catalog.go index deceeb341c..e7095385f0 100644 --- a/apps/backend/internal/persistence/requiredstores/catalog.go +++ b/apps/backend/internal/persistence/requiredstores/catalog.go @@ -70,7 +70,7 @@ var catalog = []Descriptor{ {ID: "plugin-state", OwnerPackage: "internal/plugins/state", RequiredTables: []string{"plugin_state"}, DependsOn: []string{"schema-meta"}, Capabilities: []Capability{CapabilityBoolean, CapabilityTimestamp, CapabilityConflict}}, {ID: "plugin-instance-state", OwnerPackage: "internal/plugins/state", RequiredTables: []string{"plugin_instance_state"}, DependsOn: []string{"plugin-instances"}, Capabilities: []Capability{CapabilityBoolean, CapabilityTimestamp, CapabilityConflict}}, {ID: "plugin-user-state", OwnerPackage: "internal/plugins/state", RequiredTables: []string{"plugin_user_state"}, DependsOn: []string{"user"}, Capabilities: []Capability{CapabilityBoolean, CapabilityTimestamp, CapabilityConflict}}, - {ID: "canvas", OwnerPackage: "internal/canvas", RequiredTables: []string{"canvas_lifecycle_metadata", "canvas_creation_authority"}, DependsOn: []string{"plugin-instances"}, Capabilities: []Capability{CapabilityBoolean, CapabilityTimestamp, CapabilityConflict, CapabilityTransaction}}, + {ID: "canvas", OwnerPackage: "internal/canvas", RequiredTables: []string{"canvas_lifecycle_metadata", "canvas_creation_authority", "canvas_install_receipts"}, DependsOn: []string{"plugin-instances"}, Capabilities: []Capability{CapabilityBoolean, CapabilityTimestamp, CapabilityConflict, CapabilityTransaction}}, {ID: "github", OwnerPackage: "internal/github", RequiredTables: []string{"github_pr_watches"}, DependsOn: []string{"task", "user"}, Capabilities: []Capability{CapabilityBoolean, CapabilityTimestamp, CapabilityConflict}}, {ID: "gitlab", OwnerPackage: "internal/gitlab", RequiredTables: []string{"gitlab_configs"}, DependsOn: []string{"task", "user"}, Capabilities: []Capability{CapabilityBoolean, CapabilityTimestamp, CapabilityConflict}}, {ID: "jira", OwnerPackage: "internal/jira", RequiredTables: []string{"jira_configs"}, DependsOn: []string{"task", "user"}, Capabilities: []Capability{CapabilityBoolean, CapabilityTimestamp, CapabilityConflict}}, diff --git a/apps/backend/internal/plugins/instances/store.go b/apps/backend/internal/plugins/instances/store.go index 72d6b1a20a..b51117e7de 100644 --- a/apps/backend/internal/plugins/instances/store.go +++ b/apps/backend/internal/plugins/instances/store.go @@ -1213,13 +1213,17 @@ func (s *Store) ApproveRelease(ctx context.Context, instanceID, releaseID, appro if strings.TrimSpace(instanceID) == "" || strings.TrimSpace(releaseID) == "" || strings.TrimSpace(approvedBy) == "" { return ErrInvalidRelease } - s.admission.Lock() - defer s.admission.Unlock() - tx, err := s.db.BeginTxx(ctx, nil) - if err != nil { - return err + return s.WithTransaction(ctx, func(tx *sqlx.Tx) error { + return s.ApproveReleaseTx(ctx, tx, instanceID, releaseID, approvedBy, grants) + }) +} + +// ApproveReleaseTx applies explicit permission grants and activates a pending +// release inside a caller-owned lifecycle transaction. +func (s *Store) ApproveReleaseTx(ctx context.Context, tx *sqlx.Tx, instanceID, releaseID, approvedBy string, grants []Grant) error { + if strings.TrimSpace(instanceID) == "" || strings.TrimSpace(releaseID) == "" || strings.TrimSpace(approvedBy) == "" { + return ErrInvalidRelease } - defer func() { _ = tx.Rollback() }() release, err := loadPendingReleaseTx(ctx, tx, instanceID, releaseID) if err != nil { return err @@ -1251,7 +1255,7 @@ func (s *Store) ApproveRelease(ctx context.Context, instanceID, releaseID, appro if err := activateReleaseTx(ctx, tx, instanceID, releaseID); err != nil { return err } - return tx.Commit() + return nil } type pendingReleaseRow struct { diff --git a/apps/backend/internal/plugins/manifest/distribution.go b/apps/backend/internal/plugins/manifest/distribution.go new file mode 100644 index 0000000000..b13d6d9b62 --- /dev/null +++ b/apps/backend/internal/plugins/manifest/distribution.go @@ -0,0 +1,94 @@ +package manifest + +import ( + "errors" + "fmt" + "strings" +) + +const ( + DistributionSchemaVersion = 1 + DistributionKindCanvas = "canvas" + SourceModeStatic = "static" + SourceModeProject = "project" +) + +// Distribution is the package-owned declaration for a portable canvas. It +// intentionally contains no registry preview URLs or publisher metadata. +type Distribution struct { + SchemaVersion int `yaml:"schema_version" json:"schema_version"` + Kind string `yaml:"kind" json:"kind"` + License string `yaml:"license" json:"license"` + SourceMode string `yaml:"source_mode" json:"source_mode"` +} + +// IsCanvasDistribution reports whether the manifest asserts the portable +// canvas profile. A nil declaration preserves legacy plugin behavior. +func (m *Manifest) IsCanvasDistribution() bool { + return m != nil && m.Distribution != nil && m.Distribution.Kind == DistributionKindCanvas +} + +func (m *Manifest) validateDistribution() []error { + if m.Distribution == nil { + return nil + } + errs := validateDistributionDeclaration(m) + errs = append(errs, validateDistributionApplication(m)...) + errs = append(errs, validateDistributionContributions(m)...) + return errs +} + +func validateDistributionDeclaration(m *Manifest) []error { + d := m.Distribution + var errs []error + if d.SchemaVersion != DistributionSchemaVersion { + errs = append(errs, fmt.Errorf("distribution.schema_version must be %d", DistributionSchemaVersion)) + } + if d.Kind != DistributionKindCanvas { + errs = append(errs, fmt.Errorf("distribution.kind %q is unsupported", d.Kind)) + } + if strings.TrimSpace(d.License) == "" { + errs = append(errs, errors.New("distribution.license must not be empty")) + } + if d.SourceMode != SourceModeStatic && d.SourceMode != SourceModeProject { + errs = append(errs, fmt.Errorf("distribution.source_mode %q is unsupported", d.SourceMode)) + } + if _, ok := NormalizeReleaseVersion(m.MinKandevVersion); !ok { + errs = append(errs, errors.New("min_kandev_version is required for a distribution package")) + } + return errs +} + +func validateDistributionApplication(m *Manifest) []error { + var errs []error + if !m.IsStaticWebAppOnly() { + errs = append(errs, errors.New("distribution canvas must be a static web application")) + } + if len(m.UI.WebApps) != 1 { + errs = append(errs, errors.New("distribution canvas must declare exactly one web app")) + } else if !distributionContainsString(m.UI.WebApps[0].Placements, WebAppPlacementWorkspace) { + errs = append(errs, errors.New("distribution canvas must support workspace-canvas placement")) + } + if m.UI.Bundle != "" || len(m.UI.Pages) > 0 || len(m.UI.Styles) > 0 || len(m.UI.Keybindings) > 0 { + errs = append(errs, errors.New("distribution canvas cannot declare native UI contributions")) + } + return errs +} + +func validateDistributionContributions(m *Manifest) []error { + if len(m.Webhooks) == 0 && len(m.Actions) == 0 && len(m.RepositoryProviders) == 0 && + len(m.ReferenceSources) == 0 && len(m.AuthProviders) == 0 && len(m.AgentTools) == 0 && + len(m.ConfigSchema) == 0 && m.Runtime.Type == "" && len(m.Runtime.Executables) == 0 { + return nil + } + return []error{errors.New("distribution canvas cannot declare backend, tool, or other plugin contributions")} +} + +func distributionContainsString(values []string, want string) bool { + for _, value := range values { + if value == want { + return true + } + } + return false +} diff --git a/apps/backend/internal/plugins/manifest/distribution_test.go b/apps/backend/internal/plugins/manifest/distribution_test.go new file mode 100644 index 0000000000..c2d4d71d0b --- /dev/null +++ b/apps/backend/internal/plugins/manifest/distribution_test.go @@ -0,0 +1,62 @@ +package manifest + +import ( + "strings" + "testing" +) + +func TestValidateCanvasDistribution(t *testing.T) { + m := canvasDistributionManifest() + if err := m.Validate(); err != nil { + t.Fatalf("Validate() unexpected error: %v", err) + } + if !m.IsCanvasDistribution() { + t.Fatal("IsCanvasDistribution() = false, want true") + } + if m.Distribution.SourceMode != SourceModeProject { + t.Fatalf("source mode = %q, want %q", m.Distribution.SourceMode, SourceModeProject) + } +} + +func TestValidateCanvasDistributionRejectsOtherContributions(t *testing.T) { + cases := []struct { + name string + mutate func(*Manifest) + want string + }{ + {name: "missing license", mutate: func(m *Manifest) { m.Distribution.License = "" }, want: "distribution.license"}, + {name: "missing compatibility", mutate: func(m *Manifest) { m.MinKandevVersion = "" }, want: "min_kandev_version"}, + {name: "managed runtime", mutate: func(m *Manifest) { m.Runtime.Type = "binary" }, want: "runtime.type"}, + {name: "native bundle", mutate: func(m *Manifest) { m.UI.Bundle = "/bundle.js" }, want: "native UI"}, + {name: "action", mutate: func(m *Manifest) { + m.Actions = []Action{{Key: "run", ResourceScope: ActionScopeWorkspace, MaxBodyBytes: 1}} + }, want: "backend"}, + {name: "task only", mutate: func(m *Manifest) { m.UI.WebApps[0].Placements = []string{WebAppPlacementTask} }, want: "workspace-canvas"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + m := canvasDistributionManifest() + tc.mutate(m) + err := m.Validate() + if err == nil || !strings.Contains(err.Error(), tc.want) { + t.Fatalf("Validate() error = %v, want text %q", err, tc.want) + } + }) + } +} + +func canvasDistributionManifest() *Manifest { + return &Manifest{ + ID: "canvas-board", APIVersion: CurrentAPIVersion, Version: "1.2.3", + DisplayName: "Canvas Board", Description: "A board", Author: "Kandev", + Categories: []string{"canvas"}, MinKandevVersion: "1.0.0", + Distribution: &Distribution{ + SchemaVersion: DistributionSchemaVersion, Kind: DistributionKindCanvas, + License: "MIT", SourceMode: SourceModeProject, + }, + UI: UISection{WebApps: []WebApp{{ + Key: "main", Title: "Board", Entry: "ui/index.html", + Placements: []string{WebAppPlacementWorkspace}, + }}}, + } +} diff --git a/apps/backend/internal/plugins/manifest/manifest.go b/apps/backend/internal/plugins/manifest/manifest.go index 3e0cfc8ae6..0ea0f2979e 100644 --- a/apps/backend/internal/plugins/manifest/manifest.go +++ b/apps/backend/internal/plugins/manifest/manifest.go @@ -76,6 +76,11 @@ type Manifest struct { Runtime Runtime `yaml:"runtime,omitempty" json:"runtime,omitempty"` MinKandevVersion string `yaml:"min_kandev_version,omitempty" json:"min_kandev_version,omitempty"` + + // Distribution declares an optional portable package profile. Legacy + // plugins and locally-authored canvases may omit it; registry-ready canvas + // packages must provide the complete profile. + Distribution *Distribution `yaml:"distribution,omitempty" json:"distribution,omitempty"` } const ( diff --git a/apps/backend/internal/plugins/manifest/semver.go b/apps/backend/internal/plugins/manifest/semver.go index 2f9ee6a9f2..f0be8e1351 100644 --- a/apps/backend/internal/plugins/manifest/semver.go +++ b/apps/backend/internal/plugins/manifest/semver.go @@ -1,10 +1,32 @@ package manifest import ( + "fmt" "strconv" "strings" ) +// CheckMinimumKandevVersion enforces a package minimum against a stamped +// release build. Development and git-describe builds deliberately skip the +// check because they do not provide a trustworthy release boundary. +func CheckMinimumKandevVersion(minVersion, runningVersion string) error { + if strings.TrimSpace(minVersion) == "" || strings.TrimSpace(runningVersion) == "" || strings.EqualFold(strings.TrimSpace(runningVersion), "dev") { + return nil + } + running, runningRelease := NormalizeReleaseVersion(runningVersion) + if !runningRelease { + return nil + } + minimum, minimumRelease := NormalizeReleaseVersion(minVersion) + if !minimumRelease { + return fmt.Errorf("plugins: min_kandev_version %q is not a release version", minVersion) + } + if CompareVersions(running, minimum) < 0 { + return fmt.Errorf("plugins: requires kandev >= %s, running %s", minVersion, runningVersion) + } + return nil +} + // NormalizeReleaseVersion returns a dotted numeric release version suitable // for compatibility comparisons. Kandev release builds are tagged `vX.Y.Z`, // while plugin manifests use `X.Y.Z`; development and git-describe strings diff --git a/apps/backend/internal/plugins/manifest/validate.go b/apps/backend/internal/plugins/manifest/validate.go index 153dde08cf..ca4c28c6ea 100644 --- a/apps/backend/internal/plugins/manifest/validate.go +++ b/apps/backend/internal/plugins/manifest/validate.go @@ -30,6 +30,7 @@ var validCategories = map[string]bool{ "automation": true, "tools": true, "analytics": true, + "canvas": true, } // validUISurfaces are the allowed values for UIPage.Surface. @@ -144,6 +145,7 @@ func (m *Manifest) Validate() error { errs = append(errs, m.validateRepositoryProviders()...) errs = append(errs, m.validateReferenceSources()...) errs = append(errs, m.validateAgentTools()...) + errs = append(errs, m.validateDistribution()...) return errors.Join(errs...) } diff --git a/apps/backend/internal/plugins/marketplace/catalog.go b/apps/backend/internal/plugins/marketplace/catalog.go index cd96e54eea..1bb34b2d1d 100644 --- a/apps/backend/internal/plugins/marketplace/catalog.go +++ b/apps/backend/internal/plugins/marketplace/catalog.go @@ -3,13 +3,16 @@ package marketplace import ( "context" "encoding/json" + "errors" "fmt" "io" "net/http" + "net/url" "sort" "strings" "sync" "time" + "unicode/utf8" "golang.org/x/sync/singleflight" @@ -17,6 +20,11 @@ import ( "github.com/kandev/kandev/internal/plugins/manifest" ) +var ( + ErrCanvasListingNotFound = errors.New("canvas marketplace listing not found") + ErrCanvasListingStale = errors.New("canvas marketplace listing is stale") +) + // maxIndexBytes caps the index.json body read from any source, bounding // worst-case memory from a hostile or misconfigured source. const maxIndexBytes = 5 << 20 // 5 MiB @@ -26,6 +34,11 @@ const maxIndexBytes = 5 << 20 // 5 MiB // the cache immediately. const defaultCacheTTL = 5 * time.Minute +const ( + marketplaceKindPlugin = "plugin" + marketplaceKindCanvas = "canvas" +) + // Service fetches, caches, and merges catalog documents across the configured // marketplace sources. type Service struct { @@ -114,7 +127,7 @@ func (s *Service) Catalog(ctx context.Context, installed []InstalledPlugin) (*Ca // Merge sequentially in source order so first-source-wins dedup is stable, // even though the fetches above ran concurrently. installedByID := indexInstalled(installed) - result := &CatalogResult{Plugins: []CatalogEntry{}, Sources: []SourceStatus{}} + result := &CatalogResult{Plugins: []CatalogEntry{}, Canvases: []CatalogEntry{}, Sources: []SourceStatus{}} seen := map[string]bool{} for i, src := range sources { status := statusFor(src) @@ -128,12 +141,53 @@ func (s *Service) Catalog(ctx context.Context, installed []InstalledPlugin) (*Ca result.Sources = append(result.Sources, status) continue } - result.Plugins = append(result.Plugins, mergeEntries(fetched[i].doc, src, installedByID, seen)...) + if fetched[i].doc.warning != "" { + status.Healthy = false + status.Error = fetched[i].doc.warning + } + for _, entry := range mergeEntries(fetched[i].doc, src, installedByID, seen) { + if entry.Kind == marketplaceKindCanvas { + result.Canvases = append(result.Canvases, entry) + continue + } + result.Plugins = append(result.Plugins, entry) + } result.Sources = append(result.Sources, status) } return result, nil } +// ResolveCanvasPackage resolves an exact canvas listing from the configured +// source. The caller supplies only the source and expected identity/digest; +// the package URL and repository URL come from the freshly validated source +// document. +func (s *Service) ResolveCanvasPackage(ctx context.Context, sourceID, packageID, version, digest string) (string, string, error) { + source, err := s.store.Get(strings.TrimSpace(sourceID)) + if err != nil { + return "", "", ErrCanvasListingNotFound + } + if !source.Enabled { + return "", "", ErrCanvasListingNotFound + } + doc, err := s.fetch(ctx, source.URL) + if err != nil { + return "", "", ErrCanvasListingNotFound + } + for _, entry := range doc.Plugins { + if entry.Kind != marketplaceKindCanvas || entry.ID != packageID { + continue + } + if entry.Version != version || !strings.EqualFold(entry.PackageSHA256, digest) { + return "", "", ErrCanvasListingStale + } + if entry.PackageURL == "" { + return "", "", ErrCanvasListingNotFound + } + return entry.PackageURL, entry.RepoURL, nil + } + return "", "", ErrCanvasListingNotFound +} + type fetchOutcome struct { doc *IndexDocument err error @@ -195,6 +249,9 @@ func indexInstalled(installed []InstalledPlugin) map[string]string { // annotate derives a catalog entry's install state from what is installed. func annotate(e IndexEntry, src SourceRecord, installed map[string]string) CatalogEntry { + if e.Kind == "" { + e.Kind = marketplaceKindPlugin + } ce := CatalogEntry{IndexEntry: e, SourceID: src.ID, SourceName: src.Name, InstallState: StateAvailable} if v, ok := installed[e.ID]; ok { ce.InstalledVersion = v @@ -273,9 +330,92 @@ func (s *Service) download(ctx context.Context, url string) (*IndexDocument, err if err := json.Unmarshal(body, &doc); err != nil { return nil, fmt.Errorf("parse index: %w", err) } + if err := validateIndexDocument(&doc); err != nil { + return nil, err + } return &doc, nil } +func validateIndexDocument(doc *IndexDocument) error { + if doc.SchemaVersion != 1 { + return fmt.Errorf("unsupported index schema version %d", doc.SchemaVersion) + } + if doc.Plugins == nil { + return fmt.Errorf("index plugins list is missing") + } + seen := make(map[string]struct{}, len(doc.Plugins)) + valid := make([]IndexEntry, 0, len(doc.Plugins)) + var warnings []string + for _, entry := range doc.Plugins { + kind := strings.TrimSpace(entry.Kind) + if kind == "" { + kind = marketplaceKindPlugin + } + if entry.ID == "" { + warnings = append(warnings, "an entry has no id") + continue + } + if _, exists := seen[entry.ID]; exists { + warnings = append(warnings, "duplicate entry "+entry.ID) + continue + } + seen[entry.ID] = struct{}{} + if kind != marketplaceKindPlugin && kind != marketplaceKindCanvas { + warnings = append(warnings, "entry "+entry.ID+" has an unsupported kind") + continue + } + if err := validatePreviews(entry.Previews, kind == marketplaceKindCanvas); err != nil { + warnings = append(warnings, "entry "+entry.ID+": "+err.Error()) + continue + } + if kind == marketplaceKindCanvas && !validSHA256(entry.PackageSHA256) { + warnings = append(warnings, "entry "+entry.ID+": canvas package digest is missing or invalid") + continue + } + entry.Kind = kind + valid = append(valid, entry) + } + doc.Plugins = valid + if len(warnings) > 0 { + doc.warning = strings.Join(warnings, "; ") + } + return nil +} + +func validatePreviews(previews []Preview, required bool) error { + if required && len(previews) == 0 { + return fmt.Errorf("canvas listings require at least one preview") + } + if len(previews) > 8 { + return fmt.Errorf("preview limit exceeded") + } + for _, preview := range previews { + if utf8.RuneCountInString(strings.TrimSpace(preview.Alt)) < 1 || utf8.RuneCountInString(strings.TrimSpace(preview.Alt)) > 300 { + return fmt.Errorf("preview alt text is invalid") + } + if len(preview.URL) > 2048 { + return fmt.Errorf("preview URL is too long") + } + parsed, err := url.Parse(preview.URL) + if err != nil || parsed.Scheme != "https" || parsed.Hostname() == "" || parsed.User != nil || parsed.Fragment != "" || parsed.Opaque != "" { + return fmt.Errorf("preview URL must be an HTTPS URL without credentials or fragments") + } + } + return nil +} + +func validSHA256(value string) bool { + if len(value) != 64 { + return false + } + for _, char := range value { + if (char < '0' || char > '9') && (char < 'a' || char > 'f') && (char < 'A' || char > 'F') { + return false + } + } + return true +} + // ApplyQuery filters and sorts a merged catalog per the request. Filtering: // Text matches id/name/description (case-insensitive substring); Category // matches any of an entry's categories. Sort: "name" (asc), "recent" @@ -284,8 +424,13 @@ func ApplyQuery(entries []CatalogEntry, q Query) []CatalogEntry { out := make([]CatalogEntry, 0, len(entries)) text := strings.ToLower(strings.TrimSpace(q.Text)) category := strings.ToLower(strings.TrimSpace(q.Category)) + kind := strings.ToLower(strings.TrimSpace(q.Kind)) for _, e := range entries { - if matchesText(e, text) && matchesCategory(e, category) { + entryKind := strings.ToLower(strings.TrimSpace(e.Kind)) + if entryKind == "" { + entryKind = marketplaceKindPlugin + } + if (kind == "" || entryKind == kind) && matchesText(e, text) && matchesCategory(e, category) { out = append(out, e) } } diff --git a/apps/backend/internal/plugins/marketplace/catalog_test.go b/apps/backend/internal/plugins/marketplace/catalog_test.go index 8fed4adceb..b9b5c216ad 100644 --- a/apps/backend/internal/plugins/marketplace/catalog_test.go +++ b/apps/backend/internal/plugins/marketplace/catalog_test.go @@ -119,6 +119,40 @@ func TestCatalogInstallStateAnnotation(t *testing.T) { assertState(t, result.Plugins, "fresh", StateAvailable) } +func TestCatalogSeparatesCanvasEntriesAndPreservesPreviews(t *testing.T) { + canvas := `{"id":"board","kind":"canvas","name":"Board","version":"1.0.0","package_sha256":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","package_url":"https://ex/board.tar.gz","previews":[{"url":"https://cdn.example/cover.webp","alt":"Board cover"}]}` + s := newTestService(t) + _ = s.store.EnsureBuiltin("Official", serve(t, indexJSON(canvas)).URL) + result, err := s.Catalog(context.Background(), nil) + if err != nil { + t.Fatalf("catalog: %v", err) + } + if len(result.Plugins) != 0 || len(result.Canvases) != 1 { + t.Fatalf("catalog kind split = plugins=%d canvases=%d", len(result.Plugins), len(result.Canvases)) + } + if got := result.Canvases[0].Previews[0].URL; got != "https://cdn.example/cover.webp" { + t.Fatalf("preview URL = %q", got) + } +} + +func TestCatalogOmitsInvalidCanvasListingButKeepsValidEntries(t *testing.T) { + valid := `{"id":"valid","kind":"canvas","name":"Valid","version":"1.0.0","package_sha256":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","package_url":"https://ex/valid.tar.gz","previews":[{"url":"https://cdn.example/cover.webp","alt":"Valid cover"}]}` + invalid := `{"id":"invalid","kind":"canvas","name":"Invalid","version":"1.0.0","package_url":"https://ex/invalid.tar.gz"}` + s := newTestService(t) + _ = s.store.EnsureBuiltin("Official", serve(t, indexJSON(valid+","+invalid)).URL) + result, err := s.Catalog(context.Background(), nil) + if err != nil { + t.Fatalf("catalog: %v", err) + } + if len(result.Canvases) != 1 || result.Canvases[0].ID != "valid" { + t.Fatalf("invalid canvas was not omitted: %+v", result.Canvases) + } + status := findSource(result.Sources, "Official") + if status == nil || status.Healthy || status.Error == "" { + t.Fatalf("source warning missing: %+v", status) + } +} + func TestCatalogDegradedSourceDoesNotAbortMerge(t *testing.T) { good := serve(t, indexJSON(entryJSON("alpha", "1.0.0", 1))) s := newTestService(t) diff --git a/apps/backend/internal/plugins/marketplace/types.go b/apps/backend/internal/plugins/marketplace/types.go index 32c3605df0..5c99c4ee91 100644 --- a/apps/backend/internal/plugins/marketplace/types.go +++ b/apps/backend/internal/plugins/marketplace/types.go @@ -38,12 +38,28 @@ type IndexSource struct { URL string `json:"url"` } +// Preview is registry-owned listing presentation metadata. It never supplies +// package identity, permissions, or install bytes. +type Preview struct { + URL string `json:"url"` + Alt string `json:"alt"` +} + +type PermissionSummary struct { + Reads []string `json:"reads,omitempty"` + Writes []string `json:"writes,omitempty"` + Events []string `json:"events,omitempty"` + SharedState bool `json:"shared_state"` + ExternalOrigins []string `json:"external_origins,omitempty"` +} + // IndexEntry is one plugin as published in a source's index.json. This is the // hard fetch contract shared with the registry's index-build Action // (plugin-registry/build-index.mjs); additional/corporate sources must serve // the same shape. Missing/`null` optional fields decode to their zero value. type IndexEntry struct { ID string `json:"id"` + Kind string `json:"kind,omitempty"` Name string `json:"name"` Description string `json:"description"` Author string `json:"author"` @@ -51,12 +67,15 @@ type IndexEntry struct { // IconURL is an absolute URL to the plugin's icon, resolved by the // registry index-build from the plugin manifest's `icon` path. Empty // when the plugin ships no icon; the UI falls back to a letter tile. - IconURL string `json:"icon_url"` - RepoURL string `json:"repo_url"` - Version string `json:"version"` - MinKandevVersion string `json:"min_kandev_version"` - PackageURL string `json:"package_url"` - PackageSHA256 string `json:"package_sha256"` + IconURL string `json:"icon_url"` + RepoURL string `json:"repo_url"` + Version string `json:"version"` + MinKandevVersion string `json:"min_kandev_version"` + License string `json:"license,omitempty"` + PackageURL string `json:"package_url"` + PackageSHA256 string `json:"package_sha256"` + Previews []Preview `json:"previews,omitempty"` + Permissions *PermissionSummary `json:"permissions,omitempty"` // Stars is a pointer so a `null` in index.json (the registry build emits // null when a repo's star lookup failed) stays unknown rather than // decoding to 0 — a known-zero repo and an unknown one must not collapse, @@ -71,6 +90,7 @@ type IndexDocument struct { GeneratedAt string `json:"generated_at"` Source IndexSource `json:"source"` Plugins []IndexEntry `json:"plugins"` + warning string } // CatalogEntry is an IndexEntry annotated with the source it came from and @@ -98,8 +118,9 @@ type SourceStatus struct { // CatalogResult is the merged, deduped catalog across all enabled sources. type CatalogResult struct { - Plugins []CatalogEntry `json:"plugins"` - Sources []SourceStatus `json:"sources"` + Plugins []CatalogEntry `json:"plugins"` + Canvases []CatalogEntry `json:"canvases"` + Sources []SourceStatus `json:"sources"` } // InstalledPlugin is the minimal installed-plugin fact the catalog needs to @@ -125,4 +146,5 @@ type Query struct { Text string Category string Sort string // "stars" (default) | "name" | "recent" + Kind string // "plugin" (default) | "canvas" } diff --git a/apps/backend/internal/plugins/marketplace_handlers.go b/apps/backend/internal/plugins/marketplace_handlers.go index c642ce5d91..36afb34392 100644 --- a/apps/backend/internal/plugins/marketplace_handlers.go +++ b/apps/backend/internal/plugins/marketplace_handlers.go @@ -64,6 +64,13 @@ func (c *Controller) marketplaceCatalog(ctx *gin.Context) { Text: ctx.Query("q"), Category: ctx.Query("category"), Sort: ctx.Query("sort"), + Kind: ctx.Query("kind"), + }) + result.Canvases = marketplace.ApplyQuery(result.Canvases, marketplace.Query{ + Text: ctx.Query("q"), + Category: ctx.Query("category"), + Sort: ctx.Query("sort"), + Kind: ctx.Query("kind"), }) ctx.JSON(http.StatusOK, result) } diff --git a/apps/backend/internal/plugins/service_install.go b/apps/backend/internal/plugins/service_install.go index 78324d89b5..f441e71a3a 100644 --- a/apps/backend/internal/plugins/service_install.go +++ b/apps/backend/internal/plugins/service_install.go @@ -215,21 +215,7 @@ const DevKandevVersion = "dev" // not provide a trustworthy release boundary, so they deliberately skip this // release-only compatibility gate. An invalid manifest minimum is rejected. func (s *Service) checkMinKandevVersion(minVersion string) error { - if minVersion == "" || s.kandevVersion == "" || s.kandevVersion == DevKandevVersion { - return nil - } - runningVersion, runningRelease := manifest.NormalizeReleaseVersion(s.kandevVersion) - if !runningRelease { - return nil - } - minimumVersion, minimumRelease := manifest.NormalizeReleaseVersion(minVersion) - if !minimumRelease { - return fmt.Errorf("plugins: min_kandev_version %q is not a release version", minVersion) - } - if manifest.CompareVersions(runningVersion, minimumVersion) < 0 { - return fmt.Errorf("plugins: requires kandev >= %s, running %s", minVersion, s.kandevVersion) - } - return nil + return manifest.CheckMinimumKandevVersion(minVersion, s.kandevVersion) } // rollbackFailedInstall cleans up after a store.Save failure partway diff --git a/apps/backend/internal/plugins/webapp/distribution.go b/apps/backend/internal/plugins/webapp/distribution.go new file mode 100644 index 0000000000..22a3d7f037 --- /dev/null +++ b/apps/backend/internal/plugins/webapp/distribution.go @@ -0,0 +1,496 @@ +package webapp + +import ( + "archive/tar" + "archive/zip" + "bufio" + "bytes" + "compress/gzip" + "crypto/sha256" + "encoding/hex" + "errors" + "fmt" + "io" + "path" + "sort" + "strings" + "time" + + "github.com/kandev/kandev/internal/plugins/manifest" + "gopkg.in/yaml.v3" +) + +const MaxSourceArchiveBytes int64 = 30 << 20 + +const ( + distributionChecksumsFile = "checksums.txt" + sourceEnvExample = ".env.example" +) + +var ( + ErrChecksumMissing = errors.New("webapp: distribution checksums are missing") + ErrChecksumMismatch = errors.New("webapp: distribution checksum mismatch") + ErrUnsafeSource = errors.New("webapp: unsafe distribution source") + ErrSourceUnavailable = errors.New("webapp: distribution source is unavailable") + ErrInvalidDistribution = errors.New("webapp: invalid distribution package") +) + +// DistributionArchives contains the two deterministic artifacts produced +// from one validated immutable package snapshot. +type DistributionArchives struct { + Bundle []byte + Source []byte +} + +// BuildDistributionPackage creates a validated package snapshot from a +// manifest and already-retained files. It rewrites only manifest.yaml and the +// generated checksum file; application/source bytes are copied unchanged. +func BuildDistributionPackage(m *manifest.Manifest, input map[string][]byte) (*Package, error) { + if m == nil { + return nil, ErrInvalidDistribution + } + if err := m.Validate(); err != nil { + return nil, fmt.Errorf("%w: manifest: %v", ErrInvalidDistribution, err) + } + manifestData, err := yaml.Marshal(m) + if err != nil { + return nil, fmt.Errorf("%w: marshal manifest: %v", ErrInvalidDistribution, err) + } + if int64(len(manifestData)) > MaxManifestBytes { + return nil, ErrManifestTooLarge + } + files := buildDistributionFiles(input, manifestData) + expanded, err := validateDistributionFiles(files) + if err != nil { + return nil, err + } + files[distributionChecksumsFile] = checksumBytes(files) + checksumBytesCount := int64(len(files[distributionChecksumsFile])) + if expanded > MaxExpandedBytes-checksumBytesCount { + return nil, ErrExpandedTooLarge + } + pkg := &Package{Manifest: m, Files: files, Digest: digestFiles(files), ExpandedBytes: expanded + checksumBytesCount} + if err := ValidateDistribution(pkg); err != nil { + return nil, err + } + return pkg, nil +} + +func buildDistributionFiles(input map[string][]byte, manifestData []byte) map[string][]byte { + files := make(map[string][]byte, len(input)+2) + for name, data := range input { + files[name] = append([]byte(nil), data...) + } + files["manifest.yaml"] = manifestData + delete(files, distributionChecksumsFile) + return files +} + +func validateDistributionFiles(files map[string][]byte) (int64, error) { + var expanded int64 + for name, data := range files { + clean, err := normalizePackagePath(name, MaxPathBytes) + if err != nil || clean != name { + return 0, fmt.Errorf("%w: %s", ErrUnsafePath, name) + } + if strings.HasPrefix(name, "distribution/source/") { + if err := ValidateDistributionSourcePath(name); err != nil { + return 0, err + } + } else if !supportedFile(name) { + return 0, fmt.Errorf("%w: %s", ErrUnsupportedFile, name) + } + if int64(len(data)) > MaxFileBytes || expanded > MaxExpandedBytes-int64(len(data)) { + return 0, ErrFileTooLarge + } + expanded += int64(len(data)) + } + return expanded, nil +} + +// ValidateDistributionPackage validates a complete installable canvas bundle. +// It is the same entry point used by the offline registry inspector. +func ValidateDistributionPackage(r io.Reader) (*Package, error) { + pkg, err := ValidatePackage(r) + if err != nil { + return nil, err + } + if err := ValidateDistribution(pkg); err != nil { + return nil, err + } + return pkg, nil +} + +// ValidateDistribution validates distribution-only requirements after the +// normal web-app package validator has checked archive safety and manifest +// syntax. +func ValidateDistribution(pkg *Package) error { + if pkg == nil || pkg.Manifest == nil || !pkg.Manifest.IsCanvasDistribution() { + return ErrInvalidDistribution + } + if _, ok := pkg.Files["README.md"]; !ok { + return fmt.Errorf("%w: README.md", ErrInvalidDistribution) + } + if _, ok := pkg.Files[distributionChecksumsFile]; !ok { + return ErrChecksumMissing + } + if requiresLicenseFile(pkg.Manifest.Distribution.License) { + if _, ok := pkg.Files["LICENSE.txt"]; !ok { + return fmt.Errorf("%w: LICENSE.txt", ErrInvalidDistribution) + } + } + if err := validateChecksums(pkg.Files); err != nil { + return err + } + if err := validateSourceMode(pkg.Manifest.Distribution.SourceMode, pkg.Files); err != nil { + return err + } + return nil +} + +// ValidateCanvasSourcePath applies the authoring-transfer policy to every +// workspace-relative source entry. Directories are allowed after component +// exclusions; regular files must use the retained-source extension allowlist. +func ValidateCanvasSourcePath(name string, isDir bool) error { + clean, err := normalizePackagePath(name, MaxPathBytes) + if err != nil { + return err + } + if sourcePathForbidden(clean) { + return fmt.Errorf("%w: %s", ErrUnsafeSource, clean) + } + if !isDir && !sourceFileExtensionAllowed(clean) { + return fmt.Errorf("%w: %s", ErrUnsupportedFile, clean) + } + return nil +} + +// ValidateDistributionSourcePath applies the source subtree policy during +// package validation. It rejects an unsafe path instead of silently dropping +// it, so a retained project cannot appear complete when it is not. +func ValidateDistributionSourcePath(name string) error { + clean, err := normalizePackagePath(name, MaxPathBytes) + if err != nil { + return err + } + if !strings.HasPrefix(clean, "distribution/source/") { + return nil + } + return ValidateCanvasSourcePath(clean, false) +} + +func validateSourceMode(sourceMode string, files map[string][]byte) error { + var sourceFiles int + for name := range files { + if strings.HasPrefix(name, "distribution/source/") { + sourceFiles++ + } + } + switch sourceMode { + case manifest.SourceModeStatic: + if sourceFiles != 0 { + return fmt.Errorf("%w: static distributions cannot contain distribution/source", ErrInvalidDistribution) + } + case manifest.SourceModeProject: + if sourceFiles == 0 { + return ErrSourceUnavailable + } + for _, required := range []string{"distribution/source/manifest.yaml", "distribution/source/README.md"} { + if _, ok := files[required]; !ok { + return fmt.Errorf("%w: %s", ErrSourceUnavailable, required) + } + } + default: + return fmt.Errorf("%w: unsupported source mode", ErrInvalidDistribution) + } + return nil +} + +func validateChecksums(files map[string][]byte) error { + checksums, ok := files[distributionChecksumsFile] + if !ok { + return ErrChecksumMissing + } + want, err := parseChecksums(checksums, len(files)-1) + if err != nil { + return err + } + return validateChecksumCoverage(files, want) +} + +func parseChecksums(checksums []byte, fileCount int) (map[string]string, error) { + want := make(map[string]string, fileCount) + scanner := bufio.NewScanner(bytes.NewReader(checksums)) + for scanner.Scan() { + rawLine := scanner.Text() + if strings.TrimSpace(rawLine) == "" { + continue + } + digest, rawName, found := strings.Cut(rawLine, " ") + digest = strings.TrimSpace(digest) + rawName = strings.TrimSpace(rawName) + if !found || rawName == "" || len(digest) != sha256.Size*2 { + return nil, fmt.Errorf("%w: malformed %s", ErrChecksumMismatch, distributionChecksumsFile) + } + name, err := normalizePackagePath(rawName, MaxPathBytes) + if err != nil || name == distributionChecksumsFile || !isLowerHex(digest) { + return nil, fmt.Errorf("%w: malformed %s", ErrChecksumMismatch, distributionChecksumsFile) + } + if _, exists := want[name]; exists { + return nil, fmt.Errorf("%w: duplicate %s", ErrChecksumMismatch, name) + } + want[name] = strings.ToLower(digest) + } + if err := scanner.Err(); err != nil { + return nil, fmt.Errorf("%w: read %s: %v", ErrChecksumMismatch, distributionChecksumsFile, err) + } + return want, nil +} + +func validateChecksumCoverage(files map[string][]byte, want map[string]string) error { + if len(want) != len(files)-1 { + return fmt.Errorf("%w: incomplete file coverage", ErrChecksumMismatch) + } + for name, data := range files { + if name == distributionChecksumsFile { + continue + } + expected, exists := want[name] + if !exists { + return fmt.Errorf("%w: missing %s", ErrChecksumMismatch, name) + } + actual := sha256.Sum256(data) + if expected != hex.EncodeToString(actual[:]) { + return fmt.Errorf("%w: %s", ErrChecksumMismatch, name) + } + } + for name := range want { + if _, exists := files[name]; !exists { + return fmt.Errorf("%w: unknown %s", ErrChecksumMismatch, name) + } + } + return nil +} + +func checksumBytes(files map[string][]byte) []byte { + names := sortedFileNames(files) + var output bytes.Buffer + for _, name := range names { + sum := sha256.Sum256(files[name]) + _, _ = fmt.Fprintf(&output, "%x %s\n", sum, name) + } + return output.Bytes() +} + +func isLowerHex(value string) bool { + for _, char := range value { + if (char < '0' || char > '9') && (char < 'a' || char > 'f') { + return false + } + } + return true +} + +func requiresLicenseFile(license string) bool { + switch strings.ToUpper(strings.TrimSpace(license)) { + case "MIT", "ISC", "UNLICENSE", "APACHE-2.0", "BSD-2-CLAUSE", "BSD-3-CLAUSE", "MPL-2.0", "GPL-2.0-ONLY", "GPL-3.0-ONLY", "LGPL-2.1-ONLY", "LGPL-3.0-ONLY": + return false + default: + return true + } +} + +func sourcePathForbidden(name string) bool { + for _, component := range strings.Split(name, "/") { + if component == ".git" || component == "node_modules" || component == ".kandev" { + return true + } + if strings.HasPrefix(component, ".env") && component != sourceEnvExample { + return true + } + } + ext := strings.ToLower(path.Ext(name)) + switch ext { + case ".zip", ".tar", ".gz", ".tgz", ".bz2", ".xz", ".7z", ".rar", ".exe", ".dll", ".so", ".dylib", ".bin", ".wasm": + return true + default: + return false + } +} + +func sourceFileExtensionAllowed(name string) bool { + base := path.Base(name) + if base == "Dockerfile" || base == "Makefile" || base == "README" || base == "LICENSE" || base == sourceEnvExample { + return true + } + switch strings.ToLower(path.Ext(name)) { + case ".css", ".html", ".htm", ".js", ".jsx", ".mjs", ".cjs", ".json", ".map", ".md", ".txt", ".ts", ".tsx", ".yaml", ".yml", ".lock", ".toml", + ".png", ".jpg", ".jpeg", ".gif", ".webp", ".avif", ".svg", ".ico", ".woff", ".woff2", ".ttf", ".otf", ".eot", sourceEnvExample: + return true + default: + return false + } +} + +// BuildDistributionArchives creates a deterministic installable bundle and a +// source zip from the same validated package. The source zip is intentionally +// not accepted by ValidateDistributionPackage. +func BuildDistributionArchives(pkg *Package) (DistributionArchives, error) { + if err := ValidateDistribution(pkg); err != nil { + return DistributionArchives{}, err + } + bundle, err := writeBundle(pkg.Files) + if err != nil { + return DistributionArchives{}, err + } + source, err := writeSourceZip(pkg.Files) + if err != nil { + return DistributionArchives{}, err + } + return DistributionArchives{Bundle: bundle, Source: source}, nil +} + +func writeBundle(files map[string][]byte) ([]byte, error) { + names := sortedFileNames(files) + var output bytes.Buffer + zw := gzip.NewWriter(&output) + zw.ModTime = time.Unix(0, 0) + zw.OS = 255 + tw := tar.NewWriter(zw) + for _, name := range names { + data := files[name] + header := &tar.Header{Name: name, Mode: 0o600, Size: int64(len(data)), Typeflag: tar.TypeReg} + if err := tw.WriteHeader(header); err != nil { + return nil, fmt.Errorf("webapp: write bundle header: %w", err) + } + if _, err := tw.Write(data); err != nil { + return nil, fmt.Errorf("webapp: write bundle file: %w", err) + } + } + if err := tw.Close(); err != nil { + return nil, fmt.Errorf("webapp: close bundle: %w", err) + } + if err := zw.Close(); err != nil { + return nil, fmt.Errorf("webapp: close bundle gzip: %w", err) + } + return output.Bytes(), nil +} + +func writeSourceZip(files map[string][]byte) ([]byte, error) { + var output bytes.Buffer + zw := zip.NewWriter(&output) + for _, name := range sortedFileNames(files) { + if name == distributionChecksumsFile { + continue + } + header := &zip.FileHeader{Name: name, Method: zip.Deflate} + header.Modified = time.Unix(0, 0) + header.SetMode(0o600) + writer, err := zw.CreateHeader(header) + if err != nil { + return nil, fmt.Errorf("webapp: create source file: %w", err) + } + if _, err := writer.Write(files[name]); err != nil { + return nil, fmt.Errorf("webapp: write source file: %w", err) + } + } + if err := zw.Close(); err != nil { + return nil, fmt.Errorf("webapp: close source zip: %w", err) + } + return output.Bytes(), nil +} + +// ValidateSourceArchive validates the non-installable source zip emitted by +// BuildDistributionArchives without executing or extracting it to disk. +func ValidateSourceArchive(r io.Reader) (*Package, error) { + data, err := io.ReadAll(io.LimitReader(r, MaxSourceArchiveBytes+1)) + if err != nil { + return nil, fmt.Errorf("webapp: read source archive: %w", err) + } + if int64(len(data)) > MaxSourceArchiveBytes { + return nil, ErrCompressedTooLarge + } + reader, err := zip.NewReader(bytes.NewReader(data), int64(len(data))) + if err != nil { + return nil, fmt.Errorf("webapp: open source archive: %w", err) + } + files := make(map[string][]byte, len(reader.File)) + var expanded int64 + for _, entry := range reader.File { + name, content, err := readSourceArchiveEntry(entry, files) + if err != nil { + return nil, err + } + if expanded > MaxExpandedBytes-int64(len(content)) { + return nil, ErrFileTooLarge + } + files[name] = content + expanded += int64(len(content)) + } + return validateSourceArchiveFiles(files, expanded, int64(len(data))) +} + +func readSourceArchiveEntry(entry *zip.File, files map[string][]byte) (string, []byte, error) { + if entry.FileInfo().IsDir() || entry.Mode()&0o170000 != 0 { + return "", nil, ErrUnsafeEntry + } + name, err := normalizePackagePath(entry.Name, MaxPathBytes) + if err != nil { + return "", nil, err + } + if _, exists := files[name]; exists { + return "", nil, ErrDuplicatePath + } + if name != "manifest.yaml" { + if strings.HasPrefix(name, "distribution/source/") { + if err := ValidateDistributionSourcePath(name); err != nil { + return "", nil, err + } + } else if !supportedFile(name) { + return "", nil, fmt.Errorf("%w: %s", ErrUnsupportedFile, name) + } + } + if entry.UncompressedSize64 > uint64(MaxFileBytes) { + return "", nil, ErrFileTooLarge + } + file, err := entry.Open() + if err != nil { + return "", nil, err + } + content, readErr := io.ReadAll(io.LimitReader(file, MaxFileBytes+1)) + _ = file.Close() + if readErr != nil || uint64(len(content)) != entry.UncompressedSize64 { + return "", nil, ErrFileTooLarge + } + return name, content, nil +} + +func validateSourceArchiveFiles(files map[string][]byte, expanded, compressed int64) (*Package, error) { + manifestData, ok := files["manifest.yaml"] + if !ok { + return nil, ErrManifestInvalid + } + m, err := manifest.Parse(manifestData) + if err != nil { + return nil, err + } + if err := m.Validate(); err != nil { + return nil, err + } + if !m.IsCanvasDistribution() { + return nil, ErrInvalidDistribution + } + if err := validateSourceMode(m.Distribution.SourceMode, files); err != nil { + return nil, err + } + return &Package{Manifest: m, Files: files, Digest: digestFiles(files), ExpandedBytes: expanded, CompressedBytes: compressed}, nil +} + +func sortedFileNames(files map[string][]byte) []string { + names := make([]string, 0, len(files)) + for name := range files { + names = append(names, name) + } + sort.Strings(names) + return names +} diff --git a/apps/backend/internal/plugins/webapp/distribution_test.go b/apps/backend/internal/plugins/webapp/distribution_test.go new file mode 100644 index 0000000000..9584afba9a --- /dev/null +++ b/apps/backend/internal/plugins/webapp/distribution_test.go @@ -0,0 +1,214 @@ +package webapp + +import ( + "archive/tar" + "bytes" + "compress/gzip" + "crypto/sha256" + "encoding/hex" + "errors" + "io" + "strings" + "testing" +) + +func TestValidateDistributionPackageAcceptsProjectSourceAndChecksums(t *testing.T) { + files := distributionFiles() + files["checksums.txt"] = checksumFile(files) + archive := gzipTarFiles(t, files) + pkg, err := ValidateDistributionPackage(bytes.NewReader(archive)) + if err != nil { + t.Fatalf("ValidateDistributionPackage() unexpected error: %v", err) + } + if pkg.Manifest.Distribution.SourceMode != "project" { + t.Fatalf("distribution = %+v", pkg.Manifest.Distribution) + } +} + +func TestValidateDistributionPackageRejectsChecksumAndSourceViolations(t *testing.T) { + cases := []struct { + name string + edit func(map[string]string) + want error + }{ + {name: "missing checksum", edit: func(files map[string]string) { delete(files, "checksums.txt") }, want: ErrChecksumMissing}, + {name: "checksum mismatch", edit: func(files map[string]string) { files["ui/app.js"] = "changed" }, want: ErrChecksumMismatch}, + {name: "credential file", edit: func(files map[string]string) { files["distribution/source/.env"] = "TOKEN=secret\n" }, want: ErrUnsafeSource}, + {name: "nested archive", edit: func(files map[string]string) { files["distribution/source/app.zip"] = "PK\x03\x04" }, want: ErrUnsafeSource}, + {name: "missing project source", edit: func(files map[string]string) { + for name := range files { + if strings.HasPrefix(name, "distribution/source/") { + delete(files, name) + } + } + }, want: ErrSourceUnavailable}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + files := distributionFiles() + if tc.name == "checksum mismatch" { + files["checksums.txt"] = checksumFile(files) + } + tc.edit(files) + if tc.name != "missing checksum" && tc.name != "checksum mismatch" { + files["checksums.txt"] = checksumFile(files) + } + _, err := ValidateDistributionPackage(bytes.NewReader(gzipTarFiles(t, files))) + if !errors.Is(err, tc.want) { + t.Fatalf("ValidateDistributionPackage() error = %v, want %v", err, tc.want) + } + }) + } +} + +func TestParseChecksumsPreservesSpacesInFilenames(t *testing.T) { + data := []byte("" + strings.Repeat("a", sha256.Size*2) + " ui/my app.js\n") + want, err := parseChecksums(data, 1) + if err != nil { + t.Fatalf("parseChecksums() error = %v", err) + } + if want["ui/my app.js"] != strings.Repeat("a", sha256.Size*2) { + t.Fatalf("checksums = %+v", want) + } +} + +func TestValidateCanvasSourcePathRejectsWorkspaceSecretsAndDependencies(t *testing.T) { + for _, name := range []string{".env", "node_modules/pkg/index.js", "source/.env"} { + if err := ValidateCanvasSourcePath(name, false); !errors.Is(err, ErrUnsafeSource) { + t.Fatalf("ValidateCanvasSourcePath(%q) error = %v, want ErrUnsafeSource", name, err) + } + } +} + +func TestDistributionArchiveWritersRoundTrip(t *testing.T) { + pkg := packageFromFiles(t, distributionFiles()) + archives, err := BuildDistributionArchives(pkg) + if err != nil { + t.Fatalf("BuildDistributionArchives() unexpected error: %v", err) + } + if len(archives.Bundle) == 0 || len(archives.Source) == 0 { + t.Fatalf("archives = %+v, want bundle and source bytes", archives) + } + if _, err := ValidateDistributionPackage(bytes.NewReader(archives.Bundle)); err != nil { + t.Fatalf("bundle validation error = %v", err) + } + if _, err := ValidateSourceArchive(bytes.NewReader(archives.Source)); err != nil { + t.Fatalf("source validation error = %v", err) + } +} + +func TestRuntimeFilePathRejectsDistributionAliases(t *testing.T) { + for _, requestPath := range []string{ + "distribution/source/app.ts", + "distribution%2Fsource/app.ts", + "ui/../distribution/source/app.ts", + "ui/%2e%2e/distribution/source/app.ts", + } { + if _, err := runtimeFilePath(requestPath, "ui/index.html"); !errors.Is(err, ErrUnsafePath) { + t.Errorf("runtimeFilePath(%q) error = %v, want ErrUnsafePath", requestPath, err) + } + } +} + +const distributionManifestYAML = `id: canvas-board +api_version: 2 +version: 1.2.3 +display_name: Canvas Board +description: A board +author: Kandev +categories: [canvas] +min_kandev_version: 1.0.0 +distribution: + schema_version: 1 + kind: canvas + license: Custom-License + source_mode: project +ui: + web_apps: + - key: main + title: Board + entry: ui/index.html + placements: [workspace-canvas] +` + +func distributionFiles() map[string]string { + return map[string]string{ + "manifest.yaml": distributionManifestYAML, + "README.md": "Build with pnpm build.\n", + "LICENSE.txt": "Custom license terms.\n", + "ui/index.html": "", + "ui/app.js": "console.log('ok');", + "distribution/source/manifest.yaml": "name: canvas-board\n", + "distribution/source/README.md": "Build with pnpm build.\n", + "distribution/source/package.json": "{\"scripts\":{\"build\":\"vite build\"}}\n", + "distribution/source/src/main.tsx": "export default function Main() { return null; }\n", + "distribution/source/pnpm-lock.yaml": "lockfileVersion: '9.0'\n", + } +} + +func packageFromFiles(t *testing.T, files map[string]string) *Package { + t.Helper() + files["checksums.txt"] = checksumFile(files) + pkg, err := ValidateDistributionPackage(bytes.NewReader(gzipTarFiles(t, files))) + if err != nil { + t.Fatalf("ValidateDistributionPackage() unexpected error: %v", err) + } + return pkg +} + +func checksumFile(files map[string]string) string { + names := make([]string, 0, len(files)) + for name := range files { + names = append(names, name) + } + for i := 0; i < len(names); i++ { + for j := i + 1; j < len(names); j++ { + if names[j] < names[i] { + names[i], names[j] = names[j], names[i] + } + } + } + var builder strings.Builder + for _, name := range names { + sum := sha256.Sum256([]byte(files[name])) + builder.WriteString(hex.EncodeToString(sum[:])) + builder.WriteString(" ") + builder.WriteString(name) + builder.WriteByte('\n') + } + return builder.String() +} + +func gzipTarFiles(t *testing.T, files map[string]string) []byte { + t.Helper() + var compressed bytes.Buffer + zw := gzip.NewWriter(&compressed) + tw := tar.NewWriter(zw) + names := make([]string, 0, len(files)) + for name := range files { + names = append(names, name) + } + for i := 0; i < len(names); i++ { + for j := i + 1; j < len(names); j++ { + if names[j] < names[i] { + names[i], names[j] = names[j], names[i] + } + } + } + for _, name := range names { + data := []byte(files[name]) + if err := tw.WriteHeader(&tar.Header{Name: name, Mode: 0o600, Size: int64(len(data)), Typeflag: tar.TypeReg}); err != nil { + t.Fatalf("tar header: %v", err) + } + if _, err := io.Copy(tw, bytes.NewReader(data)); err != nil { + t.Fatalf("tar data: %v", err) + } + } + if err := tw.Close(); err != nil { + t.Fatalf("tar close: %v", err) + } + if err := zw.Close(); err != nil { + t.Fatalf("gzip close: %v", err) + } + return compressed.Bytes() +} diff --git a/apps/backend/internal/plugins/webapp/package.go b/apps/backend/internal/plugins/webapp/package.go index 65c12804b1..631fa83ffb 100644 --- a/apps/backend/internal/plugins/webapp/package.go +++ b/apps/backend/internal/plugins/webapp/package.go @@ -217,7 +217,11 @@ func readArchiveRegularFile(reader *tar.Reader, header *tar.Header, files map[st if _, exists := files[name]; exists { return "", nil, 0, fmt.Errorf("%w: %s", ErrDuplicatePath, name) } - if !supportedFile(name) { + if strings.HasPrefix(name, "distribution/source/") { + if err := ValidateDistributionSourcePath(name); err != nil { + return "", nil, 0, err + } + } else if !supportedFile(name) { return "", nil, 0, fmt.Errorf("%w: %s", ErrUnsupportedFile, name) } if header.Size < 0 || header.Size > limits.MaxFileBytes { diff --git a/apps/backend/internal/plugins/webapp/runtime.go b/apps/backend/internal/plugins/webapp/runtime.go index f876a4ed15..aafd3fb0db 100644 --- a/apps/backend/internal/plugins/webapp/runtime.go +++ b/apps/backend/internal/plugins/webapp/runtime.go @@ -197,10 +197,17 @@ func runtimeFilePath(requestPath, entry string) (string, error) { if err != nil { return "", err } + if runtimePathInDistribution(entryName) { + return "", ErrUnsafePath + } name := strings.TrimPrefix(strings.TrimSpace(requestPath), "/") if name == "" { return entryName, nil } + name, err = url.PathUnescape(name) + if err != nil { + return "", ErrUnsafePath + } if strings.HasPrefix(name, "_kandev/") || name == "_kandev" { return "", ErrUnsafePath } @@ -216,7 +223,23 @@ func runtimeFilePath(requestPath, entry string) (string, error) { if entryDir != "." && !strings.HasPrefix(name, entryDir+"/") { name = path.Join(entryDir, name) } - return normalizePackagePath(name, MaxPathBytes) + name, err = normalizePackagePath(name, MaxPathBytes) + if err != nil { + return "", err + } + if runtimePathInDistribution(name) { + return "", ErrUnsafePath + } + return name, nil +} + +func runtimePathInDistribution(name string) bool { + for _, component := range strings.Split(name, "/") { + if component == "distribution" { + return true + } + } + return false } func runtimeProtocolPath(requestPath string) (string, bool) { diff --git a/apps/web/app/settings/plugins/page.test.tsx b/apps/web/app/settings/plugins/page.test.tsx index 029af226e6..67456c924c 100644 --- a/apps/web/app/settings/plugins/page.test.tsx +++ b/apps/web/app/settings/plugins/page.test.tsx @@ -147,6 +147,7 @@ function installedPlugin(overrides: Partial = {}): PluginRecord { function setStoreState(plugins: PluginRecord[]) { storeState = { auth: { user: null }, + features: { canvases: false }, plugins: { items: plugins, loading: false, loaded: true, error: null }, setPlugins: vi.fn(), setPluginsLoading: vi.fn(), diff --git a/apps/web/components/settings/canvas-host-components.test.tsx b/apps/web/components/settings/canvas-host-components.test.tsx index b8b8cd86c4..addc18bc84 100644 --- a/apps/web/components/settings/canvas-host-components.test.tsx +++ b/apps/web/components/settings/canvas-host-components.test.tsx @@ -17,6 +17,9 @@ const COPY: Record = { "canvases:archivedCanvasActionHelp": "This canvas is archived. Restore it from workspace settings before changing it.", "canvases:disabledCanvasActionHelp": "This canvas is disabled. Enable it before changing it.", + "canvases:shareCanvas": "Share canvas", + "canvases:shareCanvasDescription": + "Prepare a verified bundle or source archive for review and sharing.", }; vi.mock("react-i18next", () => ({ @@ -64,6 +67,7 @@ describe("canvas host action guidance", () => { onEdit={vi.fn()} onPromote={vi.fn()} onReleases={vi.fn()} + onShare={vi.fn()} />, ); @@ -89,6 +93,7 @@ describe("canvas host action guidance", () => { onEdit={vi.fn()} onPromote={vi.fn()} onReleases={vi.fn()} + onShare={vi.fn()} onSelectCanvas={vi.fn()} editing={false} />, diff --git a/apps/web/components/settings/canvas-host-components.tsx b/apps/web/components/settings/canvas-host-components.tsx index f5a8de2489..01a7e1bab0 100644 --- a/apps/web/components/settings/canvas-host-components.tsx +++ b/apps/web/components/settings/canvas-host-components.tsx @@ -7,6 +7,7 @@ import { IconExternalLink, IconLayoutGrid, IconListDetails, + IconShare3, IconSparkles, } from "@tabler/icons-react"; import { useTranslation } from "react-i18next"; @@ -213,12 +214,14 @@ export function CanvasDesktopActions({ onEdit, onPromote, onReleases, + onShare, }: { canvas: Canvas; editing: boolean; onEdit: () => void; onPromote: () => void; onReleases: () => void; + onShare: () => void; }) { const { t } = useTranslation(); const lifecycleLocked = canvas.status === "archived" || canvas.status === "disabled"; @@ -255,6 +258,16 @@ export function CanvasDesktopActions({ {t("canvases:releasesAndPermissions")} + + + {canvas.scope_kind === "task" && ( void; + t: (key: string) => string; +}) { + return ( + + + + ); +} + function MobileCanvasNewTabAction({ canvas, t }: { canvas: Canvas; t: (key: string) => string }) { return ( void; onPromote: () => void; onReleases: () => void; + onShare: () => void; onSelectCanvas: (canvas: Canvas) => void; editing: boolean; }) { @@ -529,6 +568,7 @@ export function MobileCanvasActions({ )} + {canvas && } {canvas?.scope_kind === "task" && ( ; + setEditing: (editing: boolean) => void; + setMenuOpen: (open: boolean) => void; + onError: (reason: unknown) => void; +}; + +async function editCanvasFromHost(options: CanvasHostEditOptions): Promise { + const { canvas, router, setEditing, setMenuOpen, onError } = options; + if (!canvas) return; + setEditing(true); + try { + const response = await startCanvasEdit(canvas.id); + if (response.task_id) { + const query = response.session_id + ? `?sessionId=${encodeURIComponent(response.session_id)}` + : ""; + router.push(`/t/${encodeURIComponent(response.task_id)}${query}`); + } + } catch (reason: unknown) { + onError(reason); + } finally { + setEditing(false); + setMenuOpen(false); + } +} + export function CanvasHostRoute({ canvasId }: { canvasId: string }) { const { t } = useTranslation(); const router = useRouter(); @@ -381,26 +410,17 @@ export function CanvasHostRoute({ canvasId }: { canvasId: string }) { const [menuOpen, setMenuOpen] = useState(false); const [promotionOpen, setPromotionOpen] = useState(false); const [releasesOpen, setReleasesOpen] = useState(false); + const [shareOpen, setShareOpen] = useState(false); const [editing, setEditing] = useState(false); - const edit = async () => { - if (!canvas) return; - setEditing(true); - try { - const response = await startCanvasEdit(canvas.id); - if (response.task_id) { - const query = response.session_id - ? `?sessionId=${encodeURIComponent(response.session_id)}` - : ""; - router.push(`/t/${encodeURIComponent(response.task_id)}${query}`); - } - } catch (reason: unknown) { - setHostError(canvasErrorMessage(reason, t, "canvases:actionFailed")); - } finally { - setEditing(false); - setMenuOpen(false); - } - }; + const edit = () => + editCanvasFromHost({ + canvas, + router, + setEditing, + setMenuOpen, + onError: (reason) => setHostError(canvasErrorMessage(reason, t, "canvases:actionFailed")), + }); const selectCanvas = useCallback( (nextCanvas: Canvas) => { @@ -418,6 +438,7 @@ export function CanvasHostRoute({ canvasId }: { canvasId: string }) { onEdit={() => void edit()} onPromote={() => setPromotionOpen(true)} onReleases={() => setReleasesOpen(true)} + onShare={() => setShareOpen(true)} /> ) : null; @@ -451,6 +472,7 @@ export function CanvasHostRoute({ canvasId }: { canvasId: string }) { onEdit={() => void edit()} onPromote={() => setPromotionOpen(true)} onReleases={() => setReleasesOpen(true)} + onShare={() => setShareOpen(true)} editing={editing} canvases={hostCanvases} onSelectCanvas={selectCanvas} @@ -464,6 +486,7 @@ export function CanvasHostRoute({ canvasId }: { canvasId: string }) { onPromotionCompleted={() => router.push(canvas ? canvasHref(canvas.id) : "/")} onChanged={load} /> + ); } diff --git a/apps/web/components/settings/canvas-share-dialog.test.tsx b/apps/web/components/settings/canvas-share-dialog.test.tsx new file mode 100644 index 0000000000..b781760b6d --- /dev/null +++ b/apps/web/components/settings/canvas-share-dialog.test.tsx @@ -0,0 +1,134 @@ +import { cleanup, fireEvent, render, screen } from "@testing-library/react"; +import type { ReactNode } from "react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { Canvas } from "@/lib/api/domains/canvas-api"; +import type { ExportReview } from "@/lib/api/domains/canvas-distribution-api"; + +const prepare = vi.fn(); +const share = { + review: null as ExportReview | null, + loading: false, + error: null, + prepare, + download: vi.fn(), + cancel: vi.fn(), + reset: vi.fn(), + invalidate: vi.fn(), +}; + +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ + t: (key: string, options?: { size?: string | number }) => + options?.size === undefined ? key : `${key}:${options.size}`, + }), +})); +vi.mock("@/hooks/use-responsive-breakpoint", () => ({ + useResponsiveBreakpoint: () => ({ isMobile: false }), +})); +vi.mock("@/hooks/domains/canvas/use-canvas-share", () => ({ + useCanvasShare: () => share, +})); +vi.mock("./canvas-share-help", () => ({ CanvasShareHelp: () => null })); +vi.mock("@kandev/ui/dialog", () => ({ + Dialog: ({ children, open }: { children?: ReactNode; open?: boolean }) => + open ? <>{children} : null, + DialogContent: ({ children }: { children?: ReactNode }) =>
{children}
, + DialogDescription: ({ children }: { children?: ReactNode }) =>

{children}

, + DialogFooter: ({ children }: { children?: ReactNode }) =>
{children}
, + DialogHeader: ({ children }: { children?: ReactNode }) =>
{children}
, + DialogTitle: ({ children }: { children?: ReactNode }) =>

{children}

, +})); +vi.mock("@kandev/ui/drawer", () => ({ + Drawer: ({ children, open }: { children?: ReactNode; open?: boolean }) => + open ? <>{children} : null, + DrawerContent: ({ children }: { children?: ReactNode }) =>
{children}
, + DrawerDescription: ({ children }: { children?: ReactNode }) =>

{children}

, + DrawerFooter: ({ children }: { children?: ReactNode }) =>
{children}
, + DrawerHeader: ({ children }: { children?: ReactNode }) =>
{children}
, + DrawerTitle: ({ children }: { children?: ReactNode }) =>

{children}

, +})); + +import { CanvasShareDialog } from "./canvas-share-dialog"; + +afterEach(() => cleanup()); + +const canvas: Canvas = { + id: "canvas-1", + plugin_instance_id: "instance-1", + plugin_id: "canvas-1", + workspace_id: "workspace-1", + scope_kind: "workspace", + title: "Canvas One", + status: "active", + active_release_id: "release-1", + active_release_status: "valid", + active_release: { + id: "release-1", + validation_status: "valid", + package_id: "canvas-one", + version: "1.0.0", + display_name: "Canvas One", + description: "A portable canvas", + author: "Author", + source_mode: "static", + min_kandev_version: "0.94.0", + }, +} as Canvas; + +beforeEach(() => { + prepare.mockReset().mockResolvedValue(null); + share.cancel.mockReset(); + share.reset.mockReset(); + share.invalidate.mockReset(); + share.download.mockReset(); + share.review = null; +}); + +describe("CanvasShareDialog", () => { + it("submits editable distribution metadata, including a user supplied license", () => { + render(); + + fireEvent.change(screen.getByLabelText("canvases:license"), { + target: { value: "MIT" }, + }); + fireEvent.click(screen.getByRole("button", { name: "canvases:prepareDownloads" })); + + expect(prepare).toHaveBeenCalledWith( + expect.objectContaining({ + package_id: "canvas-one", + version: "1.0.0", + author: "Author", + license: "MIT", + source_mode: "static", + min_kandev_version: "0.94.0", + }), + ); + }); + + it("lists the retained export files with their sizes", () => { + share.review = { + preparation_id: "preparation-1", + canvas_id: "canvas-1", + workspace_id: "workspace-1", + release_id: "release-1", + metadata: { package_id: "canvas-one", version: "1.0.0" }, + sha256: "digest", + files: [{ path: "assets/logo.svg", bytes: 12 }], + bundle_bytes: 20, + source_bytes: 18, + expires_at: "2026-09-11T12:00:00Z", + bundle_download: "", + source_download: "", + }; + share.download.mockReset().mockResolvedValue(undefined); + + render(); + + expect(screen.getByText("assets/logo.svg")).toBeTruthy(); + expect(screen.getByText("canvases:downloadSize:12")).toBeTruthy(); + fireEvent.click(screen.getByRole("button", { name: "canvases:downloadBundle" })); + fireEvent.click(screen.getByRole("button", { name: "canvases:downloadSource" })); + expect(share.download).toHaveBeenNthCalledWith(1, "bundle"); + expect(share.download).toHaveBeenNthCalledWith(2, "source"); + }); +}); diff --git a/apps/web/components/settings/canvas-share-dialog.tsx b/apps/web/components/settings/canvas-share-dialog.tsx new file mode 100644 index 0000000000..f9c8b28cee --- /dev/null +++ b/apps/web/components/settings/canvas-share-dialog.tsx @@ -0,0 +1,348 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { Button } from "@kandev/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@kandev/ui/dialog"; +import { + Drawer, + DrawerContent, + DrawerDescription, + DrawerFooter, + DrawerHeader, + DrawerTitle, +} from "@kandev/ui/drawer"; +import { Input } from "@kandev/ui/input"; +import { Label } from "@kandev/ui/label"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@kandev/ui/select"; +import { Textarea } from "@kandev/ui/textarea"; +import { useResponsiveBreakpoint } from "@/hooks/use-responsive-breakpoint"; +import { useCanvasShare } from "@/hooks/domains/canvas/use-canvas-share"; +import type { DistributionMetadata } from "@/lib/api/domains/canvas-distribution-api"; +import type { Canvas } from "@/lib/api/domains/canvas-api"; +import { formatNumber } from "@/lib/i18n/formats"; +import { CanvasShareHelp } from "./canvas-share-help"; + +type ShareMetadata = DistributionMetadata & { source_mode: "static" | "project" }; + +// eslint-disable-next-line complexity -- Seeding each editable distribution field keeps the form independent from API shape changes. +function seedShareMetadata(canvas: Canvas | null): ShareMetadata { + const release = canvas?.active_release; + const sourceMode = release?.source_mode === "project" ? "project" : "static"; + return { + package_id: release?.package_id ?? "", + version: release?.version ?? "", + display_name: release?.display_name ?? canvas?.title ?? "", + description: release?.description ?? "", + author: release?.author ?? "", + license: release?.license ?? "", + source_mode: sourceMode, + min_kandev_version: release?.min_kandev_version ?? "", + repo_url: release?.repo_url ?? "", + }; +} + +// eslint-disable-next-line max-lines-per-function -- The responsive share dialog owns one cohesive metadata and review flow. +export function CanvasShareDialog({ + canvas, + open, + onOpenChange, +}: { + canvas: Canvas | null; + open: boolean; + onOpenChange: (open: boolean) => void; +}) { + const { t } = useTranslation(); + const { isMobile } = useResponsiveBreakpoint(); + const share = useCanvasShare(canvas); + const { reset, cancel, invalidate } = share; + const [metadata, setMetadata] = useState(() => seedShareMetadata(canvas)); + + useEffect(() => { + if (open) { + setMetadata(seedShareMetadata(canvas)); + } else { + reset(); + } + }, [canvas?.active_release_id, canvas?.id, open, reset]); + + const handleOpenChange = (nextOpen: boolean) => { + if (!nextOpen) void cancel(); + onOpenChange(nextOpen); + }; + const close = () => handleOpenChange(false); + const updateMetadata = (key: K, value: ShareMetadata[K]) => { + invalidate(); + setMetadata((current) => ({ ...current, [key]: value })); + }; + const prepare = () => + void share.prepare({ + ...metadata, + package_id: metadata.package_id?.trim(), + version: metadata.version?.trim(), + display_name: metadata.display_name?.trim(), + description: metadata.description?.trim(), + author: metadata.author?.trim(), + license: metadata.license?.trim(), + min_kandev_version: metadata.min_kandev_version?.trim(), + repo_url: metadata.repo_url?.trim() || undefined, + }); + const body = ( +
+ {!canvas?.active_release_id || canvas.active_release_status !== "valid" ? ( +

+ {t("canvases:shareNoRelease")} +

+ ) : ( + <> +

+ {t("canvases:prepareDownloadsDescription")} +

+ + {!share.review && ( + + )} + {Boolean(share.error) && ( +

+ {t("canvases:shareFailed")} +

+ )} + {share.review && ( + + )} + + + )} +
+ ); + const footer = ( +
+ +
+ ); + + if (isMobile) { + return ( + + + + {t("canvases:shareCanvas")} + {t("canvases:shareCanvasDescription")} + + {body} + {footer} + + + ); + } + return ( + + + + {t("canvases:shareCanvas")} + {t("canvases:shareCanvasDescription")} + + {body} + {footer} + + + ); +} + +function ExportReview({ + review, + loading, + onDownload, +}: { + review: NonNullable["review"]>; + loading: boolean; + onDownload: (kind: "bundle" | "source") => Promise; +}) { + const { t } = useTranslation(); + return ( +
+
+

{t("canvases:downloadsReady")}

+

+ {review.metadata.display_name ?? review.canvas_id} · v{review.metadata.version ?? ""} +

+
+
+
{t("canvases:packageId")}
+
{review.metadata.package_id ?? ""}
+
{t("canvases:packageVersion")}
+
{review.metadata.version ?? ""}
+
{t("canvases:fileInventory")}
+
+ {t("canvases:fileCount", { count: review.files.length })} +
    + {review.files.map((file) => ( +
  • + {file.path} + + {t("canvases:downloadSize", { size: formatNumber(file.bytes) })} + +
  • + ))} +
+
+
{t("canvases:bundleSize")}
+
{t("canvases:downloadSize", { size: formatNumber(review.bundle_bytes) })}
+
{t("canvases:sourceSize")}
+
{t("canvases:downloadSize", { size: formatNumber(review.source_bytes) })}
+
+
+ + +
+

{t("canvases:privateContentReminder")}

+
+ ); +} + +// eslint-disable-next-line max-lines-per-function -- The metadata fields stay together so every edit shares the same invalidation path. +function ShareMetadataForm({ + metadata, + onChange, +}: { + metadata: ShareMetadata; + onChange: (key: K, value: ShareMetadata[K]) => void; +}) { + const { t } = useTranslation(); + return ( +
+
+

{t("canvases:distributionMetadata")}

+

{t("canvases:distributionMetadataHelp")}

+
+
+
+ + onChange("package_id", event.target.value)} + /> +
+
+ + onChange("version", event.target.value)} + /> +
+
+ + onChange("display_name", event.target.value)} + /> +
+
+ + onChange("author", event.target.value)} + /> +
+
+ + onChange("license", event.target.value)} + /> +
+
+ + onChange("min_kandev_version", event.target.value)} + /> +
+
+ + +
+
+ + onChange("repo_url", event.target.value)} + inputMode="url" + /> +
+
+
+ +