feat: preview task canvases with workspace data (#3887)
This commit is contained in:
@@ -39,7 +39,7 @@ desktop boundaries. Link to an existing UI contract when the feature reuses one.
|
|||||||
|
|
||||||
## Mobile Design Contract
|
## Mobile Design Contract
|
||||||
|
|
||||||
When a task changes composition, navigation, overlays, touch behavior, scrolling, or breakpoint behavior, state these choices in the working plan or task notes. Keep them brief; do not create a separate document unless the task already uses a spec or committed plan. For copy, icon, color, or content-only styling inside an unchanged surface, identify the nearest mobile exemplar and the rendered mobile check instead of forcing the full contract.
|
When a task changes composition, navigation, overlays, touch behavior, scrolling, or breakpoint behavior, state these choices in the working plan or task notes. Keep them brief; do not create a separate document unless the task already uses a spec or plan. For copy, icon, color, or content-only styling inside an unchanged surface, identify the nearest mobile exemplar and the rendered mobile check instead of forcing the full contract.
|
||||||
|
|
||||||
- desktop user outcome and mobile entry point
|
- desktop user outcome and mobile entry point
|
||||||
- nearest shipped mobile exemplar and which interaction/geometry it contributes
|
- nearest shipped mobile exemplar and which interaction/geometry it contributes
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
---
|
---
|
||||||
name: plan
|
name: plan
|
||||||
description: Create a committed implementation plan and work orders from approved requirements and current system designs. Use after specification work and before implementation.
|
description: Create an implementation plan and work orders from approved requirements and current system designs. Leave the design package uncommitted for review before implementation.
|
||||||
---
|
---
|
||||||
|
|
||||||
# Create an Implementation Plan
|
# Create an Implementation Plan
|
||||||
@@ -259,6 +259,11 @@ review after the PR opens.
|
|||||||
|
|
||||||
### 5. End the design turn
|
### 5. End the design turn
|
||||||
|
|
||||||
|
Leave the full design package, including requirements, system designs, ADRs,
|
||||||
|
plan, and work orders, unstaged and uncommitted so the user can review its diff
|
||||||
|
in the workspace. Do not stage or commit these files unless the user explicitly
|
||||||
|
asks. Check `git status --short` and include the changed paths in the handoff.
|
||||||
|
|
||||||
Report the requirement IDs, system designs, plan, work orders, dependency
|
Report the requirement IDs, system designs, plan, work orders, dependency
|
||||||
order, exact checks, and open risks. For UI changes, also render a compact
|
order, exact checks, and open risks. For UI changes, also render a compact
|
||||||
ASCII preview inline in the final conversation summary; links alone are not
|
ASCII preview inline in the final conversation summary; links alone are not
|
||||||
|
|||||||
@@ -97,13 +97,14 @@ apps/backend/
|
|||||||
│ └── worktree/ # Git worktree management for workspace isolation
|
│ └── worktree/ # Git worktree management for workspace isolation
|
||||||
```
|
```
|
||||||
|
|
||||||
Canvas creation authority is recorded only by the trusted task authoring
|
Canvas creation authority comes only from the trusted task adapter and binds
|
||||||
adapter. It binds the workspace owner, creating session, task scope, and
|
the owner, session, task, and policy version. Its first valid static release
|
||||||
policy version to a new draft. The first valid static web-app release may
|
may consume that authority for exact workspace-ceiling grants in the activation
|
||||||
consume that single-use authority for exact task-scoped grants in the same
|
transaction. Existing drafts, imports, later permission increases, and
|
||||||
transaction as release insertion and activation. Existing drafts, imports,
|
revoked grants still require human review; source metadata and manifests never
|
||||||
later permission increases, and revoked grants remain on the human review
|
grant authority.
|
||||||
path. Never use source metadata or a manifest trust field as authority.
|
Canvas scopes expose `canvas_data_scope_transition_total` with fixed transition
|
||||||
|
and result labels. Lifecycle events carry IDs; metrics never label task data.
|
||||||
|
|
||||||
## Key Concepts
|
## Key Concepts
|
||||||
|
|
||||||
@@ -293,8 +294,7 @@ Enforced by `apps/backend/.golangci.yml` (errors on new code only):
|
|||||||
|
|
||||||
When a PR fixup touches backend code, run `golangci-lint run ./... --new-from-rev="<base-sha>" --timeout=5m` from `apps/backend` with the PR base SHA before pushing; CI enforces changed-file complexity thresholds.
|
When a PR fixup touches backend code, run `golangci-lint run ./... --new-from-rev="<base-sha>" --timeout=5m` from `apps/backend` with the PR base SHA before pushing; CI enforces changed-file complexity thresholds.
|
||||||
- `internal/launcher/` — native launcher owning every entrypoint (`dev`, `start`, `run`, `service`); `dev` runs `make -C apps/backend dev` with Vite as a supervised child, state under `<repoRoot>/.kandev-dev/`. The root `make dev` prebuilds only the copied launcher; the backend dev target builds the native agentctl and a linux/amd64 helper when the host is not Linux/amd64 (`docs/plans/go-dev-launcher/`).
|
- `internal/launcher/` — native launcher owning every entrypoint (`dev`, `start`, `run`, `service`); `dev` runs `make -C apps/backend dev` with Vite as a supervised child, state under `<repoRoot>/.kandev-dev/`. The root `make dev` prebuilds only the copied launcher; the backend dev target builds the native agentctl and a linux/amd64 helper when the host is not Linux/amd64 (`docs/plans/go-dev-launcher/`).
|
||||||
- `internal/agentctl/AGENTS.md` — agentctl server route groups, adapter model, ACP protocol
|
- `internal/agentctl/AGENTS.md` — server routes, adapters, ACP; `cmd/mock-agent/AGENTS.md` — E2E scenario patterns and rebuild requirements
|
||||||
- `internal/agentctl/server/api/AGENTS.md` — reverse-proxy body rewriting (`Accept-Encoding`), iframe-blocking header stripping
|
- `internal/agentctl/server/api/AGENTS.md` — reverse-proxy body rewriting (`Accept-Encoding`), iframe-blocking header stripping
|
||||||
- `internal/integrations/AGENTS.md` — playbook for adding a new third-party integration (Jira/Linear pattern)
|
- `internal/integrations/AGENTS.md` — playbook for adding a new third-party integration (Jira/Linear pattern)
|
||||||
- `docs/i18n.md` ("Backend") — `internal/i18n` covers only what Go renders straight to a browser: the SPA-unavailable error pages and the shared-task artifacts (`share.html`, gist README and description). Both are complete. Everything else stays English by design; for new user-facing output prefer a stable error code the frontend translates. Use `i18n.Tf` for anything carrying a value — never `fmt.Sprintf` a translated string, and never build a plural in Go. A locale for output that outlives the request is resolved once at write time and threaded as an argument, not a context value (ADR `2026-08-01-share-artifact-locale.md`).
|
- `docs/i18n.md` ("Backend") — `internal/i18n` covers only what Go renders straight to a browser: the SPA-unavailable error pages and the shared-task artifacts (`share.html`, gist README and description). Both are complete. Everything else stays English by design; for new user-facing output prefer a stable error code the frontend translates. Use `i18n.Tf` for anything carrying a value — never `fmt.Sprintf` a translated string, and never build a plural in Go. A locale for output that outlives the request is resolved once at write time and threaded as an argument, not a context value (ADR `2026-08-01-share-artifact-locale.md`).
|
||||||
- `cmd/mock-agent/AGENTS.md` — predefined `/e2e:<name>` scenarios vs inline `e2e:...` scripts, recipe for adding a scenario, and the rebuild-before-e2e requirement
|
|
||||||
|
|||||||
@@ -16,8 +16,10 @@ canvas identity and whether its release is active, awaits permission review,
|
|||||||
or was unsuccessful.
|
or was unsuccessful.
|
||||||
|
|
||||||
If publication is unsuccessful, report the failure and do not claim that the
|
If publication is unsuccessful, report the failure and do not claim that the
|
||||||
canvas is published. If workspace access requires promotion, explain the user
|
canvas is published. A new owner-authorized task canvas can use its declared
|
||||||
action that is still required.
|
supported permissions across the current workspace before promotion. Promotion
|
||||||
|
adds workspace navigation. Do not claim that promotion is required to preview
|
||||||
|
workspace task data.
|
||||||
|
|
||||||
A local build alone does not publish a canvas inside Kandev. If the tools remain
|
A local build alone does not publish a canvas inside Kandev. If the tools remain
|
||||||
unavailable, report the limitation instead of claiming that workspace files
|
unavailable, report the limitation instead of claiming that workspace files
|
||||||
|
|||||||
@@ -351,12 +351,7 @@ func (s *canvasAuthoringService) PublishCanvas(ctx context.Context, request mcph
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
defer s.endPublish(request.Agent.SessionID, item.ID)
|
defer s.endPublish(request.Agent.SessionID, item.ID)
|
||||||
expectedAuthority := instances.PublishAuthority{
|
expectedAuthority := canvasPublishAuthority(item)
|
||||||
InstanceID: item.PluginInstanceID, ScopeKind: item.ScopeKind,
|
|
||||||
WorkspaceID: item.WorkspaceID, TaskID: item.TaskID,
|
|
||||||
Status: item.Status, ActiveReleaseID: item.ActiveReleaseID,
|
|
||||||
GrantGeneration: item.GrantGeneration,
|
|
||||||
}
|
|
||||||
expectedBaseReleaseID := ""
|
expectedBaseReleaseID := ""
|
||||||
if editSession {
|
if editSession {
|
||||||
expectedBaseReleaseID = target.ReleaseID
|
expectedBaseReleaseID = target.ReleaseID
|
||||||
@@ -364,6 +359,15 @@ func (s *canvasAuthoringService) PublishCanvas(ctx context.Context, request mcph
|
|||||||
return s.publishCanvasSource(ctx, execution, task, item, request, expectedAuthority, expectedBaseReleaseID)
|
return s.publishCanvasSource(ctx, execution, task, item, request, expectedAuthority, expectedBaseReleaseID)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func canvasPublishAuthority(item *canvas.Canvas) instances.PublishAuthority {
|
||||||
|
return instances.PublishAuthority{
|
||||||
|
InstanceID: item.PluginInstanceID, ScopeKind: item.ScopeKind,
|
||||||
|
DataScopeKind: item.DataScopeKind, WorkspaceID: item.WorkspaceID,
|
||||||
|
TaskID: item.TaskID, Status: item.Status,
|
||||||
|
ActiveReleaseID: item.ActiveReleaseID, GrantGeneration: item.GrantGeneration,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func validateCanvasPublishSource(item *canvas.Canvas, request mcphandlers.CanvasPublishRequest) error {
|
func validateCanvasPublishSource(item *canvas.Canvas, request mcphandlers.CanvasPublishRequest) error {
|
||||||
if request.SourcePath != canvasSourceRoot(item.ID) {
|
if request.SourcePath != canvasSourceRoot(item.ID) {
|
||||||
return canvasOperationError("invalid_source_root", "source_path must be the assigned canvas directory", nil)
|
return canvasOperationError("invalid_source_root", "source_path must be the assigned canvas directory", nil)
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
package backendapp
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/kandev/kandev/internal/canvas"
|
||||||
|
"github.com/kandev/kandev/internal/plugins/instances"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestCanvasPublishAuthorityCapturesPlacementAndDataScope(t *testing.T) {
|
||||||
|
item := &canvas.Canvas{
|
||||||
|
PluginInstanceID: "instance-1",
|
||||||
|
ScopeKind: canvas.ScopeTask,
|
||||||
|
DataScopeKind: instances.ScopeWorkspace,
|
||||||
|
WorkspaceID: "workspace-1",
|
||||||
|
TaskID: "task-1",
|
||||||
|
Status: canvas.StatusActive,
|
||||||
|
GrantGeneration: 4,
|
||||||
|
}
|
||||||
|
|
||||||
|
authority := canvasPublishAuthority(item)
|
||||||
|
if authority.ScopeKind != canvas.ScopeTask || authority.DataScopeKind != instances.ScopeWorkspace {
|
||||||
|
t.Fatalf("publish authority scopes = placement %q, data %q; want task placement and workspace data", authority.ScopeKind, authority.DataScopeKind)
|
||||||
|
}
|
||||||
|
if authority.InstanceID != item.PluginInstanceID || authority.WorkspaceID != item.WorkspaceID || authority.TaskID != item.TaskID || authority.Status != item.Status || authority.GrantGeneration != item.GrantGeneration {
|
||||||
|
t.Fatalf("publish authority = %+v, missing current canvas state", authority)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -23,6 +23,7 @@ import (
|
|||||||
"github.com/kandev/kandev/internal/plugins/webapp"
|
"github.com/kandev/kandev/internal/plugins/webapp"
|
||||||
"github.com/kandev/kandev/internal/task/models"
|
"github.com/kandev/kandev/internal/task/models"
|
||||||
taskservice "github.com/kandev/kandev/internal/task/service"
|
taskservice "github.com/kandev/kandev/internal/task/service"
|
||||||
|
userstore "github.com/kandev/kandev/internal/user/store"
|
||||||
"github.com/kandev/kandev/internal/worktree/copyfiles"
|
"github.com/kandev/kandev/internal/worktree/copyfiles"
|
||||||
v1 "github.com/kandev/kandev/pkg/api/v1"
|
v1 "github.com/kandev/kandev/pkg/api/v1"
|
||||||
)
|
)
|
||||||
@@ -38,6 +39,11 @@ type canvasHTTPHandler struct {
|
|||||||
distribution *canvasservice.DistributionService
|
distribution *canvasservice.DistributionService
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type canvasWorkspaceOwnerAccess interface {
|
||||||
|
AuthorizeWorkspaceAccess(context.Context, string) error
|
||||||
|
GetWorkspace(context.Context, string) (*models.Workspace, error)
|
||||||
|
}
|
||||||
|
|
||||||
type canvasEditTaskStore interface {
|
type canvasEditTaskStore interface {
|
||||||
AuthorizeWorkspaceAccess(context.Context, string) error
|
AuthorizeWorkspaceAccess(context.Context, string) error
|
||||||
GetWorkspace(context.Context, string) (*models.Workspace, error)
|
GetWorkspace(context.Context, string) (*models.Workspace, error)
|
||||||
@@ -370,6 +376,8 @@ func registerCanvasRoutes(p routeParams) {
|
|||||||
p.router.GET("/api/v1/canvases/:canvasID/releases", h.releases)
|
p.router.GET("/api/v1/canvases/:canvasID/releases", h.releases)
|
||||||
p.router.GET("/api/v1/canvases/:canvasID/promotion-preview", h.promotionPreview)
|
p.router.GET("/api/v1/canvases/:canvasID/promotion-preview", h.promotionPreview)
|
||||||
p.router.POST("/api/v1/canvases/:canvasID/promotion", h.promote)
|
p.router.POST("/api/v1/canvases/:canvasID/promotion", h.promote)
|
||||||
|
p.router.GET("/api/v1/canvases/:canvasID/workspace-data-preview", h.workspaceDataPreview)
|
||||||
|
p.router.POST("/api/v1/canvases/:canvasID/workspace-data", h.enableWorkspaceData)
|
||||||
p.router.POST("/api/v1/canvases/:canvasID/releases/:releaseID/approve", h.approveRelease)
|
p.router.POST("/api/v1/canvases/:canvasID/releases/:releaseID/approve", h.approveRelease)
|
||||||
p.router.POST("/api/v1/canvases/:canvasID/releases/:releaseID/reject", h.rejectRelease)
|
p.router.POST("/api/v1/canvases/:canvasID/releases/:releaseID/reject", h.rejectRelease)
|
||||||
p.router.POST("/api/v1/canvases/:canvasID/rollback", h.rollback)
|
p.router.POST("/api/v1/canvases/:canvasID/rollback", h.rollback)
|
||||||
@@ -390,6 +398,7 @@ type canvasHTTPResponse struct {
|
|||||||
TaskID string `json:"task_id,omitempty"`
|
TaskID string `json:"task_id,omitempty"`
|
||||||
OriginTaskID string `json:"origin_task_id,omitempty"`
|
OriginTaskID string `json:"origin_task_id,omitempty"`
|
||||||
ScopeKind string `json:"scope_kind"`
|
ScopeKind string `json:"scope_kind"`
|
||||||
|
DataScopeKind string `json:"data_scope_kind"`
|
||||||
Title string `json:"title"`
|
Title string `json:"title"`
|
||||||
CreatedBySessionID string `json:"created_by_session_id,omitempty"`
|
CreatedBySessionID string `json:"created_by_session_id,omitempty"`
|
||||||
PromotedByUserID string `json:"promoted_by_user_id,omitempty"`
|
PromotedByUserID string `json:"promoted_by_user_id,omitempty"`
|
||||||
@@ -442,6 +451,7 @@ func canvasResponse(value canvasservice.Canvas) canvasHTTPResponse {
|
|||||||
TaskID: value.TaskID,
|
TaskID: value.TaskID,
|
||||||
OriginTaskID: value.OriginTaskID,
|
OriginTaskID: value.OriginTaskID,
|
||||||
ScopeKind: value.ScopeKind,
|
ScopeKind: value.ScopeKind,
|
||||||
|
DataScopeKind: value.DataScopeKind,
|
||||||
Title: value.Title,
|
Title: value.Title,
|
||||||
CreatedBySessionID: value.CreatedBySessionID,
|
CreatedBySessionID: value.CreatedBySessionID,
|
||||||
PromotedByUserID: value.PromotedByUserID,
|
PromotedByUserID: value.PromotedByUserID,
|
||||||
@@ -633,23 +643,25 @@ func (h *canvasHTTPHandler) promotionPreview(c *gin.Context) {
|
|||||||
preview.SourceSessionID,
|
preview.SourceSessionID,
|
||||||
)
|
)
|
||||||
writeCanvasJSON(c, http.StatusOK, map[string]interface{}{
|
writeCanvasJSON(c, http.StatusOK, map[string]interface{}{
|
||||||
"canvas_id": preview.Canvas.ID,
|
"canvas_id": preview.Canvas.ID,
|
||||||
"title": preview.Canvas.Title,
|
"title": preview.Canvas.Title,
|
||||||
"origin_task_id": preview.Canvas.OriginTaskID,
|
"origin_task_id": preview.Canvas.OriginTaskID,
|
||||||
"active_release": releaseResponseFromCanvas(preview.Canvas),
|
"active_release": releaseResponseFromCanvas(preview.Canvas),
|
||||||
"source_actor_kind": preview.SourceActorKind,
|
"source_actor_kind": preview.SourceActorKind,
|
||||||
"source_user_id": preview.SourceUserID,
|
"source_user_id": preview.SourceUserID,
|
||||||
"source_task_id": preview.SourceTaskID,
|
"source_task_id": preview.SourceTaskID,
|
||||||
"source_task_title": sourceLabels.TaskTitle,
|
"source_task_title": sourceLabels.TaskTitle,
|
||||||
"source_session_id": preview.SourceSessionID,
|
"source_session_id": preview.SourceSessionID,
|
||||||
"source_session_name": sourceLabels.SessionName,
|
"source_session_name": sourceLabels.SessionName,
|
||||||
"permissions": preview.Permissions,
|
"permissions": preview.Permissions,
|
||||||
"active_release_id": preview.ActiveReleaseID,
|
"active_release_id": preview.ActiveReleaseID,
|
||||||
"permission_digest": preview.PermissionDigest,
|
"permission_digest": preview.PermissionDigest,
|
||||||
"grant_generation": preview.GrantGeneration,
|
"grant_generation": preview.GrantGeneration,
|
||||||
"current_scope": preview.CurrentScope,
|
"current_scope": preview.CurrentScope,
|
||||||
"target_scope": preview.TargetScope,
|
"target_scope": preview.TargetScope,
|
||||||
"placement": preview.Placement,
|
"current_data_scope_kind": preview.CurrentDataScope,
|
||||||
|
"target_data_scope_kind": preview.TargetDataScope,
|
||||||
|
"placement": preview.Placement,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -688,6 +700,56 @@ func (h *canvasHTTPHandler) promote(c *gin.Context) {
|
|||||||
writeCanvasJSON(c, http.StatusOK, canvasResponse(*updated))
|
writeCanvasJSON(c, http.StatusOK, canvasResponse(*updated))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (h *canvasHTTPHandler) workspaceDataPreview(c *gin.Context) {
|
||||||
|
item, err := h.canvases.Get(c.Request.Context(), c.Param("canvasID"))
|
||||||
|
if err != nil {
|
||||||
|
h.writeError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if item == nil || !h.authorizeWorkspaceOwner(c, item.WorkspaceID) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
preview, err := h.canvases.WorkspaceDataPreview(c.Request.Context(), item.ID)
|
||||||
|
if err != nil {
|
||||||
|
h.writeError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeCanvasJSON(c, http.StatusOK, map[string]interface{}{
|
||||||
|
"canvas": canvasResponse(*preview.Canvas),
|
||||||
|
"active_release_id": preview.ActiveReleaseID,
|
||||||
|
"permission_digest": preview.PermissionDigest,
|
||||||
|
"grant_generation": preview.GrantGeneration,
|
||||||
|
"current_data_scope_kind": preview.CurrentDataScopeKind,
|
||||||
|
"target_data_scope_kind": preview.TargetDataScopeKind,
|
||||||
|
"permissions": preview.Permissions,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *canvasHTTPHandler) enableWorkspaceData(c *gin.Context) {
|
||||||
|
item, err := h.canvases.Get(c.Request.Context(), c.Param("canvasID"))
|
||||||
|
if err != nil {
|
||||||
|
h.writeError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if item == nil || !h.authorizeWorkspaceOwner(c, item.WorkspaceID) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var request canvasPromotionRequest
|
||||||
|
if c.Request.Body == nil || c.ShouldBindJSON(&request) != nil || strings.TrimSpace(request.ExpectedReleaseID) == "" || strings.TrimSpace(request.ExpectedPermissionDigest) == "" || request.ExpectedGrantGeneration == nil || *request.ExpectedGrantGeneration < 0 {
|
||||||
|
writeCanvasError(c, http.StatusBadRequest, "workspace_data_review_required", nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
updated, err := h.canvases.EnableWorkspaceDataReviewed(
|
||||||
|
c.Request.Context(), item.ID, canvasRuntimeUser(c), request.ExpectedReleaseID,
|
||||||
|
request.ExpectedPermissionDigest, *request.ExpectedGrantGeneration,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
h.writeError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeCanvasJSON(c, http.StatusOK, canvasResponse(*updated))
|
||||||
|
}
|
||||||
|
|
||||||
func (h *canvasHTTPHandler) approveRelease(c *gin.Context) {
|
func (h *canvasHTTPHandler) approveRelease(c *gin.Context) {
|
||||||
h.releaseMutation(c, func(ctx context.Context, canvasID, releaseID, userID string) (*canvasservice.Canvas, error) {
|
h.releaseMutation(c, func(ctx context.Context, canvasID, releaseID, userID string) (*canvasservice.Canvas, error) {
|
||||||
return h.canvases.ApproveRelease(ctx, canvasID, releaseID, userID)
|
return h.canvases.ApproveRelease(ctx, canvasID, releaseID, userID)
|
||||||
@@ -886,6 +948,38 @@ func (h *canvasHTTPHandler) authorizeWorkspaceManage(c *gin.Context, workspaceID
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (h *canvasHTTPHandler) authorizeWorkspaceOwner(c *gin.Context, workspaceID string) bool {
|
||||||
|
owner, err := checkCanvasWorkspaceOwner(c.Request.Context(), h.tasks, workspaceID, canvasRuntimeUser(c))
|
||||||
|
if err != nil {
|
||||||
|
writeCanvasError(c, http.StatusNotFound, "canvas_not_found", nil)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if !owner {
|
||||||
|
writeCanvasError(c, http.StatusForbidden, "workspace_owner_required", nil)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func checkCanvasWorkspaceOwner(ctx context.Context, access canvasWorkspaceOwnerAccess, workspaceID, userID string) (bool, error) {
|
||||||
|
if access == nil {
|
||||||
|
return false, errors.New("canvas workspace access is unavailable")
|
||||||
|
}
|
||||||
|
if err := access.AuthorizeWorkspaceAccess(ctx, workspaceID); err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
workspace, err := access.GetWorkspace(ctx, workspaceID)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
if workspace == nil {
|
||||||
|
return false, errors.New("canvas workspace was not found")
|
||||||
|
}
|
||||||
|
actorID := strings.TrimSpace(userID)
|
||||||
|
ownerID := strings.TrimSpace(workspace.OwnerID)
|
||||||
|
return actorID != "" && (ownerID == actorID || (ownerID == "" && actorID == userstore.DefaultUserID)), nil
|
||||||
|
}
|
||||||
|
|
||||||
func canvasRouteParam(c *gin.Context, names ...string) string {
|
func canvasRouteParam(c *gin.Context, names ...string) string {
|
||||||
for _, name := range names {
|
for _, name := range names {
|
||||||
if value := c.Param(name); value != "" {
|
if value := c.Param(name); value != "" {
|
||||||
|
|||||||
@@ -0,0 +1,70 @@
|
|||||||
|
package backendapp
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/kandev/kandev/internal/task/models"
|
||||||
|
userstore "github.com/kandev/kandev/internal/user/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
type canvasWorkspaceOwnerAccessStub struct {
|
||||||
|
workspace *models.Workspace
|
||||||
|
accessErr error
|
||||||
|
workspaceErr error
|
||||||
|
workspaceRead bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *canvasWorkspaceOwnerAccessStub) AuthorizeWorkspaceAccess(context.Context, string) error {
|
||||||
|
return s.accessErr
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *canvasWorkspaceOwnerAccessStub) GetWorkspace(context.Context, string) (*models.Workspace, error) {
|
||||||
|
s.workspaceRead = true
|
||||||
|
return s.workspace, s.workspaceErr
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCheckCanvasWorkspaceOwnerRequiresCurrentWorkspaceOwner(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
userID string
|
||||||
|
workspace *models.Workspace
|
||||||
|
wantOwner bool
|
||||||
|
}{
|
||||||
|
{name: "owner", userID: "owner-1", workspace: &models.Workspace{ID: "workspace-1", OwnerID: "owner-1"}, wantOwner: true},
|
||||||
|
{name: "workspace member", userID: "member-1", workspace: &models.Workspace{ID: "workspace-1", OwnerID: "owner-1"}},
|
||||||
|
{name: "empty user", workspace: &models.Workspace{ID: "workspace-1", OwnerID: "owner-1"}},
|
||||||
|
{name: "auth-disabled default user owns an unowned workspace", userID: userstore.DefaultUserID, workspace: &models.Workspace{ID: "workspace-1"}, wantOwner: true},
|
||||||
|
{name: "ordinary user cannot own an unowned workspace", userID: "member-1", workspace: &models.Workspace{ID: "workspace-1"}},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
access := &canvasWorkspaceOwnerAccessStub{workspace: tt.workspace}
|
||||||
|
owner, err := checkCanvasWorkspaceOwner(context.Background(), access, "workspace-1", tt.userID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("checkCanvasWorkspaceOwner() error = %v", err)
|
||||||
|
}
|
||||||
|
if owner != tt.wantOwner {
|
||||||
|
t.Fatalf("owner = %t, want %t", owner, tt.wantOwner)
|
||||||
|
}
|
||||||
|
if !access.workspaceRead {
|
||||||
|
t.Fatal("workspace owner was not checked")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCheckCanvasWorkspaceOwnerDoesNotReadWorkspaceAfterAccessDenial(t *testing.T) {
|
||||||
|
access := &canvasWorkspaceOwnerAccessStub{accessErr: errors.New("workspace access denied")}
|
||||||
|
owner, err := checkCanvasWorkspaceOwner(context.Background(), access, "workspace-1", "owner-1")
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("checkCanvasWorkspaceOwner() error = nil, want access denial")
|
||||||
|
}
|
||||||
|
if owner {
|
||||||
|
t.Fatal("owner = true after workspace access denial")
|
||||||
|
}
|
||||||
|
if access.workspaceRead {
|
||||||
|
t.Fatal("workspace details were read after access denial")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -39,12 +39,13 @@ func (h *canvasHTTPHandler) runtimeBinding(c *gin.Context, canvas canvasservice.
|
|||||||
WebAppKey: app.Key,
|
WebAppKey: app.Key,
|
||||||
Placement: placement,
|
Placement: placement,
|
||||||
ScopeKind: instance.ScopeKind,
|
ScopeKind: instance.ScopeKind,
|
||||||
|
DataScopeKind: instance.EffectiveDataScopeKind(),
|
||||||
WorkspaceID: instance.WorkspaceID,
|
WorkspaceID: instance.WorkspaceID,
|
||||||
TaskID: instance.TaskID,
|
TaskID: instance.TaskID,
|
||||||
SessionID: instance.SessionID,
|
SessionID: instance.SessionID,
|
||||||
RepositoryID: instance.RepositoryID,
|
RepositoryID: instance.RepositoryID,
|
||||||
GrantGeneration: instance.GrantGeneration,
|
GrantGeneration: instance.GrantGeneration,
|
||||||
Permissions: runtimeGrantedPermissions(appManifest, instance.ScopeKind, grants),
|
Permissions: runtimeGrantedPermissions(appManifest, instance.EffectiveDataScopeKind(), grants),
|
||||||
Artifact: webapp.Artifact{
|
Artifact: webapp.Artifact{
|
||||||
Digest: release.PackageDigest,
|
Digest: release.PackageDigest,
|
||||||
RelativePath: release.ArtifactPath,
|
RelativePath: release.ArtifactPath,
|
||||||
@@ -52,7 +53,7 @@ func (h *canvasHTTPHandler) runtimeBinding(c *gin.Context, canvas canvasservice.
|
|||||||
Available: true,
|
Available: true,
|
||||||
},
|
},
|
||||||
Entry: app.Entry,
|
Entry: app.Entry,
|
||||||
NetworkOrigins: runtimeNetworkOrigins(app, instance.ScopeKind, grants),
|
NetworkOrigins: runtimeNetworkOrigins(app, instance.EffectiveDataScopeKind(), grants),
|
||||||
}
|
}
|
||||||
runtime := h.plugins.WebRuntime()
|
runtime := h.plugins.WebRuntime()
|
||||||
if runtime == nil {
|
if runtime == nil {
|
||||||
@@ -141,6 +142,10 @@ func (h *canvasHTTPHandler) writeError(c *gin.Context, err error) {
|
|||||||
status, code = http.StatusBadRequest, canvasErrorCodeInvalid
|
status, code = http.StatusBadRequest, canvasErrorCodeInvalid
|
||||||
case errors.Is(err, canvasservice.ErrStalePromotionReview):
|
case errors.Is(err, canvasservice.ErrStalePromotionReview):
|
||||||
status, code = http.StatusConflict, "promotion_review_stale"
|
status, code = http.StatusConflict, "promotion_review_stale"
|
||||||
|
case errors.Is(err, canvasservice.ErrStaleWorkspaceDataReview):
|
||||||
|
status, code = http.StatusConflict, "workspace_data_review_stale"
|
||||||
|
case errors.Is(err, instances.ErrWorkspaceOwnerRequired):
|
||||||
|
status, code = http.StatusForbidden, "workspace_owner_required"
|
||||||
case errors.Is(err, canvasservice.ErrStaleCanvasEdit):
|
case errors.Is(err, canvasservice.ErrStaleCanvasEdit):
|
||||||
status, code = http.StatusConflict, "canvas_edit_stale"
|
status, code = http.StatusConflict, "canvas_edit_stale"
|
||||||
case errors.Is(err, canvasservice.ErrInvalidCanvas), errors.Is(err, canvasservice.ErrInvalidCanvasState):
|
case errors.Is(err, canvasservice.ErrInvalidCanvas), errors.Is(err, canvasservice.ErrInvalidCanvasState):
|
||||||
|
|||||||
@@ -74,9 +74,23 @@ type PromotionPreview struct {
|
|||||||
GrantGeneration int64 `json:"grant_generation"`
|
GrantGeneration int64 `json:"grant_generation"`
|
||||||
CurrentScope string `json:"current_scope"`
|
CurrentScope string `json:"current_scope"`
|
||||||
TargetScope string `json:"target_scope"`
|
TargetScope string `json:"target_scope"`
|
||||||
|
CurrentDataScope string `json:"current_data_scope_kind"`
|
||||||
|
TargetDataScope string `json:"target_data_scope_kind"`
|
||||||
Placement string `json:"placement"`
|
Placement string `json:"placement"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// WorkspaceDataPreview is the owner-facing snapshot for widening a retained
|
||||||
|
// task canvas's data scope without changing its placement.
|
||||||
|
type WorkspaceDataPreview struct {
|
||||||
|
Canvas *Canvas `json:"canvas"`
|
||||||
|
Permissions PermissionSummary `json:"permissions"`
|
||||||
|
ActiveReleaseID string `json:"active_release_id"`
|
||||||
|
PermissionDigest string `json:"permission_digest"`
|
||||||
|
GrantGeneration int64 `json:"grant_generation"`
|
||||||
|
CurrentDataScopeKind string `json:"current_data_scope_kind"`
|
||||||
|
TargetDataScopeKind string `json:"target_data_scope_kind"`
|
||||||
|
}
|
||||||
|
|
||||||
// authoringInstanceStore is the release/governance extension implemented by
|
// authoringInstanceStore is the release/governance extension implemented by
|
||||||
// the durable plugin instance store. Keeping it optional preserves the small
|
// the durable plugin instance store. Keeping it optional preserves the small
|
||||||
// lifecycle fake used by the basic canvas service tests.
|
// lifecycle fake used by the basic canvas service tests.
|
||||||
@@ -106,12 +120,20 @@ type reviewedPromotionStore interface {
|
|||||||
PromoteScopeAndGrantsReviewedTx(context.Context, *sqlx.Tx, string, string, string, []plugininstances.Grant, string, string, int64) error
|
PromoteScopeAndGrantsReviewedTx(context.Context, *sqlx.Tx, string, string, string, []plugininstances.Grant, string, string, int64) error
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type reviewedWorkspaceDataStore interface {
|
||||||
|
EnableWorkspaceDataReviewedTx(context.Context, *sqlx.Tx, string, string, string, []plugininstances.Grant, string, string, int64) error
|
||||||
|
}
|
||||||
|
|
||||||
type conditionalReleaseStore interface {
|
type conditionalReleaseStore interface {
|
||||||
CreateReleaseIfAuthorityTx(context.Context, *sqlx.Tx, string, plugininstances.PublishAuthority, plugininstances.Release) error
|
CreateReleaseIfAuthorityTx(context.Context, *sqlx.Tx, string, plugininstances.PublishAuthority, plugininstances.Release) error
|
||||||
}
|
}
|
||||||
|
|
||||||
type initialGrantStore interface {
|
type initialGrantStore interface {
|
||||||
AddInitialGrantsTx(context.Context, *sqlx.Tx, string, string, []plugininstances.Grant) error
|
AddInitialGrantsTx(context.Context, *sqlx.Tx, string, string, string, []plugininstances.Grant) error
|
||||||
|
}
|
||||||
|
|
||||||
|
type initialPublicationDataScopeStore interface {
|
||||||
|
SetInitialPublicationDataScopeTx(context.Context, *sqlx.Tx, plugininstances.PublishAuthority, string) error
|
||||||
}
|
}
|
||||||
|
|
||||||
type creationAuthorityStore interface {
|
type creationAuthorityStore interface {
|
||||||
@@ -294,7 +316,7 @@ func (s *Service) preparePublishedRelease(ctx context.Context, store authoringIn
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return plugininstances.Instance{}, plugininstances.Release{}, false, CreationAuthority{}, nil, err
|
return plugininstances.Instance{}, plugininstances.Release{}, false, CreationAuthority{}, nil, err
|
||||||
}
|
}
|
||||||
activated := permissionsFit(permissions, instance.ScopeKind, grants)
|
activated := permissionsFit(permissions, instance.EffectiveDataScopeKind(), grants)
|
||||||
if creationAuthority.CanvasID != "" {
|
if creationAuthority.CanvasID != "" {
|
||||||
activated = true
|
activated = true
|
||||||
}
|
}
|
||||||
@@ -326,7 +348,7 @@ func (s *Service) preparePublishedReleaseForInstance(ctx context.Context, store
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return plugininstances.Instance{}, plugininstances.Release{}, false, err
|
return plugininstances.Instance{}, plugininstances.Release{}, false, err
|
||||||
}
|
}
|
||||||
activated := permissionsFit(permissions, instance.ScopeKind, grants)
|
activated := permissionsFit(permissions, instance.EffectiveDataScopeKind(), grants)
|
||||||
release, err := s.buildPublishedRelease(request, instance.ID, permissions, activated)
|
release, err := s.buildPublishedRelease(request, instance.ID, permissions, activated)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return plugininstances.Instance{}, plugininstances.Release{}, false, err
|
return plugininstances.Instance{}, plugininstances.Release{}, false, err
|
||||||
@@ -383,7 +405,7 @@ func (s *Service) resolveCreationAuthority(ctx context.Context, request PublishR
|
|||||||
if !creationAuthorityEligible(candidate, instance, request, grants, releases) {
|
if !creationAuthorityEligible(candidate, instance, request, grants, releases) {
|
||||||
return CreationAuthority{}, nil, nil
|
return CreationAuthority{}, nil, nil
|
||||||
}
|
}
|
||||||
return candidate, grantsForManifest(permissions, candidate.OwnerUserID, ScopeTask), nil
|
return candidate, grantsForManifest(permissions, candidate.OwnerUserID, creationAuthorityDataScope(candidate)), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) buildPublishedRelease(request PublishRequest, instanceID string, permissions PermissionSummary, activated bool) (plugininstances.Release, error) {
|
func (s *Service) buildPublishedRelease(request PublishRequest, instanceID string, permissions PermissionSummary, activated bool) (plugininstances.Release, error) {
|
||||||
@@ -422,6 +444,9 @@ func persistPublishedRelease(ctx context.Context, store authoringInstanceStore,
|
|||||||
return false, ErrStaleCanvasPublish
|
return false, ErrStaleCanvasPublish
|
||||||
}
|
}
|
||||||
err := persistAuthorityRelease(ctx, transactional, conditional, authorityStore, canvasID, instanceID, release, activated, expectedAuthority, creationAuthority, initialGrants, ownerUserID, allowUnownedWorkspace, sessionID, taskID)
|
err := persistAuthorityRelease(ctx, transactional, conditional, authorityStore, canvasID, instanceID, release, activated, expectedAuthority, creationAuthority, initialGrants, ownerUserID, allowUnownedWorkspace, sessionID, taskID)
|
||||||
|
if err == nil && activated && creationAuthority.CanvasID != "" && creationAuthorityDataScope(creationAuthority) == ScopeWorkspace {
|
||||||
|
recordCanvasScopeTransition(canvasScopeFirstPublication, canvasScopeEnabled)
|
||||||
|
}
|
||||||
return err == nil, err
|
return err == nil, err
|
||||||
}
|
}
|
||||||
if expectedBaseReleaseID != "" {
|
if expectedBaseReleaseID != "" {
|
||||||
@@ -460,10 +485,19 @@ func persistAuthorityRelease(ctx context.Context, transactional transactionalAut
|
|||||||
if !ok || authorityStore == nil || creationAuthority.CanvasID != canvasID {
|
if !ok || authorityStore == nil || creationAuthority.CanvasID != canvasID {
|
||||||
return ErrStaleCanvasPublish
|
return ErrStaleCanvasPublish
|
||||||
}
|
}
|
||||||
if err := grantStore.AddInitialGrantsTx(ctx, tx, instanceID, ownerUserID, initialGrants); err != nil {
|
if err := authorityStore.ConsumeCreationAuthorityTx(ctx, tx, creationAuthority, ownerUserID, sessionID, taskID, allowUnownedWorkspace); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if err := authorityStore.ConsumeCreationAuthorityTx(ctx, tx, creationAuthority, ownerUserID, sessionID, taskID, allowUnownedWorkspace); err != nil {
|
if creationAuthorityDataScope(creationAuthority) == ScopeWorkspace {
|
||||||
|
scopeStore, ok := transactional.(initialPublicationDataScopeStore)
|
||||||
|
if !ok {
|
||||||
|
return ErrStaleCanvasPublish
|
||||||
|
}
|
||||||
|
if err := scopeStore.SetInitialPublicationDataScopeTx(ctx, tx, expectedAuthority, release.ID); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := grantStore.AddInitialGrantsTx(ctx, tx, instanceID, release.ID, ownerUserID, initialGrants); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -568,10 +602,91 @@ func (s *Service) PromotionPreview(ctx context.Context, canvasID string) (*Promo
|
|||||||
GrantGeneration: canvas.GrantGeneration,
|
GrantGeneration: canvas.GrantGeneration,
|
||||||
CurrentScope: ScopeTask,
|
CurrentScope: ScopeTask,
|
||||||
TargetScope: ScopeWorkspace,
|
TargetScope: ScopeWorkspace,
|
||||||
|
CurrentDataScope: canvas.DataScopeKind,
|
||||||
|
TargetDataScope: ScopeWorkspace,
|
||||||
Placement: manifest.WebAppPlacementWorkspace,
|
Placement: manifest.WebAppPlacementWorkspace,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// WorkspaceDataPreview describes the active release and exact permissions a
|
||||||
|
// workspace owner would enable for a retained task canvas.
|
||||||
|
func (s *Service) WorkspaceDataPreview(ctx context.Context, canvasID string) (*WorkspaceDataPreview, error) {
|
||||||
|
if err := s.ready(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
store, ok := s.instances.(authoringInstanceStore)
|
||||||
|
if !ok {
|
||||||
|
return nil, ErrCanvasNotConfigured
|
||||||
|
}
|
||||||
|
canvas, err := s.Get(ctx, canvasID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if canvas.ScopeKind != ScopeTask || canvas.Status != StatusActive || canvas.ActiveReleaseID == "" ||
|
||||||
|
canvas.ActiveReleaseStatus != ValidationValid || canvas.DataScopeKind != ScopeTask {
|
||||||
|
return nil, fmt.Errorf("%w: canvas must have an active task-only release", ErrInvalidCanvasState)
|
||||||
|
}
|
||||||
|
release, err := store.GetRelease(ctx, canvas.ActiveReleaseID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if release.InstanceID != canvas.PluginInstanceID || release.ValidationStatus != ValidationValid {
|
||||||
|
return nil, plugininstances.ErrInvalidRelease
|
||||||
|
}
|
||||||
|
m, err := manifestFromRelease(release)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &WorkspaceDataPreview{
|
||||||
|
Canvas: canvas,
|
||||||
|
Permissions: ManifestPermissions(m),
|
||||||
|
ActiveReleaseID: release.ID,
|
||||||
|
PermissionDigest: PermissionDigest(release),
|
||||||
|
GrantGeneration: canvas.GrantGeneration,
|
||||||
|
CurrentDataScopeKind: canvas.DataScopeKind,
|
||||||
|
TargetDataScopeKind: ScopeWorkspace,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// EnableWorkspaceDataReviewed changes only data scope and declared grants.
|
||||||
|
// Placement, task ownership, and the active package remain unchanged.
|
||||||
|
func (s *Service) EnableWorkspaceDataReviewed(ctx context.Context, canvasID, userID, expectedReleaseID, expectedPermissionDigest string, expectedGrantGeneration int64) (*Canvas, error) {
|
||||||
|
if err := s.ready(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(userID) == "" {
|
||||||
|
return nil, fmt.Errorf("%w: approving user is required", ErrInvalidCanvas)
|
||||||
|
}
|
||||||
|
preview, err := s.WorkspaceDataPreview(ctx, canvasID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
grants := grantsForManifest(preview.Permissions, userID, ScopeWorkspace)
|
||||||
|
transactional, ok := s.instances.(transactionalAuthoringStore)
|
||||||
|
if !ok {
|
||||||
|
return nil, ErrStaleWorkspaceDataReview
|
||||||
|
}
|
||||||
|
reviewed, ok := s.instances.(reviewedWorkspaceDataStore)
|
||||||
|
if !ok {
|
||||||
|
return nil, ErrStaleWorkspaceDataReview
|
||||||
|
}
|
||||||
|
if err := transactional.WithTransaction(ctx, func(tx *sqlx.Tx) error {
|
||||||
|
return reviewed.EnableWorkspaceDataReviewedTx(ctx, tx, preview.Canvas.PluginInstanceID, preview.Canvas.WorkspaceID, userID, grants, expectedReleaseID, expectedPermissionDigest, expectedGrantGeneration)
|
||||||
|
}); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
recordCanvasScopeTransition(canvasScopeReviewedUpgrade, canvasScopeEnabled)
|
||||||
|
updated, err := s.Get(ctx, canvasID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
s.mu.Lock()
|
||||||
|
publisher := s.publisher
|
||||||
|
s.mu.Unlock()
|
||||||
|
publishEvent(ctx, publisher, lifecycleEvent(EventWorkspaceDataEnabled, *updated))
|
||||||
|
return updated, nil
|
||||||
|
}
|
||||||
|
|
||||||
// PromoteCanvas atomically approves the active declaration and changes the
|
// PromoteCanvas atomically approves the active declaration and changes the
|
||||||
// instance scope with the canvas metadata provenance. The durable store
|
// instance scope with the canvas metadata provenance. The durable store
|
||||||
// transaction keeps both lifecycle authorities consistent after a crash.
|
// transaction keeps both lifecycle authorities consistent after a crash.
|
||||||
@@ -605,6 +720,11 @@ func (s *Service) PromoteCanvasReviewed(ctx context.Context, canvasID, userID, e
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
result := canvasScopePreserved
|
||||||
|
if preview.CurrentDataScope != ScopeWorkspace && updated.DataScopeKind == ScopeWorkspace {
|
||||||
|
result = canvasScopeExpanded
|
||||||
|
}
|
||||||
|
recordCanvasScopeTransition(canvasScopePromotion, result)
|
||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
publisher := s.publisher
|
publisher := s.publisher
|
||||||
s.mu.Unlock()
|
s.mu.Unlock()
|
||||||
@@ -675,7 +795,7 @@ func (s *Service) ApproveRelease(ctx context.Context, canvasID, releaseID, userI
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if err := store.ApproveRelease(ctx, canvas.PluginInstanceID, releaseID, userID, grantsForManifest(ManifestPermissions(m), userID, canvas.ScopeKind)); err != nil {
|
if err := store.ApproveRelease(ctx, canvas.PluginInstanceID, releaseID, userID, grantsForManifest(ManifestPermissions(m), userID, canvas.DataScopeKind)); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if err := prunePublishedReleases(ctx, store, canvas.PluginInstanceID); err != nil {
|
if err := prunePublishedReleases(ctx, store, canvas.PluginInstanceID); err != nil {
|
||||||
@@ -768,7 +888,7 @@ func (s *Service) RollbackRelease(ctx context.Context, canvasID, releaseID strin
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if !permissionsFit(ManifestPermissions(m), instance.ScopeKind, grants) {
|
if !permissionsFit(ManifestPermissions(m), instance.EffectiveDataScopeKind(), grants) {
|
||||||
return nil, fmt.Errorf("%w: rollback requires permission review", plugininstances.ErrInvalidRelease)
|
return nil, fmt.Errorf("%w: rollback requires permission review", plugininstances.ErrInvalidRelease)
|
||||||
}
|
}
|
||||||
if err := store.ActivateRelease(ctx, instance.ID, release.ID); err != nil {
|
if err := store.ActivateRelease(ctx, instance.ID, release.ID); err != nil {
|
||||||
@@ -1012,11 +1132,11 @@ func invalidLocalCanvasManifest() error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func creationAuthorityPolicyIsCurrent(authority CreationAuthority) bool {
|
func creationAuthorityPolicyIsCurrent(authority CreationAuthority) bool {
|
||||||
return authority.PolicyVersion == CreationAuthorityPolicyVersion && authority.ConsumedAt.IsZero() && authority.OwnerUserID != "" && authority.CreatingSessionID != "" && authority.TaskID != ""
|
return creationAuthorityPolicyVersionSupported(authority.PolicyVersion) && authority.ConsumedAt.IsZero() && authority.OwnerUserID != "" && authority.CreatingSessionID != "" && authority.TaskID != ""
|
||||||
}
|
}
|
||||||
|
|
||||||
func creationAuthorityInstanceMatches(authority CreationAuthority, instance plugininstances.Instance, grants []plugininstances.Grant, releases []plugininstances.Release) bool {
|
func creationAuthorityInstanceMatches(authority CreationAuthority, instance plugininstances.Instance, grants []plugininstances.Grant, releases []plugininstances.Release) bool {
|
||||||
return instance.SourceKind == plugininstances.SourceLocalCanvas && instance.ScopeKind == ScopeTask && instance.Status == StatusPending && instance.ActiveReleaseID == "" && instance.GrantGeneration == 0 && instance.TaskID == authority.TaskID && len(grants) == 0 && len(releases) == 0
|
return instance.SourceKind == plugininstances.SourceLocalCanvas && instance.ScopeKind == ScopeTask && instance.EffectiveDataScopeKind() == ScopeTask && instance.Status == StatusPending && instance.ActiveReleaseID == "" && instance.GrantGeneration == 0 && instance.TaskID == authority.TaskID && len(grants) == 0 && len(releases) == 0
|
||||||
}
|
}
|
||||||
|
|
||||||
func creationAuthoritySourceMatches(authority CreationAuthority, instance plugininstances.Instance, request PublishRequest) bool {
|
func creationAuthoritySourceMatches(authority CreationAuthority, instance plugininstances.Instance, request PublishRequest) bool {
|
||||||
|
|||||||
@@ -85,6 +85,9 @@ func TestCanvasCreationAuthorityFirstPublish(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("get instance: %v", err)
|
t.Fatalf("get instance: %v", err)
|
||||||
}
|
}
|
||||||
|
if instance.DataScopeKind != "" || instance.EffectiveDataScopeKind() != ScopeTask {
|
||||||
|
t.Fatalf("new owner-authorized canvas scope = explicit %q/effective %q, want task until its first publication is consumed", instance.DataScopeKind, instance.EffectiveDataScopeKind())
|
||||||
|
}
|
||||||
pkg := testCanvasPackage("owner-first", []string{"tasks"})
|
pkg := testCanvasPackage("owner-first", []string{"tasks"})
|
||||||
pkg.Manifest.Capabilities.APIWrite = []string{"messages"}
|
pkg.Manifest.Capabilities.APIWrite = []string{"messages"}
|
||||||
pkg.Manifest.Capabilities.Events = []string{"task.updated"}
|
pkg.Manifest.Capabilities.Events = []string{"task.updated"}
|
||||||
@@ -114,6 +117,9 @@ func TestCanvasCreationAuthorityFirstPublish(t *testing.T) {
|
|||||||
if updated.ActiveReleaseID != result.Release.ID || updated.PluginID != result.Release.PluginID {
|
if updated.ActiveReleaseID != result.Release.ID || updated.PluginID != result.Release.PluginID {
|
||||||
t.Fatalf("updated instance = %+v, want active release and package identity", updated)
|
t.Fatalf("updated instance = %+v, want active release and package identity", updated)
|
||||||
}
|
}
|
||||||
|
if updated.ScopeKind != ScopeTask || updated.EffectiveDataScopeKind() != ScopeWorkspace {
|
||||||
|
t.Fatalf("updated scopes = placement %q, effective data %q, want task and workspace", updated.ScopeKind, updated.EffectiveDataScopeKind())
|
||||||
|
}
|
||||||
grants, err := instanceStore.ListGrants(context.Background(), created.PluginInstanceID)
|
grants, err := instanceStore.ListGrants(context.Background(), created.PluginInstanceID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("list initial grants: %v", err)
|
t.Fatalf("list initial grants: %v", err)
|
||||||
@@ -136,8 +142,8 @@ func TestCanvasCreationAuthorityFirstPublish(t *testing.T) {
|
|||||||
if grant.PermissionKind == "network" {
|
if grant.PermissionKind == "network" {
|
||||||
key += grant.NetworkOrigin
|
key += grant.NetworkOrigin
|
||||||
}
|
}
|
||||||
if _, ok := wantGrants[key]; !ok || grant.ScopeCeiling != ScopeTask || grant.ApprovedBy != "owner-1" {
|
if _, ok := wantGrants[key]; !ok || grant.ScopeCeiling != ScopeWorkspace || grant.ApprovedBy != "owner-1" {
|
||||||
t.Fatalf("initial grant = %+v, want task-scoped owner grant", grant)
|
t.Fatalf("initial grant = %+v, want workspace-scoped owner grant", grant)
|
||||||
}
|
}
|
||||||
wantGrants[key] = true
|
wantGrants[key] = true
|
||||||
}
|
}
|
||||||
@@ -156,6 +162,73 @@ func TestCanvasCreationAuthorityFirstPublish(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestCanvasCreationAuthorityVersionOneRetainsTaskDataCeiling(t *testing.T) {
|
||||||
|
service, instanceStore, pool := newCanvasService(t)
|
||||||
|
created := createCanvas(t, service, CreateCanvasRequest{
|
||||||
|
WorkspaceID: "workspace-1", TaskID: "task-1", Title: "Legacy task authority",
|
||||||
|
CreatedBySessionID: "session-1", OwnerUserID: "owner-1",
|
||||||
|
})
|
||||||
|
if _, err := pool.Writer().Exec(`UPDATE canvas_creation_authority SET policy_version = 1 WHERE canvas_id = ?`, created.ID); err != nil {
|
||||||
|
t.Fatalf("set legacy authority version: %v", err)
|
||||||
|
}
|
||||||
|
instance, err := instanceStore.Get(context.Background(), created.PluginInstanceID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("get instance: %v", err)
|
||||||
|
}
|
||||||
|
result, err := service.PublishPackage(context.Background(), PublishRequest{
|
||||||
|
CanvasID: created.ID, Package: testCanvasPackage("legacy-task-authority", []string{"tasks"}),
|
||||||
|
Artifact: webapp.Artifact{Digest: "legacy-task-authority", RelativePath: "releases/legacy-task-authority", Bytes: 1},
|
||||||
|
ExpectedAuthority: instance.PublishAuthority(), SourceActorKind: "agent", SourceUserID: "owner-1",
|
||||||
|
SourceTaskID: "task-1", SourceSessionID: "session-1",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("publish with legacy authority: %v", err)
|
||||||
|
}
|
||||||
|
if !result.Activated {
|
||||||
|
t.Fatalf("legacy authority result = %+v, want active task-scoped first release", result)
|
||||||
|
}
|
||||||
|
updated, err := instanceStore.Get(context.Background(), created.PluginInstanceID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("get published instance: %v", err)
|
||||||
|
}
|
||||||
|
if updated.EffectiveDataScopeKind() != ScopeTask {
|
||||||
|
t.Fatalf("legacy authority effective data scope = %q, want task", updated.EffectiveDataScopeKind())
|
||||||
|
}
|
||||||
|
grants, err := instanceStore.ListGrants(context.Background(), created.PluginInstanceID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("list legacy initial grants: %v", err)
|
||||||
|
}
|
||||||
|
if len(grants) != 1 || grants[0].ScopeCeiling != ScopeTask {
|
||||||
|
t.Fatalf("legacy authority grants = %+v, want one task-scoped task-read grant", grants)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPublishCanvasAuthorityNormalizesLegacyNullDataScope(t *testing.T) {
|
||||||
|
service, instanceStore, _ := newCanvasService(t)
|
||||||
|
created := createCanvas(t, service, CreateCanvasRequest{WorkspaceID: "workspace-1", TaskID: "task-1", Title: "Legacy data scope"})
|
||||||
|
canvas, err := service.Get(context.Background(), created.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("get canvas: %v", err)
|
||||||
|
}
|
||||||
|
instance, err := instanceStore.Get(context.Background(), created.PluginInstanceID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("get instance: %v", err)
|
||||||
|
}
|
||||||
|
expected := instance.PublishAuthority()
|
||||||
|
expected.DataScopeKind = canvas.DataScopeKind
|
||||||
|
result, err := service.PublishPackage(context.Background(), PublishRequest{
|
||||||
|
CanvasID: created.ID, Package: testCanvasPackage("legacy-null-data-scope", []string{"tasks"}),
|
||||||
|
Artifact: webapp.Artifact{Digest: "legacy-null-data-scope", RelativePath: "releases/legacy-null-data-scope", Bytes: 1},
|
||||||
|
ExpectedAuthority: expected, SourceActorKind: "agent",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("publish with projected legacy data scope: %v", err)
|
||||||
|
}
|
||||||
|
if result.Activated || !result.PermissionRequired {
|
||||||
|
t.Fatalf("legacy task-scope publication = %+v, want task permission review", result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestCanvasCreationAuthorityRejectsMismatchedSource(t *testing.T) {
|
func TestCanvasCreationAuthorityRejectsMismatchedSource(t *testing.T) {
|
||||||
service, instanceStore, _ := newCanvasService(t)
|
service, instanceStore, _ := newCanvasService(t)
|
||||||
created := createCanvas(t, service, CreateCanvasRequest{
|
created := createCanvas(t, service, CreateCanvasRequest{
|
||||||
@@ -185,12 +258,26 @@ func TestCanvasCreationAuthorityRejectsMismatchedSource(t *testing.T) {
|
|||||||
if result.Activated || !result.PermissionRequired {
|
if result.Activated || !result.PermissionRequired {
|
||||||
t.Fatalf("mismatched owner result = %+v, want manual review", result)
|
t.Fatalf("mismatched owner result = %+v, want manual review", result)
|
||||||
}
|
}
|
||||||
|
updated, err := instanceStore.Get(context.Background(), created.PluginInstanceID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("get mismatched-owner instance: %v", err)
|
||||||
|
}
|
||||||
|
if updated.EffectiveDataScopeKind() != ScopeTask {
|
||||||
|
t.Fatalf("mismatched-owner effective data scope = %q, want task", updated.EffectiveDataScopeKind())
|
||||||
|
}
|
||||||
|
approved, err := service.ApproveRelease(context.Background(), created.ID, result.Release.ID, "member-1")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("member approves task-scoped fallback release: %v", err)
|
||||||
|
}
|
||||||
|
if approved.DataScopeKind != ScopeTask {
|
||||||
|
t.Fatalf("member approval widened effective data scope to %q, want task", approved.DataScopeKind)
|
||||||
|
}
|
||||||
grants, err := instanceStore.ListGrants(context.Background(), created.PluginInstanceID)
|
grants, err := instanceStore.ListGrants(context.Background(), created.PluginInstanceID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("list mismatched-owner grants: %v", err)
|
t.Fatalf("list fallback grants: %v", err)
|
||||||
}
|
}
|
||||||
if len(grants) != 0 {
|
if len(grants) != 1 || grants[0].ScopeCeiling != ScopeTask {
|
||||||
t.Fatalf("mismatched-owner grants = %+v, want none", grants)
|
t.Fatalf("member approval grants = %+v, want one task-scoped grant", grants)
|
||||||
}
|
}
|
||||||
authority, err := service.repo.GetCreationAuthority(context.Background(), created.ID)
|
authority, err := service.repo.GetCreationAuthority(context.Background(), created.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -252,12 +339,22 @@ func TestCanvasCreationAuthorityDoesNotApproveLaterIncrease(t *testing.T) {
|
|||||||
if second.Activated || !second.PermissionRequired {
|
if second.Activated || !second.PermissionRequired {
|
||||||
t.Fatalf("later owner release = %+v, want pending review", second)
|
t.Fatalf("later owner release = %+v, want pending review", second)
|
||||||
}
|
}
|
||||||
|
if _, err := service.ApproveRelease(context.Background(), created.ID, second.Release.ID, "member-1"); !errors.Is(err, plugininstances.ErrWorkspaceOwnerRequired) {
|
||||||
|
t.Fatalf("non-owner approval of workspace-data increase = %v, want ErrWorkspaceOwnerRequired", err)
|
||||||
|
}
|
||||||
|
approved, err := service.ApproveRelease(context.Background(), created.ID, second.Release.ID, "owner-1")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("owner approves workspace-data increase: %v", err)
|
||||||
|
}
|
||||||
|
if approved.ActiveReleaseID != second.Release.ID {
|
||||||
|
t.Fatalf("active release after owner approval = %q, want %q", approved.ActiveReleaseID, second.Release.ID)
|
||||||
|
}
|
||||||
current, err := instanceStore.Get(context.Background(), created.PluginInstanceID)
|
current, err := instanceStore.Get(context.Background(), created.PluginInstanceID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("get current instance: %v", err)
|
t.Fatalf("get current instance: %v", err)
|
||||||
}
|
}
|
||||||
if current.ActiveReleaseID != first.Release.ID {
|
if current.ActiveReleaseID != second.Release.ID {
|
||||||
t.Fatalf("active release after later increase = %q, want %q", current.ActiveReleaseID, first.Release.ID)
|
t.Fatalf("active release after owner-approved increase = %q, want %q", current.ActiveReleaseID, second.Release.ID)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -82,7 +82,7 @@ func effectiveGrantProjection(instance plugininstances.Instance, summary Permiss
|
|||||||
declared := permissionKeys(summary)
|
declared := permissionKeys(summary)
|
||||||
result := make([]GrantProjection, 0, len(grants))
|
result := make([]GrantProjection, 0, len(grants))
|
||||||
for _, grant := range grants {
|
for _, grant := range grants {
|
||||||
if !grantScopeCovers(grant.ScopeCeiling, instance.ScopeKind) {
|
if !grantScopeCovers(grant.ScopeCeiling, instance.EffectiveDataScopeKind()) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
permission := grant.PermissionKind + ":" + grant.Resource
|
permission := grant.PermissionKind + ":" + grant.Resource
|
||||||
|
|||||||
@@ -304,7 +304,7 @@ WHERE a.canvas_id = ?`,
|
|||||||
// while rechecking its owner, session, task, policy, task scope, and current
|
// while rechecking its owner, session, task, policy, task scope, and current
|
||||||
// workspace ownership.
|
// workspace ownership.
|
||||||
func (r *Repository) ConsumeCreationAuthorityTx(ctx context.Context, tx *sqlx.Tx, authority CreationAuthority, ownerUserID, sessionID, taskID string, allowUnownedWorkspace bool) error {
|
func (r *Repository) ConsumeCreationAuthorityTx(ctx context.Context, tx *sqlx.Tx, authority CreationAuthority, ownerUserID, sessionID, taskID string, allowUnownedWorkspace bool) error {
|
||||||
if authority.CanvasID == "" || ownerUserID == "" || sessionID == "" || taskID == "" {
|
if authority.CanvasID == "" || !creationAuthorityPolicyVersionSupported(authority.PolicyVersion) || ownerUserID == "" || sessionID == "" || taskID == "" {
|
||||||
return ErrStaleCanvasPublish
|
return ErrStaleCanvasPublish
|
||||||
}
|
}
|
||||||
now := time.Now().UTC().Format(time.RFC3339Nano)
|
now := time.Now().UTC().Format(time.RFC3339Nano)
|
||||||
@@ -321,7 +321,7 @@ WHERE canvas_creation_authority.canvas_id = ? AND owner_user_id = ? AND creating
|
|||||||
AND m.task_id = ?
|
AND m.task_id = ?
|
||||||
AND (w.owner_id = ? OR (? AND COALESCE(w.owner_id, '') = ''))
|
AND (w.owner_id = ? OR (? AND COALESCE(w.owner_id, '') = ''))
|
||||||
)`,
|
)`,
|
||||||
), now, authority.CanvasID, ownerUserID, sessionID, CreationAuthorityPolicyVersion, taskID, ownerUserID, allowUnownedWorkspace)
|
), now, authority.CanvasID, ownerUserID, sessionID, authority.PolicyVersion, taskID, ownerUserID, allowUnownedWorkspace)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
package canvas
|
||||||
|
|
||||||
|
import "expvar"
|
||||||
|
|
||||||
|
var canvasDataScopeTransitions = expvar.NewMap("canvas_data_scope_transition_total")
|
||||||
|
|
||||||
|
type canvasScopeTransition string
|
||||||
|
type canvasScopeTransitionResult string
|
||||||
|
|
||||||
|
const (
|
||||||
|
canvasScopeFirstPublication canvasScopeTransition = "first_publication"
|
||||||
|
canvasScopeReviewedUpgrade canvasScopeTransition = "reviewed_upgrade"
|
||||||
|
canvasScopePromotion canvasScopeTransition = "promotion"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
canvasScopeEnabled canvasScopeTransitionResult = "data_scope_enabled"
|
||||||
|
canvasScopeExpanded canvasScopeTransitionResult = "data_scope_expanded"
|
||||||
|
canvasScopePreserved canvasScopeTransitionResult = "data_scope_preserved"
|
||||||
|
)
|
||||||
|
|
||||||
|
func recordCanvasScopeTransition(transition canvasScopeTransition, result canvasScopeTransitionResult) {
|
||||||
|
canvasDataScopeTransitions.Add("transition="+string(transition)+";result="+string(result), 1)
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
package canvas
|
||||||
|
|
||||||
|
import (
|
||||||
|
"expvar"
|
||||||
|
"strconv"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func canvasScopeMetricCount(t *testing.T, label string) int64 {
|
||||||
|
t.Helper()
|
||||||
|
value := expvar.Get("canvas_data_scope_transition_total")
|
||||||
|
if value == nil {
|
||||||
|
t.Fatal("canvas data-scope transition counter is not registered")
|
||||||
|
}
|
||||||
|
metrics, ok := value.(*expvar.Map)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("canvas data-scope transition metric has type %T, want *expvar.Map", value)
|
||||||
|
}
|
||||||
|
var count int64
|
||||||
|
metrics.Do(func(keyValue expvar.KeyValue) {
|
||||||
|
if keyValue.Key != label {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
parsed, err := strconv.ParseInt(keyValue.Value.String(), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("canvas data-scope metric %q value = %q: %v", label, keyValue.Value.String(), err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
count = parsed
|
||||||
|
})
|
||||||
|
return count
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRecordCanvasScopeTransitionUsesFixedLabels(t *testing.T) {
|
||||||
|
label := "transition=reviewed_upgrade;result=data_scope_enabled"
|
||||||
|
before := canvasScopeMetricCount(t, label)
|
||||||
|
recordCanvasScopeTransition(canvasScopeReviewedUpgrade, canvasScopeEnabled)
|
||||||
|
after := canvasScopeMetricCount(t, label)
|
||||||
|
if after-before != 1 {
|
||||||
|
t.Fatalf("transition counter delta = %d, want 1", after-before)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -737,7 +737,7 @@ func (s *Service) buildCanvas(ctx context.Context, metadata CanvasMetadata, inst
|
|||||||
return Canvas{}, err
|
return Canvas{}, err
|
||||||
}
|
}
|
||||||
if instance.ActiveReleaseID == "" {
|
if instance.ActiveReleaseID == "" {
|
||||||
return s.addPendingRelease(ctx, &canvas, instance.ID, instance.ScopeKind, grants)
|
return s.addPendingRelease(ctx, &canvas, instance.ID, instance.EffectiveDataScopeKind(), grants)
|
||||||
}
|
}
|
||||||
release, err := s.instances.GetRelease(ctx, instance.ActiveReleaseID)
|
release, err := s.instances.GetRelease(ctx, instance.ActiveReleaseID)
|
||||||
if errors.Is(err, plugininstances.ErrNotFound) {
|
if errors.Is(err, plugininstances.ErrNotFound) {
|
||||||
@@ -755,7 +755,7 @@ func (s *Service) buildCanvas(ctx context.Context, metadata CanvasMetadata, inst
|
|||||||
canvas.ActiveReleaseStatus = release.ValidationStatus
|
canvas.ActiveReleaseStatus = release.ValidationStatus
|
||||||
canvas.ActiveReleaseError = release.ValidationError
|
canvas.ActiveReleaseError = release.ValidationError
|
||||||
canvas.EffectiveGrants = effectiveGrantProjection(instance, ReleasePermissionSummary(release), grants)
|
canvas.EffectiveGrants = effectiveGrantProjection(instance, ReleasePermissionSummary(release), grants)
|
||||||
canvas.ActiveRelease = releaseMetadata(release, instance.ScopeKind, grants)
|
canvas.ActiveRelease = releaseMetadata(release, instance.EffectiveDataScopeKind(), grants)
|
||||||
return canvas, nil
|
return canvas, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -866,6 +866,7 @@ func canvasFromMetadataInstance(metadata CanvasMetadata, instance plugininstance
|
|||||||
TaskID: metadata.TaskID,
|
TaskID: metadata.TaskID,
|
||||||
OriginTaskID: metadata.OriginTaskID,
|
OriginTaskID: metadata.OriginTaskID,
|
||||||
ScopeKind: instance.ScopeKind,
|
ScopeKind: instance.ScopeKind,
|
||||||
|
DataScopeKind: instance.EffectiveDataScopeKind(),
|
||||||
Title: metadata.Title,
|
Title: metadata.Title,
|
||||||
CreatedBySessionID: metadata.CreatedBySessionID,
|
CreatedBySessionID: metadata.CreatedBySessionID,
|
||||||
PromotedByUserID: metadata.PromotedByUserID,
|
PromotedByUserID: metadata.PromotedByUserID,
|
||||||
@@ -896,6 +897,7 @@ func lifecycleEvent(eventType string, canvas Canvas) LifecycleEvent {
|
|||||||
WorkspaceID: canvas.WorkspaceID,
|
WorkspaceID: canvas.WorkspaceID,
|
||||||
TaskID: canvas.TaskID,
|
TaskID: canvas.TaskID,
|
||||||
ScopeKind: canvas.ScopeKind,
|
ScopeKind: canvas.ScopeKind,
|
||||||
|
DataScopeKind: canvas.DataScopeKind,
|
||||||
Status: canvas.Status,
|
Status: canvas.Status,
|
||||||
ActiveReleaseID: canvas.ActiveReleaseID,
|
ActiveReleaseID: canvas.ActiveReleaseID,
|
||||||
ActiveReleaseStatus: canvas.ActiveReleaseStatus,
|
ActiveReleaseStatus: canvas.ActiveReleaseStatus,
|
||||||
|
|||||||
@@ -16,14 +16,26 @@ import (
|
|||||||
const (
|
const (
|
||||||
// CanvasPluginID identifies the synthetic plugin binding used by local
|
// CanvasPluginID identifies the synthetic plugin binding used by local
|
||||||
// canvas instances. The package identity remains in the release metadata.
|
// canvas instances. The package identity remains in the release metadata.
|
||||||
CanvasPluginID = "kandev.canvas"
|
CanvasPluginID = "kandev.canvas"
|
||||||
MaxTitleLength = 200
|
MaxTitleLength = 200
|
||||||
CreationAuthorityPolicyVersion = 1
|
creationAuthorityTaskPolicyVersion = 1
|
||||||
|
CreationAuthorityPolicyVersion = 2
|
||||||
|
|
||||||
MaxTaskCanvases = plugininstances.MaxTaskInstances
|
MaxTaskCanvases = plugininstances.MaxTaskInstances
|
||||||
MaxWorkspaceCanvases = plugininstances.MaxWorkspaceInstances
|
MaxWorkspaceCanvases = plugininstances.MaxWorkspaceInstances
|
||||||
)
|
)
|
||||||
|
|
||||||
|
func creationAuthorityPolicyVersionSupported(version int) bool {
|
||||||
|
return version == creationAuthorityTaskPolicyVersion || version == CreationAuthorityPolicyVersion
|
||||||
|
}
|
||||||
|
|
||||||
|
func creationAuthorityDataScope(authority CreationAuthority) string {
|
||||||
|
if authority.PolicyVersion == CreationAuthorityPolicyVersion {
|
||||||
|
return ScopeWorkspace
|
||||||
|
}
|
||||||
|
return ScopeTask
|
||||||
|
}
|
||||||
|
|
||||||
const (
|
const (
|
||||||
ScopeTask = plugininstances.ScopeTask
|
ScopeTask = plugininstances.ScopeTask
|
||||||
ScopeWorkspace = plugininstances.ScopeWorkspace
|
ScopeWorkspace = plugininstances.ScopeWorkspace
|
||||||
@@ -55,15 +67,16 @@ const (
|
|||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
ErrCanvasNotFound = errors.New("canvas not found")
|
ErrCanvasNotFound = errors.New("canvas not found")
|
||||||
ErrInvalidCanvas = errors.New("invalid canvas")
|
ErrInvalidCanvas = errors.New("invalid canvas")
|
||||||
ErrInvalidCanvasState = errors.New("invalid canvas state")
|
ErrInvalidCanvasState = errors.New("invalid canvas state")
|
||||||
ErrCanvasMetadataBroken = errors.New("canvas metadata is inconsistent with its plugin instance")
|
ErrCanvasMetadataBroken = errors.New("canvas metadata is inconsistent with its plugin instance")
|
||||||
ErrCanvasNotConfigured = errors.New("canvas lifecycle is not configured")
|
ErrCanvasNotConfigured = errors.New("canvas lifecycle is not configured")
|
||||||
ErrStalePromotionReview = plugininstances.ErrStalePromotionReview
|
ErrStalePromotionReview = plugininstances.ErrStalePromotionReview
|
||||||
ErrStaleCanvasEdit = plugininstances.ErrStaleCanvasEdit
|
ErrStaleWorkspaceDataReview = plugininstances.ErrStaleWorkspaceDataReview
|
||||||
ErrStaleCanvasPublish = plugininstances.ErrStaleCanvasPublish
|
ErrStaleCanvasEdit = plugininstances.ErrStaleCanvasEdit
|
||||||
ErrInvalidLifecycleState = plugininstances.ErrInvalidLifecycleState
|
ErrStaleCanvasPublish = plugininstances.ErrStaleCanvasPublish
|
||||||
|
ErrInvalidLifecycleState = plugininstances.ErrInvalidLifecycleState
|
||||||
|
|
||||||
// These aliases preserve the stable plugin admission errors while keeping
|
// These aliases preserve the stable plugin admission errors while keeping
|
||||||
// the canvas package convenient for service callers and API adapters.
|
// the canvas package convenient for service callers and API adapters.
|
||||||
@@ -109,6 +122,7 @@ type Canvas struct {
|
|||||||
TaskID string `json:"task_id,omitempty"`
|
TaskID string `json:"task_id,omitempty"`
|
||||||
OriginTaskID string `json:"origin_task_id,omitempty"`
|
OriginTaskID string `json:"origin_task_id,omitempty"`
|
||||||
ScopeKind string `json:"scope_kind"`
|
ScopeKind string `json:"scope_kind"`
|
||||||
|
DataScopeKind string `json:"data_scope_kind"`
|
||||||
Title string `json:"title"`
|
Title string `json:"title"`
|
||||||
CreatedBySessionID string `json:"created_by_session_id,omitempty"`
|
CreatedBySessionID string `json:"created_by_session_id,omitempty"`
|
||||||
PromotedByUserID string `json:"promoted_by_user_id,omitempty"`
|
PromotedByUserID string `json:"promoted_by_user_id,omitempty"`
|
||||||
@@ -215,6 +229,7 @@ type LifecycleEvent struct {
|
|||||||
WorkspaceID string `json:"workspace_id"`
|
WorkspaceID string `json:"workspace_id"`
|
||||||
TaskID string `json:"task_id,omitempty"`
|
TaskID string `json:"task_id,omitempty"`
|
||||||
ScopeKind string `json:"scope_kind"`
|
ScopeKind string `json:"scope_kind"`
|
||||||
|
DataScopeKind string `json:"data_scope_kind"`
|
||||||
Status string `json:"status"`
|
Status string `json:"status"`
|
||||||
ActiveReleaseID string `json:"active_release_id,omitempty"`
|
ActiveReleaseID string `json:"active_release_id,omitempty"`
|
||||||
ActiveReleaseStatus string `json:"active_release_status,omitempty"`
|
ActiveReleaseStatus string `json:"active_release_status,omitempty"`
|
||||||
@@ -226,6 +241,7 @@ const (
|
|||||||
EventReleaseActivated = "canvas.release.activated"
|
EventReleaseActivated = "canvas.release.activated"
|
||||||
EventReleasePermissionRequired = "canvas.release.permission_required"
|
EventReleasePermissionRequired = "canvas.release.permission_required"
|
||||||
EventPromoted = "canvas.promoted"
|
EventPromoted = "canvas.promoted"
|
||||||
|
EventWorkspaceDataEnabled = "canvas.workspace_data_enabled"
|
||||||
EventArchived = "canvas.archived"
|
EventArchived = "canvas.archived"
|
||||||
EventRestored = "canvas.restored"
|
EventRestored = "canvas.restored"
|
||||||
EventRemoved = "canvas.removed"
|
EventRemoved = "canvas.removed"
|
||||||
|
|||||||
@@ -0,0 +1,143 @@
|
|||||||
|
package canvas
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/kandev/kandev/internal/plugins/instances"
|
||||||
|
"github.com/kandev/kandev/internal/plugins/webapp"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestFirstPublicationRecordsWorkspaceDataTransition(t *testing.T) {
|
||||||
|
service, instanceStore, _ := newCanvasService(t)
|
||||||
|
created := createCanvas(t, service, CreateCanvasRequest{
|
||||||
|
WorkspaceID: "workspace-1", TaskID: "task-1", Title: "Owner-authorized canvas",
|
||||||
|
CreatedBySessionID: "session-1", OwnerUserID: "owner-1",
|
||||||
|
})
|
||||||
|
instance, err := instanceStore.Get(context.Background(), created.PluginInstanceID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("get instance: %v", err)
|
||||||
|
}
|
||||||
|
metric := "transition=first_publication;result=data_scope_enabled"
|
||||||
|
before := canvasScopeMetricCount(t, metric)
|
||||||
|
result, err := service.PublishPackage(context.Background(), PublishRequest{
|
||||||
|
CanvasID: created.ID,
|
||||||
|
Package: testCanvasPackage("workspace-metric", []string{"tasks"}),
|
||||||
|
Artifact: webapp.Artifact{Digest: "workspace-metric", RelativePath: "releases/workspace-metric", Bytes: 1},
|
||||||
|
ExpectedAuthority: instance.PublishAuthority(),
|
||||||
|
SourceActorKind: "agent",
|
||||||
|
SourceUserID: "owner-1",
|
||||||
|
SourceTaskID: "task-1",
|
||||||
|
SourceSessionID: "session-1",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("publish owner-authorized canvas: %v", err)
|
||||||
|
}
|
||||||
|
if !result.Activated {
|
||||||
|
t.Fatalf("publish result = %+v, want activated first release", result)
|
||||||
|
}
|
||||||
|
if after := canvasScopeMetricCount(t, metric); after-before != 1 {
|
||||||
|
t.Fatalf("first-publication scope metric delta = %d, want 1", after-before)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWorkspaceDataUpgradePreservesPlacementAndRequiresFreshOwnerReview(t *testing.T) {
|
||||||
|
service, instanceStore, _ := newCanvasService(t)
|
||||||
|
canvas := createCanvas(t, service, CreateCanvasRequest{
|
||||||
|
WorkspaceID: "workspace-1", TaskID: "task-1", Title: "Legacy canvas",
|
||||||
|
})
|
||||||
|
published := publishTestPackage(t, service, canvas.ID, "legacy-workspace-review", []string{"tasks"})
|
||||||
|
if _, err := service.ApproveRelease(context.Background(), canvas.ID, published.Release.ID, "owner-1"); err != nil {
|
||||||
|
t.Fatalf("approve legacy task release: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
preview, err := service.WorkspaceDataPreview(context.Background(), canvas.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("workspace data preview: %v", err)
|
||||||
|
}
|
||||||
|
if preview.CurrentDataScopeKind != instances.ScopeTask || preview.TargetDataScopeKind != instances.ScopeWorkspace ||
|
||||||
|
preview.ActiveReleaseID != published.Release.ID || preview.Permissions.Reads[0] != "tasks" {
|
||||||
|
t.Fatalf("workspace data preview = %+v, want task-to-workspace review of the active task read", preview)
|
||||||
|
}
|
||||||
|
if err := instanceStore.AddGrant(context.Background(), instances.Grant{
|
||||||
|
InstanceID: canvas.PluginInstanceID, PermissionKind: "api_read", Resource: "tasks",
|
||||||
|
ScopeCeiling: instances.ScopeTask, ApprovedBy: "owner-1",
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("advance grant generation: %v", err)
|
||||||
|
}
|
||||||
|
if err := instanceStore.AddGrant(context.Background(), instances.Grant{
|
||||||
|
InstanceID: canvas.PluginInstanceID, PermissionKind: "api_write", Resource: "messages",
|
||||||
|
ScopeCeiling: instances.ScopeTask, ApprovedBy: "owner-1",
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("seed stale task-ceiling grant: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := service.EnableWorkspaceDataReviewed(context.Background(), canvas.ID, "owner-1", preview.ActiveReleaseID, preview.PermissionDigest, preview.GrantGeneration); !errors.Is(err, instances.ErrStaleWorkspaceDataReview) {
|
||||||
|
t.Fatalf("stale workspace data confirmation = %v, want ErrStaleWorkspaceDataReview", err)
|
||||||
|
}
|
||||||
|
unchanged, err := instanceStore.Get(context.Background(), canvas.PluginInstanceID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("get unchanged instance: %v", err)
|
||||||
|
}
|
||||||
|
if unchanged.ScopeKind != ScopeTask || unchanged.EffectiveDataScopeKind() != instances.ScopeTask {
|
||||||
|
t.Fatalf("stale review changed scopes to placement %q/data %q", unchanged.ScopeKind, unchanged.EffectiveDataScopeKind())
|
||||||
|
}
|
||||||
|
|
||||||
|
preview, err = service.WorkspaceDataPreview(context.Background(), canvas.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("refresh workspace data preview: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := service.EnableWorkspaceDataReviewed(context.Background(), canvas.ID, "owner-2", preview.ActiveReleaseID, preview.PermissionDigest, preview.GrantGeneration); !errors.Is(err, instances.ErrWorkspaceOwnerRequired) {
|
||||||
|
t.Fatalf("foreign owner confirmation = %v, want ErrWorkspaceOwnerRequired", err)
|
||||||
|
}
|
||||||
|
metric := "transition=reviewed_upgrade;result=data_scope_enabled"
|
||||||
|
before := canvasScopeMetricCount(t, metric)
|
||||||
|
upgraded, err := service.EnableWorkspaceDataReviewed(context.Background(), canvas.ID, "owner-1", preview.ActiveReleaseID, preview.PermissionDigest, preview.GrantGeneration)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("owner enables workspace data: %v", err)
|
||||||
|
}
|
||||||
|
if after := canvasScopeMetricCount(t, metric); after-before != 1 {
|
||||||
|
t.Fatalf("reviewed-upgrade scope metric delta = %d, want 1", after-before)
|
||||||
|
}
|
||||||
|
if upgraded.ScopeKind != ScopeTask || upgraded.DataScopeKind != instances.ScopeWorkspace ||
|
||||||
|
upgraded.TaskID != "task-1" || upgraded.ActiveReleaseID != published.Release.ID || upgraded.PromotedAt != nil {
|
||||||
|
t.Fatalf("upgraded canvas = %+v, want unchanged task placement and active release", upgraded)
|
||||||
|
}
|
||||||
|
grants, err := instanceStore.ListGrants(context.Background(), canvas.PluginInstanceID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("list upgraded grants: %v", err)
|
||||||
|
}
|
||||||
|
if len(grants) != 1 || grants[0].PermissionKind != "api_read" || grants[0].Resource != "tasks" || grants[0].ScopeCeiling != instances.ScopeWorkspace || grants[0].ApprovedBy != "owner-1" {
|
||||||
|
t.Fatalf("upgraded grants = %+v, want one owner-approved workspace task grant", grants)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPromotionRecordsExpandedDataScope(t *testing.T) {
|
||||||
|
service, _, _ := newCanvasService(t)
|
||||||
|
created := createCanvas(t, service, CreateCanvasRequest{
|
||||||
|
WorkspaceID: "workspace-1", TaskID: "task-1", Title: "Promoted canvas",
|
||||||
|
})
|
||||||
|
published := publishTestPackage(t, service, created.ID, "promotion-scope-metric", []string{"tasks"})
|
||||||
|
if _, err := service.ApproveRelease(context.Background(), created.ID, published.Release.ID, "owner-1"); err != nil {
|
||||||
|
t.Fatalf("approve task-scoped release: %v", err)
|
||||||
|
}
|
||||||
|
preview, err := service.PromotionPreview(context.Background(), created.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("promotion preview: %v", err)
|
||||||
|
}
|
||||||
|
metric := "transition=promotion;result=data_scope_expanded"
|
||||||
|
before := canvasScopeMetricCount(t, metric)
|
||||||
|
promoted, err := service.PromoteCanvasReviewed(
|
||||||
|
context.Background(), created.ID, "owner-1", preview.ActiveReleaseID,
|
||||||
|
preview.PermissionDigest, preview.GrantGeneration,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("promote task-scoped canvas: %v", err)
|
||||||
|
}
|
||||||
|
if promoted.ScopeKind != ScopeWorkspace || promoted.DataScopeKind != instances.ScopeWorkspace {
|
||||||
|
t.Fatalf("promoted scopes = placement %q, data %q, want workspace/workspace", promoted.ScopeKind, promoted.DataScopeKind)
|
||||||
|
}
|
||||||
|
if after := canvasScopeMetricCount(t, metric); after-before != 1 {
|
||||||
|
t.Fatalf("promotion scope metric delta = %d, want 1", after-before)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -41,6 +41,7 @@ const (
|
|||||||
CanvasReleaseActivated = "canvas.release.activated"
|
CanvasReleaseActivated = "canvas.release.activated"
|
||||||
CanvasReleasePermissionRequired = "canvas.release.permission_required"
|
CanvasReleasePermissionRequired = "canvas.release.permission_required"
|
||||||
CanvasPromoted = "canvas.promoted"
|
CanvasPromoted = "canvas.promoted"
|
||||||
|
CanvasWorkspaceDataEnabled = "canvas.workspace_data_enabled"
|
||||||
CanvasArchived = "canvas.archived"
|
CanvasArchived = "canvas.archived"
|
||||||
CanvasRestored = "canvas.restored"
|
CanvasRestored = "canvas.restored"
|
||||||
CanvasRemoved = "canvas.removed"
|
CanvasRemoved = "canvas.removed"
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ func RegisterCanvasNotifications(ctx context.Context, eventBus bus.EventBus, hub
|
|||||||
{events.CanvasReleaseActivated, ws.ActionCanvasReleaseActivated},
|
{events.CanvasReleaseActivated, ws.ActionCanvasReleaseActivated},
|
||||||
{events.CanvasReleasePermissionRequired, ws.ActionCanvasReleasePermissionRequired},
|
{events.CanvasReleasePermissionRequired, ws.ActionCanvasReleasePermissionRequired},
|
||||||
{events.CanvasPromoted, ws.ActionCanvasPromoted},
|
{events.CanvasPromoted, ws.ActionCanvasPromoted},
|
||||||
|
{events.CanvasWorkspaceDataEnabled, ws.ActionCanvasWorkspaceDataEnabled},
|
||||||
{events.CanvasArchived, ws.ActionCanvasArchived},
|
{events.CanvasArchived, ws.ActionCanvasArchived},
|
||||||
{events.CanvasRestored, ws.ActionCanvasRestored},
|
{events.CanvasRestored, ws.ActionCanvasRestored},
|
||||||
{events.CanvasRemoved, ws.ActionCanvasRemoved},
|
{events.CanvasRemoved, ws.ActionCanvasRemoved},
|
||||||
|
|||||||
@@ -27,12 +27,13 @@ core bundle again during the same authoring task.
|
|||||||
ID and source path. Read validation diagnostics and correct rejected source
|
ID and source path. Read validation diagnostics and correct rejected source
|
||||||
before publishing again.
|
before publishing again.
|
||||||
|
|
||||||
The first valid release of a new owner-created task canvas uses the returned
|
The first valid release of a new owner-authorized task canvas uses the returned
|
||||||
initial permission policy. It can activate without a second approval for its
|
initial permission policy. It can activate without a second approval for its
|
||||||
declared supported task-scoped data, event, state, and exact HTTPS-origin
|
declared supported permissions. Kandev data access is limited to the current
|
||||||
permissions. A later permission increase, imported package, or workspace
|
workspace, while the canvas remains placed in its creating task. Imported
|
||||||
promotion still requires human review. Do not add a trust flag to the
|
packages and later permission increases need human review. Promotion changes
|
||||||
manifest, and do not request permissions outside the policy.
|
workspace navigation. Do not add a trust flag to the manifest, and do not
|
||||||
|
request permissions outside the policy.
|
||||||
|
|
||||||
## Core application contract
|
## Core application contract
|
||||||
|
|
||||||
@@ -66,7 +67,8 @@ Use the returned `manifest_scaffold` as the starting point. New manifests use
|
|||||||
least one `task-canvas` or `workspace-canvas` placement. Declare only the
|
least one `task-canvas` or `workspace-canvas` placement. Declare only the
|
||||||
`api_read`, `api_write`, `events`, `state`, and `network_origins` permissions
|
`api_read`, `api_write`, `events`, `state`, and `network_origins` permissions
|
||||||
that the application needs. The owner-authorized first release can receive
|
that the application needs. The owner-authorized first release can receive
|
||||||
only these supported task-scoped grants. The entry and all relative assets
|
only its declared supported permissions, with Kandev data limited to the
|
||||||
|
current workspace. The entry and all relative assets
|
||||||
must be in the published package.
|
must be in the published package.
|
||||||
|
|
||||||
## Browser protocol summary
|
## Browser protocol summary
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ show a retry state for transient reads.
|
|||||||
"web_app_key": "main",
|
"web_app_key": "main",
|
||||||
"placement": "task-canvas",
|
"placement": "task-canvas",
|
||||||
"scope_kind": "task",
|
"scope_kind": "task",
|
||||||
|
"data_scope_kind": "workspace",
|
||||||
"workspace_id": "workspace-id",
|
"workspace_id": "workspace-id",
|
||||||
"task_id": "task-id",
|
"task_id": "task-id",
|
||||||
"session_id": "session-id",
|
"session_id": "session-id",
|
||||||
@@ -34,7 +35,9 @@ show a retry state for transient reads.
|
|||||||
|
|
||||||
Scope identifiers are omitted when they do not apply. `capabilities` contains
|
Scope identifiers are omitted when they do not apply. `capabilities` contains
|
||||||
the effective, approved permission keys. It is not a replacement for handling
|
the effective, approved permission keys. It is not a replacement for handling
|
||||||
permission errors from later requests.
|
permission errors from later requests. `scope_kind` describes canvas placement
|
||||||
|
and lifecycle. `data_scope_kind` describes the Kandev data boundary. A task
|
||||||
|
canvas can have `scope_kind: "task"` and `data_scope_kind: "workspace"`.
|
||||||
|
|
||||||
## Data routes
|
## Data routes
|
||||||
|
|
||||||
@@ -51,9 +54,11 @@ All data responses use JSON. Collection responses use this envelope:
|
|||||||
and accepts any value from 1 to 200. Unlike the gRPC Host API, this surface
|
and accepts any value from 1 to 200. Unlike the gRPC Host API, this surface
|
||||||
does not clamp an out-of-range `limit` into that window: a supplied value
|
does not clamp an out-of-range `limit` into that window: a supplied value
|
||||||
outside 1..200 is rejected outright with HTTP 400 `invalid_request`, and only
|
outside 1..200 is rejected outright with HTTP 400 `invalid_request`, and only
|
||||||
an omitted `limit` falls back to the 50-row default. A task-scoped canvas is
|
an omitted `limit` falls back to the 50-row default. Task data scope returns
|
||||||
restricted to its task. A workspace-scoped canvas is restricted to its
|
only the bound task. Workspace data scope returns tasks from the instance's
|
||||||
workspace.
|
trusted workspace, including when the canvas is still placed in one task. A
|
||||||
|
`workspace_id` filter must match that workspace; another workspace is denied.
|
||||||
|
Follow `page_info.next_cursor` until it is omitted to load every page.
|
||||||
|
|
||||||
| Method | Route | Permission | Use |
|
| Method | Route | Permission | Use |
|
||||||
| --- | --- | --- | --- |
|
| --- | --- | --- | --- |
|
||||||
|
|||||||
@@ -67,10 +67,11 @@ the old direct requests cannot continue under the old binding.
|
|||||||
|
|
||||||
Capabilities are declarations, not grants. Use `api_read` for `tasks` and
|
Capabilities are declarations, not grants. Use `api_read` for `tasks` and
|
||||||
`workflows`, `api_write` for `tasks` and `messages`, `events` for event
|
`workflows`, `api_write` for `tasks` and `messages`, `events` for event
|
||||||
subscriptions, and `state: true` for instance state. The owner-authorized
|
subscriptions, and `state: true` for instance state. The first owner-authorized
|
||||||
first release can receive only these supported task-scoped grants. The
|
release can receive only its declared supported permissions, with Kandev data
|
||||||
operator reviews a later permission increase, an imported package, or a
|
limited to the current workspace. The canvas remains placed in its creating
|
||||||
workspace promotion. Request only the capabilities used by the application.
|
task until promotion. Imported packages and later permission increases need
|
||||||
|
human review. Request only the capabilities used by the application.
|
||||||
|
|
||||||
The package must include the declared entry document and every local asset it
|
The package must include the declared entry document and every local asset it
|
||||||
references. Bundle executable dependencies. A build tool, package manager, or
|
references. Bundle executable dependencies. A build tool, package manager, or
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import (
|
|||||||
"github.com/jmoiron/sqlx"
|
"github.com/jmoiron/sqlx"
|
||||||
|
|
||||||
"github.com/kandev/kandev/internal/db"
|
"github.com/kandev/kandev/internal/db"
|
||||||
|
userstore "github.com/kandev/kandev/internal/user/store"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -66,6 +67,8 @@ var (
|
|||||||
ErrInstallationStorageLimit = errors.New("canvas installation storage limit exceeded")
|
ErrInstallationStorageLimit = errors.New("canvas installation storage limit exceeded")
|
||||||
ErrInvalidRelease = errors.New("invalid plugin release")
|
ErrInvalidRelease = errors.New("invalid plugin release")
|
||||||
ErrStalePromotionReview = errors.New("canvas promotion review is stale")
|
ErrStalePromotionReview = errors.New("canvas promotion review is stale")
|
||||||
|
ErrStaleWorkspaceDataReview = errors.New("canvas workspace data review is stale")
|
||||||
|
ErrWorkspaceOwnerRequired = errors.New("current workspace owner is required")
|
||||||
ErrStaleCanvasEdit = errors.New("canvas edit base release is stale")
|
ErrStaleCanvasEdit = errors.New("canvas edit base release is stale")
|
||||||
ErrStaleCanvasPublish = errors.New("canvas publish authority is stale")
|
ErrStaleCanvasPublish = errors.New("canvas publish authority is stale")
|
||||||
ErrInvalidLifecycleState = errors.New("plugin instance lifecycle state does not allow this operation")
|
ErrInvalidLifecycleState = errors.New("plugin instance lifecycle state does not allow this operation")
|
||||||
@@ -100,6 +103,16 @@ func ValidateScope(kind string, ids ScopeIdentifiers) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func validateDataScope(sourceKind, placementScope, dataScope string) error {
|
||||||
|
if dataScope == "" || dataScope == placementScope {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if sourceKind == SourceLocalCanvas && placementScope == ScopeTask && dataScope == ScopeWorkspace {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return ErrInvalidScope
|
||||||
|
}
|
||||||
|
|
||||||
func scopeIdentifiersFor(kind string) map[string]bool {
|
func scopeIdentifiersFor(kind string) map[string]bool {
|
||||||
switch kind {
|
switch kind {
|
||||||
case ScopeInstance:
|
case ScopeInstance:
|
||||||
@@ -122,6 +135,7 @@ type Instance struct {
|
|||||||
PluginID string
|
PluginID string
|
||||||
SourceKind string
|
SourceKind string
|
||||||
ScopeKind string
|
ScopeKind string
|
||||||
|
DataScopeKind string
|
||||||
WorkspaceID string
|
WorkspaceID string
|
||||||
TaskID string
|
TaskID string
|
||||||
SessionID string
|
SessionID string
|
||||||
@@ -133,6 +147,21 @@ type Instance struct {
|
|||||||
UpdatedAt time.Time
|
UpdatedAt time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// EffectiveDataScopeKind preserves the placement-bound behavior of stored
|
||||||
|
// instances whose nullable data scope predates the independent scope field.
|
||||||
|
func (i Instance) EffectiveDataScopeKind() string {
|
||||||
|
return EffectiveDataScopeKind(i.ScopeKind, i.DataScopeKind)
|
||||||
|
}
|
||||||
|
|
||||||
|
// EffectiveDataScopeKind resolves legacy rows and runtime bindings that do
|
||||||
|
// not carry a separate data scope.
|
||||||
|
func EffectiveDataScopeKind(placementScope, dataScope string) string {
|
||||||
|
if dataScope == "" {
|
||||||
|
return placementScope
|
||||||
|
}
|
||||||
|
return dataScope
|
||||||
|
}
|
||||||
|
|
||||||
// PublishAuthority is the durable identity captured when a trusted authoring
|
// PublishAuthority is the durable identity captured when a trusted authoring
|
||||||
// request is authorized. A publish must match every field in this snapshot at
|
// request is authorized. A publish must match every field in this snapshot at
|
||||||
// the release insertion boundary so a scope transition, archive, or competing
|
// the release insertion boundary so a scope transition, archive, or competing
|
||||||
@@ -141,6 +170,7 @@ type Instance struct {
|
|||||||
type PublishAuthority struct {
|
type PublishAuthority struct {
|
||||||
InstanceID string
|
InstanceID string
|
||||||
ScopeKind string
|
ScopeKind string
|
||||||
|
DataScopeKind string
|
||||||
WorkspaceID string
|
WorkspaceID string
|
||||||
TaskID string
|
TaskID string
|
||||||
SessionID string
|
SessionID string
|
||||||
@@ -152,14 +182,14 @@ type PublishAuthority struct {
|
|||||||
|
|
||||||
func (i Instance) PublishAuthority() PublishAuthority {
|
func (i Instance) PublishAuthority() PublishAuthority {
|
||||||
return PublishAuthority{
|
return PublishAuthority{
|
||||||
InstanceID: i.ID, ScopeKind: i.ScopeKind, WorkspaceID: i.WorkspaceID,
|
InstanceID: i.ID, ScopeKind: i.ScopeKind, DataScopeKind: i.EffectiveDataScopeKind(), WorkspaceID: i.WorkspaceID,
|
||||||
TaskID: i.TaskID, SessionID: i.SessionID, RepositoryID: i.RepositoryID,
|
TaskID: i.TaskID, SessionID: i.SessionID, RepositoryID: i.RepositoryID,
|
||||||
Status: i.Status, ActiveReleaseID: i.ActiveReleaseID, GrantGeneration: i.GrantGeneration,
|
Status: i.Status, ActiveReleaseID: i.ActiveReleaseID, GrantGeneration: i.GrantGeneration,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a PublishAuthority) IsZero() bool {
|
func (a PublishAuthority) IsZero() bool {
|
||||||
return a.InstanceID == "" && a.ScopeKind == "" && a.WorkspaceID == "" &&
|
return a.InstanceID == "" && a.ScopeKind == "" && a.DataScopeKind == "" && a.WorkspaceID == "" &&
|
||||||
a.TaskID == "" && a.SessionID == "" && a.RepositoryID == "" &&
|
a.TaskID == "" && a.SessionID == "" && a.RepositoryID == "" &&
|
||||||
a.Status == "" && a.ActiveReleaseID == "" && a.GrantGeneration == 0
|
a.Status == "" && a.ActiveReleaseID == "" && a.GrantGeneration == 0
|
||||||
}
|
}
|
||||||
@@ -247,6 +277,7 @@ CREATE TABLE IF NOT EXISTS plugin_instances (
|
|||||||
plugin_id TEXT NOT NULL,
|
plugin_id TEXT NOT NULL,
|
||||||
source_kind TEXT NOT NULL,
|
source_kind TEXT NOT NULL,
|
||||||
scope_kind TEXT NOT NULL,
|
scope_kind TEXT NOT NULL,
|
||||||
|
data_scope_kind TEXT,
|
||||||
workspace_id TEXT NOT NULL DEFAULT '',
|
workspace_id TEXT NOT NULL DEFAULT '',
|
||||||
task_id TEXT NOT NULL DEFAULT '',
|
task_id TEXT NOT NULL DEFAULT '',
|
||||||
session_id TEXT NOT NULL DEFAULT '',
|
session_id TEXT NOT NULL DEFAULT '',
|
||||||
@@ -321,6 +352,9 @@ func (s *Store) initSchema() error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if _, err := s.db.Exec(`ALTER TABLE plugin_instances ADD COLUMN data_scope_kind TEXT`); err != nil && !db.IsDuplicateColumnError(err) {
|
||||||
|
return err
|
||||||
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -358,6 +392,9 @@ func (s *Store) CreateTx(ctx context.Context, tx *sqlx.Tx, instance Instance) er
|
|||||||
if err := ValidateScope(instance.ScopeKind, ScopeIdentifiers{WorkspaceID: instance.WorkspaceID, TaskID: instance.TaskID, SessionID: instance.SessionID, RepositoryID: instance.RepositoryID}); err != nil {
|
if err := ValidateScope(instance.ScopeKind, ScopeIdentifiers{WorkspaceID: instance.WorkspaceID, TaskID: instance.TaskID, SessionID: instance.SessionID, RepositoryID: instance.RepositoryID}); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
if err := validateDataScope(instance.SourceKind, instance.ScopeKind, instance.DataScopeKind); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
if err := s.checkAdmission(ctx, tx, instance.ScopeKind, instance.WorkspaceID, instance.TaskID); err != nil {
|
if err := s.checkAdmission(ctx, tx, instance.ScopeKind, instance.WorkspaceID, instance.TaskID); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -370,8 +407,8 @@ func (s *Store) CreateTx(ctx context.Context, tx *sqlx.Tx, instance Instance) er
|
|||||||
updatedAt = createdAt
|
updatedAt = createdAt
|
||||||
}
|
}
|
||||||
_, err := tx.ExecContext(ctx, tx.Rebind(`
|
_, err := tx.ExecContext(ctx, tx.Rebind(`
|
||||||
INSERT INTO plugin_instances (id, plugin_id, source_kind, scope_kind, workspace_id, task_id, session_id, repository_id, status, active_release_id, grant_generation, created_at, updated_at)
|
INSERT INTO plugin_instances (id, plugin_id, source_kind, scope_kind, data_scope_kind, workspace_id, task_id, session_id, repository_id, status, active_release_id, grant_generation, created_at, updated_at)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`), instance.ID, instance.PluginID, instance.SourceKind, instance.ScopeKind, instance.WorkspaceID, instance.TaskID, instance.SessionID, instance.RepositoryID, instance.Status, instance.ActiveReleaseID, instance.GrantGeneration, createdAt.Format(time.RFC3339Nano), updatedAt.Format(time.RFC3339Nano))
|
VALUES (?, ?, ?, ?, NULLIF(?, ''), ?, ?, ?, ?, ?, ?, ?, ?, ?)`), instance.ID, instance.PluginID, instance.SourceKind, instance.ScopeKind, instance.DataScopeKind, instance.WorkspaceID, instance.TaskID, instance.SessionID, instance.RepositoryID, instance.Status, instance.ActiveReleaseID, instance.GrantGeneration, createdAt.Format(time.RFC3339Nano), updatedAt.Format(time.RFC3339Nano))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -385,7 +422,7 @@ func (s *Store) ListBySource(ctx context.Context, sourceKind string, includeRemo
|
|||||||
if strings.TrimSpace(sourceKind) == "" {
|
if strings.TrimSpace(sourceKind) == "" {
|
||||||
return nil, ErrInvalidScope
|
return nil, ErrInvalidScope
|
||||||
}
|
}
|
||||||
query := `SELECT id, plugin_id, source_kind, scope_kind, workspace_id, task_id, session_id, repository_id, status, active_release_id, grant_generation, created_at, updated_at FROM plugin_instances WHERE source_kind = ?`
|
query := `SELECT id, plugin_id, source_kind, scope_kind, data_scope_kind, workspace_id, task_id, session_id, repository_id, status, active_release_id, grant_generation, created_at, updated_at FROM plugin_instances WHERE source_kind = ?`
|
||||||
args := []any{sourceKind}
|
args := []any{sourceKind}
|
||||||
if !includeRemoved {
|
if !includeRemoved {
|
||||||
query += ` AND status <> ?`
|
query += ` AND status <> ?`
|
||||||
@@ -453,7 +490,7 @@ func (s *Store) checkAdmissionExcept(ctx context.Context, tx *sqlx.Tx, scopeKind
|
|||||||
|
|
||||||
func (s *Store) Get(ctx context.Context, id string) (Instance, error) {
|
func (s *Store) Get(ctx context.Context, id string) (Instance, error) {
|
||||||
var row instanceRow
|
var row instanceRow
|
||||||
err := s.ro.GetContext(ctx, &row, s.ro.Rebind(`SELECT id, plugin_id, source_kind, scope_kind, workspace_id, task_id, session_id, repository_id, status, active_release_id, grant_generation, created_at, updated_at FROM plugin_instances WHERE id = ?`), id)
|
err := s.ro.GetContext(ctx, &row, s.ro.Rebind(`SELECT id, plugin_id, source_kind, scope_kind, data_scope_kind, workspace_id, task_id, session_id, repository_id, status, active_release_id, grant_generation, created_at, updated_at FROM plugin_instances WHERE id = ?`), id)
|
||||||
if errors.Is(err, sql.ErrNoRows) {
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
return Instance{}, ErrNotFound
|
return Instance{}, ErrNotFound
|
||||||
}
|
}
|
||||||
@@ -487,7 +524,7 @@ func (s *Store) GetMany(ctx context.Context, ids []string) ([]Instance, error) {
|
|||||||
for i, id := range unique {
|
for i, id := range unique {
|
||||||
args[i] = id
|
args[i] = id
|
||||||
}
|
}
|
||||||
rows, err := s.ro.QueryxContext(ctx, s.ro.Rebind(`SELECT id, plugin_id, source_kind, scope_kind, workspace_id, task_id, session_id, repository_id, status, active_release_id, grant_generation, created_at, updated_at FROM plugin_instances WHERE id IN (`+placeholders+`)`), args...)
|
rows, err := s.ro.QueryxContext(ctx, s.ro.Rebind(`SELECT id, plugin_id, source_kind, scope_kind, data_scope_kind, workspace_id, task_id, session_id, repository_id, status, active_release_id, grant_generation, created_at, updated_at FROM plugin_instances WHERE id IN (`+placeholders+`)`), args...)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -508,7 +545,7 @@ func (s *Store) GetMany(ctx context.Context, ids []string) ([]Instance, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *Store) List(ctx context.Context, workspaceID string, includeRemoved bool) ([]Instance, error) {
|
func (s *Store) List(ctx context.Context, workspaceID string, includeRemoved bool) ([]Instance, error) {
|
||||||
query := `SELECT id, plugin_id, source_kind, scope_kind, workspace_id, task_id, session_id, repository_id, status, active_release_id, grant_generation, created_at, updated_at FROM plugin_instances WHERE workspace_id = ?`
|
query := `SELECT id, plugin_id, source_kind, scope_kind, data_scope_kind, workspace_id, task_id, session_id, repository_id, status, active_release_id, grant_generation, created_at, updated_at FROM plugin_instances WHERE workspace_id = ?`
|
||||||
args := []any{workspaceID}
|
args := []any{workspaceID}
|
||||||
if !includeRemoved {
|
if !includeRemoved {
|
||||||
query += ` AND status <> ?`
|
query += ` AND status <> ?`
|
||||||
@@ -558,7 +595,7 @@ func (s *Store) Restore(ctx context.Context, id string) error {
|
|||||||
}
|
}
|
||||||
defer func() { _ = tx.Rollback() }()
|
defer func() { _ = tx.Rollback() }()
|
||||||
var row instanceRow
|
var row instanceRow
|
||||||
if err := tx.GetContext(ctx, &row, tx.Rebind(`SELECT id, plugin_id, source_kind, scope_kind, workspace_id, task_id, session_id, repository_id, status, active_release_id, grant_generation, created_at, updated_at FROM plugin_instances WHERE id = ?`), id); err != nil {
|
if err := tx.GetContext(ctx, &row, tx.Rebind(`SELECT id, plugin_id, source_kind, scope_kind, data_scope_kind, workspace_id, task_id, session_id, repository_id, status, active_release_id, grant_generation, created_at, updated_at FROM plugin_instances WHERE id = ?`), id); err != nil {
|
||||||
if errors.Is(err, sql.ErrNoRows) {
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
return ErrNotFound
|
return ErrNotFound
|
||||||
}
|
}
|
||||||
@@ -611,7 +648,7 @@ func (s *Store) SetScope(ctx context.Context, id, scopeKind string, ids ScopeIde
|
|||||||
}
|
}
|
||||||
defer func() { _ = tx.Rollback() }()
|
defer func() { _ = tx.Rollback() }()
|
||||||
var current instanceRow
|
var current instanceRow
|
||||||
if err := tx.GetContext(ctx, ¤t, tx.Rebind(`SELECT id, plugin_id, source_kind, scope_kind, workspace_id, task_id, session_id, repository_id, status, active_release_id, grant_generation, created_at, updated_at FROM plugin_instances WHERE id = ?`), id); err != nil {
|
if err := tx.GetContext(ctx, ¤t, tx.Rebind(`SELECT id, plugin_id, source_kind, scope_kind, data_scope_kind, workspace_id, task_id, session_id, repository_id, status, active_release_id, grant_generation, created_at, updated_at FROM plugin_instances WHERE id = ?`), id); err != nil {
|
||||||
if errors.Is(err, sql.ErrNoRows) {
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
return ErrNotFound
|
return ErrNotFound
|
||||||
}
|
}
|
||||||
@@ -620,6 +657,9 @@ func (s *Store) SetScope(ctx context.Context, id, scopeKind string, ids ScopeIde
|
|||||||
if current.Status == StatusRemoved {
|
if current.Status == StatusRemoved {
|
||||||
return ErrNotFound
|
return ErrNotFound
|
||||||
}
|
}
|
||||||
|
if err := validateDataScope(current.SourceKind, scopeKind, current.DataScopeKind.String); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
if err := s.checkAdmissionExcept(ctx, tx, scopeKind, ids.WorkspaceID, ids.TaskID, id); err != nil {
|
if err := s.checkAdmissionExcept(ctx, tx, scopeKind, ids.WorkspaceID, ids.TaskID, id); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -718,7 +758,7 @@ func (s *Store) ActivateReleaseTx(ctx context.Context, tx *sqlx.Tx, instanceID,
|
|||||||
}
|
}
|
||||||
var current instanceRow
|
var current instanceRow
|
||||||
if err := tx.GetContext(ctx, ¤t, tx.Rebind(
|
if err := tx.GetContext(ctx, ¤t, tx.Rebind(
|
||||||
`SELECT id, plugin_id, source_kind, scope_kind, workspace_id, task_id, session_id, repository_id, status, active_release_id, grant_generation, created_at, updated_at FROM plugin_instances WHERE id = ?`,
|
`SELECT id, plugin_id, source_kind, scope_kind, data_scope_kind, workspace_id, task_id, session_id, repository_id, status, active_release_id, grant_generation, created_at, updated_at FROM plugin_instances WHERE id = ?`,
|
||||||
), instanceID); err != nil {
|
), instanceID); err != nil {
|
||||||
if errors.Is(err, sql.ErrNoRows) {
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
return ErrNotFound
|
return ErrNotFound
|
||||||
@@ -743,7 +783,7 @@ func (s *Store) ActivateReleaseTx(ctx context.Context, tx *sqlx.Tx, instanceID,
|
|||||||
}
|
}
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if release.ValidationStatus != ValidationValid || !declaredGrantsFitInstance(ctx, tx, instanceID, current.ScopeKind, release.DeclaredPermissionsJSON) {
|
if release.ValidationStatus != ValidationValid || !declaredGrantsFitInstance(ctx, tx, instanceID, EffectiveDataScopeKind(current.ScopeKind, current.DataScopeKind.String), release.DeclaredPermissionsJSON) {
|
||||||
return ErrInvalidRelease
|
return ErrInvalidRelease
|
||||||
}
|
}
|
||||||
result, err := tx.ExecContext(ctx, tx.Rebind(
|
result, err := tx.ExecContext(ctx, tx.Rebind(
|
||||||
@@ -1106,6 +1146,79 @@ func (s *Store) PromoteScopeAndGrantsReviewedTx(ctx context.Context, tx *sqlx.Tx
|
|||||||
return s.promoteScopeAndGrantsReviewedTx(ctx, tx, instanceID, workspaceID, approvedBy, grants, expectedReleaseID, expectedPermissionDigest, expectedGrantGeneration)
|
return s.promoteScopeAndGrantsReviewedTx(ctx, tx, instanceID, workspaceID, approvedBy, grants, expectedReleaseID, expectedPermissionDigest, expectedGrantGeneration)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// EnableWorkspaceDataReviewedTx widens only a retained task canvas's data
|
||||||
|
// scope after the current workspace owner confirms its current release.
|
||||||
|
func (s *Store) EnableWorkspaceDataReviewedTx(ctx context.Context, tx *sqlx.Tx, instanceID, workspaceID, approvedBy string, grants []Grant, expectedReleaseID, expectedPermissionDigest string, expectedGrantGeneration int64) error {
|
||||||
|
if err := validateWorkspaceDataReviewRequest(instanceID, workspaceID, approvedBy, expectedReleaseID, expectedPermissionDigest, expectedGrantGeneration); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
current, declaredJSON, err := loadPromotionState(ctx, tx, instanceID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := validateWorkspaceDataReviewState(current, declaredJSON, workspaceID, expectedReleaseID, expectedPermissionDigest, expectedGrantGeneration); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := validateGrantsForDeclaration(declaredJSON, ScopeWorkspace, grants); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := verifyWorkspaceOwnerTx(ctx, tx, workspaceID, approvedBy); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return updateWorkspaceDataScopeTx(ctx, tx, current, workspaceID, approvedBy, grants, expectedReleaseID, expectedGrantGeneration)
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateWorkspaceDataReviewRequest(instanceID, workspaceID, approvedBy, expectedReleaseID, expectedPermissionDigest string, expectedGrantGeneration int64) error {
|
||||||
|
if strings.TrimSpace(instanceID) == "" || strings.TrimSpace(workspaceID) == "" || strings.TrimSpace(approvedBy) == "" ||
|
||||||
|
strings.TrimSpace(expectedReleaseID) == "" || strings.TrimSpace(expectedPermissionDigest) == "" || expectedGrantGeneration < 0 {
|
||||||
|
return ErrStaleWorkspaceDataReview
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateWorkspaceDataReviewState(current instanceRow, declaredJSON, workspaceID, expectedReleaseID, expectedPermissionDigest string, expectedGrantGeneration int64) error {
|
||||||
|
if current.SourceKind != SourceLocalCanvas || current.ScopeKind != ScopeTask || current.WorkspaceID != workspaceID ||
|
||||||
|
current.TaskID == "" || current.Status != StatusActive || current.DataScopeKind.Valid && current.DataScopeKind.String == ScopeWorkspace {
|
||||||
|
return ErrInvalidLifecycleState
|
||||||
|
}
|
||||||
|
if current.ActiveReleaseID != expectedReleaseID || current.GrantGeneration != expectedGrantGeneration ||
|
||||||
|
permissionDigest(declaredJSON) != expectedPermissionDigest || EffectiveDataScopeKind(current.ScopeKind, current.DataScopeKind.String) != ScopeTask {
|
||||||
|
return ErrStaleWorkspaceDataReview
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func verifyWorkspaceOwnerTx(ctx context.Context, tx *sqlx.Tx, workspaceID, approvedBy string) error {
|
||||||
|
ownerUpdate, err := tx.ExecContext(ctx, tx.Rebind(
|
||||||
|
`UPDATE workspaces SET owner_id = owner_id WHERE id = ? AND (COALESCE(owner_id, '') = ? OR (COALESCE(owner_id, '') = '' AND ? = ?))`,
|
||||||
|
), workspaceID, approvedBy, approvedBy, userstore.DefaultUserID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if affected, _ := ownerUpdate.RowsAffected(); affected == 0 {
|
||||||
|
return ErrWorkspaceOwnerRequired
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateWorkspaceDataScopeTx(ctx context.Context, tx *sqlx.Tx, current instanceRow, workspaceID, approvedBy string, grants []Grant, expectedReleaseID string, expectedGrantGeneration int64) error {
|
||||||
|
result, err := tx.ExecContext(ctx, tx.Rebind(
|
||||||
|
`UPDATE plugin_instances SET data_scope_kind = ?, grant_generation = grant_generation + 1, updated_at = ? WHERE id = ? AND source_kind = ? AND scope_kind = ? AND workspace_id = ? AND task_id = ? AND status = ? AND active_release_id = ? AND grant_generation = ? AND COALESCE(data_scope_kind, '') = ?`,
|
||||||
|
), ScopeWorkspace, time.Now().UTC().Format(time.RFC3339Nano), current.ID, SourceLocalCanvas, ScopeTask, workspaceID, current.TaskID, StatusActive, expectedReleaseID, expectedGrantGeneration, current.DataScopeKind.String)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if affected, _ := result.RowsAffected(); affected == 0 {
|
||||||
|
return ErrStaleWorkspaceDataReview
|
||||||
|
}
|
||||||
|
if _, err := tx.ExecContext(ctx, tx.Rebind(
|
||||||
|
`DELETE FROM plugin_instance_grants WHERE plugin_instance_id = ?`,
|
||||||
|
), current.ID); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return insertInstanceGrantsTx(ctx, tx, current.ID, approvedBy, grants)
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Store) promoteScopeAndGrantsReviewedTx(ctx context.Context, tx *sqlx.Tx, instanceID, workspaceID, approvedBy string, grants []Grant, expectedReleaseID, expectedPermissionDigest string, expectedGrantGeneration int64) error {
|
func (s *Store) promoteScopeAndGrantsReviewedTx(ctx context.Context, tx *sqlx.Tx, instanceID, workspaceID, approvedBy string, grants []Grant, expectedReleaseID, expectedPermissionDigest string, expectedGrantGeneration int64) error {
|
||||||
if err := validatePromotionRequest(instanceID, workspaceID, approvedBy); err != nil {
|
if err := validatePromotionRequest(instanceID, workspaceID, approvedBy); err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -1129,7 +1242,7 @@ func (s *Store) promoteScopeAndGrantsReviewedTx(ctx context.Context, tx *sqlx.Tx
|
|||||||
func loadPromotionState(ctx context.Context, tx *sqlx.Tx, instanceID string) (instanceRow, string, error) {
|
func loadPromotionState(ctx context.Context, tx *sqlx.Tx, instanceID string) (instanceRow, string, error) {
|
||||||
var current instanceRow
|
var current instanceRow
|
||||||
if err := tx.GetContext(ctx, ¤t, tx.Rebind(
|
if err := tx.GetContext(ctx, ¤t, tx.Rebind(
|
||||||
`SELECT id, plugin_id, source_kind, scope_kind, workspace_id, task_id, session_id, repository_id, status, active_release_id, grant_generation, created_at, updated_at FROM plugin_instances WHERE id = ?`,
|
`SELECT id, plugin_id, source_kind, scope_kind, data_scope_kind, workspace_id, task_id, session_id, repository_id, status, active_release_id, grant_generation, created_at, updated_at FROM plugin_instances WHERE id = ?`,
|
||||||
), instanceID); err != nil {
|
), instanceID); err != nil {
|
||||||
if errors.Is(err, sql.ErrNoRows) {
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
return instanceRow{}, "", ErrNotFound
|
return instanceRow{}, "", ErrNotFound
|
||||||
@@ -1231,14 +1344,20 @@ func (s *Store) ApproveReleaseTx(ctx context.Context, tx *sqlx.Tx, instanceID, r
|
|||||||
if release.ValidationStatus != ValidationPendingPermission {
|
if release.ValidationStatus != ValidationPendingPermission {
|
||||||
return ErrInvalidRelease
|
return ErrInvalidRelease
|
||||||
}
|
}
|
||||||
if err := validateGrantsForDeclaration(release.DeclaredPermissionsJSON, release.ScopeKind, grants); err != nil {
|
effectiveScope := EffectiveDataScopeKind(release.ScopeKind, release.DataScopeKind)
|
||||||
|
if release.SourceKind == SourceLocalCanvas && effectiveScope == ScopeWorkspace {
|
||||||
|
if err := verifyWorkspaceOwnerTx(ctx, tx, release.WorkspaceID, approvedBy); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := validateGrantsForDeclaration(release.DeclaredPermissionsJSON, effectiveScope, grants); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
existingGrants, err := loadInstanceGrants(ctx, tx, instanceID)
|
existingGrants, err := loadInstanceGrants(ctx, tx, instanceID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if !grantsCoverDeclaration(release.DeclaredPermissionsJSON, release.ScopeKind, append(existingGrants, grants...)) {
|
if !grantsCoverDeclaration(release.DeclaredPermissionsJSON, effectiveScope, append(existingGrants, grants...)) {
|
||||||
return ErrInvalidScope
|
return ErrInvalidScope
|
||||||
}
|
}
|
||||||
if err := insertInstanceGrantsTx(ctx, tx, instanceID, approvedBy, grants); err != nil {
|
if err := insertInstanceGrantsTx(ctx, tx, instanceID, approvedBy, grants); err != nil {
|
||||||
@@ -1262,14 +1381,17 @@ type pendingReleaseRow struct {
|
|||||||
PluginID string `db:"plugin_id"`
|
PluginID string `db:"plugin_id"`
|
||||||
DeclaredPermissionsJSON string `db:"declared_permissions_json"`
|
DeclaredPermissionsJSON string `db:"declared_permissions_json"`
|
||||||
ValidationStatus string `db:"validation_status"`
|
ValidationStatus string `db:"validation_status"`
|
||||||
|
SourceKind string `db:"source_kind"`
|
||||||
|
WorkspaceID string `db:"workspace_id"`
|
||||||
ScopeKind string `db:"scope_kind"`
|
ScopeKind string `db:"scope_kind"`
|
||||||
|
DataScopeKind string `db:"data_scope_kind"`
|
||||||
Status string `db:"status"`
|
Status string `db:"status"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func loadPendingReleaseTx(ctx context.Context, tx *sqlx.Tx, instanceID, releaseID string) (pendingReleaseRow, error) {
|
func loadPendingReleaseTx(ctx context.Context, tx *sqlx.Tx, instanceID, releaseID string) (pendingReleaseRow, error) {
|
||||||
var release pendingReleaseRow
|
var release pendingReleaseRow
|
||||||
if err := tx.GetContext(ctx, &release, tx.Rebind(
|
if err := tx.GetContext(ctx, &release, tx.Rebind(
|
||||||
`SELECT r.plugin_id, r.declared_permissions_json, r.validation_status, i.scope_kind, i.status FROM plugin_releases r JOIN plugin_instances i ON i.id = r.instance_id WHERE r.id = ? AND r.instance_id = ?`,
|
`SELECT r.plugin_id, r.declared_permissions_json, r.validation_status, i.source_kind, i.workspace_id, i.scope_kind, COALESCE(i.data_scope_kind, '') AS data_scope_kind, i.status FROM plugin_releases r JOIN plugin_instances i ON i.id = r.instance_id WHERE r.id = ? AND r.instance_id = ?`,
|
||||||
), releaseID, instanceID); err != nil {
|
), releaseID, instanceID); err != nil {
|
||||||
if errors.Is(err, sql.ErrNoRows) {
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
return pendingReleaseRow{}, ErrNotFound
|
return pendingReleaseRow{}, ErrNotFound
|
||||||
@@ -1383,8 +1505,8 @@ func (s *Store) CreateReleaseIfAuthorityTx(ctx context.Context, tx *sqlx.Tx, ins
|
|||||||
return ErrInvalidRelease
|
return ErrInvalidRelease
|
||||||
}
|
}
|
||||||
result, err := tx.ExecContext(ctx, tx.Rebind(
|
result, err := tx.ExecContext(ctx, tx.Rebind(
|
||||||
`UPDATE plugin_instances SET updated_at = updated_at WHERE id = ? AND scope_kind = ? AND workspace_id = ? AND task_id = ? AND session_id = ? AND repository_id = ? AND status = ? AND active_release_id = ? AND grant_generation = ?`,
|
`UPDATE plugin_instances SET updated_at = updated_at WHERE id = ? AND scope_kind = ? AND COALESCE(NULLIF(data_scope_kind, ''), scope_kind) = ? AND workspace_id = ? AND task_id = ? AND session_id = ? AND repository_id = ? AND status = ? AND active_release_id = ? AND grant_generation = ?`,
|
||||||
), instanceID, expected.ScopeKind, expected.WorkspaceID, expected.TaskID, expected.SessionID, expected.RepositoryID, expected.Status, expected.ActiveReleaseID, expected.GrantGeneration)
|
), instanceID, expected.ScopeKind, expected.DataScopeKind, expected.WorkspaceID, expected.TaskID, expected.SessionID, expected.RepositoryID, expected.Status, expected.ActiveReleaseID, expected.GrantGeneration)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -1659,8 +1781,8 @@ func (s *Store) AddGrant(ctx context.Context, grant Grant) error {
|
|||||||
// canvas creation. It does not advance grant_generation because SetPluginIDTx
|
// canvas creation. It does not advance grant_generation because SetPluginIDTx
|
||||||
// increments it before the public ActivateReleaseTx call in the same
|
// increments it before the public ActivateReleaseTx call in the same
|
||||||
// activation transaction. The empty-grant case is valid.
|
// activation transaction. The empty-grant case is valid.
|
||||||
func (s *Store) AddInitialGrantsTx(ctx context.Context, tx *sqlx.Tx, instanceID, approvedBy string, grants []Grant) error {
|
func (s *Store) AddInitialGrantsTx(ctx context.Context, tx *sqlx.Tx, instanceID, releaseID, approvedBy string, grants []Grant) error {
|
||||||
if strings.TrimSpace(instanceID) == "" || strings.TrimSpace(approvedBy) == "" {
|
if strings.TrimSpace(instanceID) == "" || strings.TrimSpace(releaseID) == "" || strings.TrimSpace(approvedBy) == "" {
|
||||||
return ErrInvalidScope
|
return ErrInvalidScope
|
||||||
}
|
}
|
||||||
var exists int
|
var exists int
|
||||||
@@ -1672,6 +1794,27 @@ func (s *Store) AddInitialGrantsTx(ctx context.Context, tx *sqlx.Tx, instanceID,
|
|||||||
if exists == 0 {
|
if exists == 0 {
|
||||||
return ErrNotFound
|
return ErrNotFound
|
||||||
}
|
}
|
||||||
|
var current struct {
|
||||||
|
ScopeKind string `db:"scope_kind"`
|
||||||
|
DataScopeKind string `db:"data_scope_kind"`
|
||||||
|
}
|
||||||
|
if err := tx.GetContext(ctx, ¤t, tx.Rebind(
|
||||||
|
`SELECT scope_kind, COALESCE(data_scope_kind, '') AS data_scope_kind FROM plugin_instances WHERE id = ?`,
|
||||||
|
), instanceID); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
var release struct {
|
||||||
|
DeclaredPermissionsJSON string `db:"declared_permissions_json"`
|
||||||
|
ValidationStatus string `db:"validation_status"`
|
||||||
|
}
|
||||||
|
if err := tx.GetContext(ctx, &release, tx.Rebind(
|
||||||
|
`SELECT declared_permissions_json, validation_status FROM plugin_releases WHERE id = ? AND instance_id = ?`,
|
||||||
|
), releaseID, instanceID); err != nil || release.ValidationStatus != ValidationValid {
|
||||||
|
return ErrInvalidRelease
|
||||||
|
}
|
||||||
|
if err := validateGrantsForDeclaration(release.DeclaredPermissionsJSON, EffectiveDataScopeKind(current.ScopeKind, current.DataScopeKind), grants); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
var existing int
|
var existing int
|
||||||
if err := tx.GetContext(ctx, &existing, tx.Rebind(
|
if err := tx.GetContext(ctx, &existing, tx.Rebind(
|
||||||
`SELECT COUNT(*) FROM plugin_instance_grants WHERE plugin_instance_id = ?`,
|
`SELECT COUNT(*) FROM plugin_instance_grants WHERE plugin_instance_id = ?`,
|
||||||
@@ -1684,6 +1827,37 @@ func (s *Store) AddInitialGrantsTx(ctx context.Context, tx *sqlx.Tx, instanceID,
|
|||||||
return insertInstanceGrantsTx(ctx, tx, instanceID, approvedBy, grants)
|
return insertInstanceGrantsTx(ctx, tx, instanceID, approvedBy, grants)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SetInitialPublicationDataScopeTx widens a pending task canvas only after its
|
||||||
|
// creation authority has been consumed in the caller's transaction.
|
||||||
|
func (s *Store) SetInitialPublicationDataScopeTx(ctx context.Context, tx *sqlx.Tx, expected PublishAuthority, releaseID string) error {
|
||||||
|
if expected.InstanceID == "" || expected.ScopeKind != ScopeTask || expected.DataScopeKind != ScopeTask ||
|
||||||
|
expected.Status != StatusPending || expected.ActiveReleaseID != "" || expected.WorkspaceID == "" ||
|
||||||
|
expected.TaskID == "" || strings.TrimSpace(releaseID) == "" {
|
||||||
|
return ErrStaleCanvasPublish
|
||||||
|
}
|
||||||
|
result, err := tx.ExecContext(ctx, tx.Rebind(
|
||||||
|
`UPDATE plugin_instances
|
||||||
|
SET data_scope_kind = ?, grant_generation = grant_generation + 1, updated_at = ?
|
||||||
|
WHERE id = ? AND source_kind = ? AND scope_kind = ? AND COALESCE(NULLIF(data_scope_kind, ''), scope_kind) = ?
|
||||||
|
AND workspace_id = ? AND task_id = ? AND session_id = ? AND repository_id = ?
|
||||||
|
AND status = ? AND active_release_id = ? AND grant_generation = ?
|
||||||
|
AND NOT EXISTS (SELECT 1 FROM plugin_instance_grants WHERE plugin_instance_id = ?)
|
||||||
|
AND EXISTS (SELECT 1 FROM plugin_releases WHERE instance_id = ? AND id = ? AND validation_status = ?)
|
||||||
|
AND NOT EXISTS (SELECT 1 FROM plugin_releases WHERE instance_id = ? AND id <> ?)`),
|
||||||
|
ScopeWorkspace, time.Now().UTC().Format(time.RFC3339Nano), expected.InstanceID, SourceLocalCanvas,
|
||||||
|
expected.ScopeKind, expected.DataScopeKind, expected.WorkspaceID, expected.TaskID, expected.SessionID,
|
||||||
|
expected.RepositoryID, expected.Status, expected.ActiveReleaseID, expected.GrantGeneration,
|
||||||
|
expected.InstanceID, expected.InstanceID, releaseID, ValidationValid, expected.InstanceID, releaseID,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if affected, _ := result.RowsAffected(); affected == 0 {
|
||||||
|
return ErrStaleCanvasPublish
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Store) ListGrants(ctx context.Context, instanceID string) ([]Grant, error) {
|
func (s *Store) ListGrants(ctx context.Context, instanceID string) ([]Grant, error) {
|
||||||
rows, err := s.ro.QueryxContext(ctx, s.ro.Rebind(`SELECT plugin_instance_id, permission_kind, resource, network_origin, scope_ceiling, approved_by, approved_at FROM plugin_instance_grants WHERE plugin_instance_id = ? ORDER BY permission_kind, resource, network_origin`), instanceID)
|
rows, err := s.ro.QueryxContext(ctx, s.ro.Rebind(`SELECT plugin_instance_id, permission_kind, resource, network_origin, scope_ceiling, approved_by, approved_at FROM plugin_instance_grants WHERE plugin_instance_id = ? ORDER BY permission_kind, resource, network_origin`), instanceID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -2210,19 +2384,20 @@ func (s *Store) RemoveInstanceTx(ctx context.Context, tx *sqlx.Tx, id string) er
|
|||||||
}
|
}
|
||||||
|
|
||||||
type instanceRow struct {
|
type instanceRow struct {
|
||||||
ID string `db:"id"`
|
ID string `db:"id"`
|
||||||
PluginID string `db:"plugin_id"`
|
PluginID string `db:"plugin_id"`
|
||||||
SourceKind string `db:"source_kind"`
|
SourceKind string `db:"source_kind"`
|
||||||
ScopeKind string `db:"scope_kind"`
|
ScopeKind string `db:"scope_kind"`
|
||||||
WorkspaceID string `db:"workspace_id"`
|
DataScopeKind sql.NullString `db:"data_scope_kind"`
|
||||||
TaskID string `db:"task_id"`
|
WorkspaceID string `db:"workspace_id"`
|
||||||
SessionID string `db:"session_id"`
|
TaskID string `db:"task_id"`
|
||||||
RepositoryID string `db:"repository_id"`
|
SessionID string `db:"session_id"`
|
||||||
Status string `db:"status"`
|
RepositoryID string `db:"repository_id"`
|
||||||
ActiveReleaseID string `db:"active_release_id"`
|
Status string `db:"status"`
|
||||||
GrantGeneration int64 `db:"grant_generation"`
|
ActiveReleaseID string `db:"active_release_id"`
|
||||||
CreatedAt string `db:"created_at"`
|
GrantGeneration int64 `db:"grant_generation"`
|
||||||
UpdatedAt string `db:"updated_at"`
|
CreatedAt string `db:"created_at"`
|
||||||
|
UpdatedAt string `db:"updated_at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r instanceRow) instance() (Instance, error) {
|
func (r instanceRow) instance() (Instance, error) {
|
||||||
@@ -2234,7 +2409,7 @@ func (r instanceRow) instance() (Instance, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return Instance{}, err
|
return Instance{}, err
|
||||||
}
|
}
|
||||||
return Instance{ID: r.ID, PluginID: r.PluginID, SourceKind: r.SourceKind, ScopeKind: r.ScopeKind, WorkspaceID: r.WorkspaceID, TaskID: r.TaskID, SessionID: r.SessionID, RepositoryID: r.RepositoryID, Status: r.Status, ActiveReleaseID: r.ActiveReleaseID, GrantGeneration: r.GrantGeneration, CreatedAt: created, UpdatedAt: updated}, nil
|
return Instance{ID: r.ID, PluginID: r.PluginID, SourceKind: r.SourceKind, ScopeKind: r.ScopeKind, DataScopeKind: r.DataScopeKind.String, WorkspaceID: r.WorkspaceID, TaskID: r.TaskID, SessionID: r.SessionID, RepositoryID: r.RepositoryID, Status: r.Status, ActiveReleaseID: r.ActiveReleaseID, GrantGeneration: r.GrantGeneration, CreatedAt: created, UpdatedAt: updated}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
type releaseRow struct {
|
type releaseRow struct {
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import (
|
|||||||
_ "github.com/mattn/go-sqlite3"
|
_ "github.com/mattn/go-sqlite3"
|
||||||
|
|
||||||
"github.com/kandev/kandev/internal/db"
|
"github.com/kandev/kandev/internal/db"
|
||||||
|
userstore "github.com/kandev/kandev/internal/user/store"
|
||||||
)
|
)
|
||||||
|
|
||||||
func newTestStore(t *testing.T) *Store {
|
func newTestStore(t *testing.T) *Store {
|
||||||
@@ -44,6 +45,181 @@ func TestValidateScopeRequiresOnlyIdentifiersForScope(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestInstanceDataScopeIsIndependentAndDefaultsToPlacement(t *testing.T) {
|
||||||
|
store := newTestStore(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
if err := store.Create(ctx, Instance{
|
||||||
|
ID: "workspace-data", PluginID: "canvas-board", SourceKind: SourceLocalCanvas,
|
||||||
|
ScopeKind: ScopeTask, DataScopeKind: ScopeWorkspace, WorkspaceID: "workspace-1",
|
||||||
|
TaskID: "task-1", Status: StatusActive,
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("Create workspace-data task canvas: %v", err)
|
||||||
|
}
|
||||||
|
workspaceData, err := store.Get(ctx, "workspace-data")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Get workspace-data task canvas: %v", err)
|
||||||
|
}
|
||||||
|
if workspaceData.ScopeKind != ScopeTask || workspaceData.EffectiveDataScopeKind() != ScopeWorkspace {
|
||||||
|
t.Fatalf("scopes = placement %q, effective data %q, want task and workspace", workspaceData.ScopeKind, workspaceData.EffectiveDataScopeKind())
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := store.Create(ctx, Instance{
|
||||||
|
ID: "legacy", PluginID: "canvas-board", SourceKind: SourceLocalCanvas,
|
||||||
|
ScopeKind: ScopeTask, WorkspaceID: "workspace-1", TaskID: "task-2", Status: StatusActive,
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("Create legacy task canvas: %v", err)
|
||||||
|
}
|
||||||
|
legacy, err := store.Get(ctx, "legacy")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Get legacy task canvas: %v", err)
|
||||||
|
}
|
||||||
|
if legacy.DataScopeKind != "" || legacy.EffectiveDataScopeKind() != ScopeTask {
|
||||||
|
t.Fatalf("legacy scopes = explicit %q, effective %q, want empty and task", legacy.DataScopeKind, legacy.EffectiveDataScopeKind())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInstanceRejectsUntrustedDataScopeCombinations(t *testing.T) {
|
||||||
|
store := newTestStore(t)
|
||||||
|
for _, instance := range []Instance{
|
||||||
|
{ID: "installed", PluginID: "plugin", SourceKind: SourceInstalled, ScopeKind: ScopeTask, DataScopeKind: ScopeWorkspace, WorkspaceID: "workspace-1", TaskID: "task-1", Status: StatusActive},
|
||||||
|
{ID: "session-data", PluginID: "canvas", SourceKind: SourceLocalCanvas, ScopeKind: ScopeTask, DataScopeKind: ScopeSession, WorkspaceID: "workspace-1", TaskID: "task-2", Status: StatusActive},
|
||||||
|
{ID: "instance", PluginID: "canvas", SourceKind: SourceLocalCanvas, ScopeKind: ScopeTask, DataScopeKind: ScopeInstance, WorkspaceID: "workspace-1", TaskID: "task-3", Status: StatusActive},
|
||||||
|
} {
|
||||||
|
if err := store.Create(context.Background(), instance); !errors.Is(err, ErrInvalidScope) {
|
||||||
|
t.Errorf("Create(%s) = %v, want ErrInvalidScope", instance.ID, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExistingInstanceTableMigratesWithTaskDataScopeFallback(t *testing.T) {
|
||||||
|
connection, err := sql.Open("sqlite3", filepath.Join(t.TempDir(), "legacy-instances.db"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("open sqlite: %v", err)
|
||||||
|
}
|
||||||
|
connection.SetMaxOpenConns(1)
|
||||||
|
pool := db.NewPool(sqlx.NewDb(connection, "sqlite3"), sqlx.NewDb(connection, "sqlite3"))
|
||||||
|
t.Cleanup(func() { _ = pool.Close() })
|
||||||
|
if _, err := pool.Writer().Exec(`CREATE TABLE plugin_instances (
|
||||||
|
id TEXT PRIMARY KEY, plugin_id TEXT NOT NULL, source_kind TEXT NOT NULL, scope_kind TEXT NOT NULL,
|
||||||
|
workspace_id TEXT NOT NULL DEFAULT '', task_id TEXT NOT NULL DEFAULT '', session_id TEXT NOT NULL DEFAULT '',
|
||||||
|
repository_id TEXT NOT NULL DEFAULT '', status TEXT NOT NULL, active_release_id TEXT NOT NULL DEFAULT '',
|
||||||
|
grant_generation INTEGER NOT NULL DEFAULT 0, created_at TEXT NOT NULL, updated_at TEXT NOT NULL
|
||||||
|
)`); err != nil {
|
||||||
|
t.Fatalf("create legacy plugin_instances schema: %v", err)
|
||||||
|
}
|
||||||
|
created := time.Now().UTC().Format(time.RFC3339Nano)
|
||||||
|
if _, err := pool.Writer().Exec(`INSERT INTO plugin_instances
|
||||||
|
(id, plugin_id, source_kind, scope_kind, workspace_id, task_id, status, created_at, updated_at)
|
||||||
|
VALUES ('legacy', 'canvas', 'local_canvas', 'task', 'workspace-1', 'task-1', 'active', ?, ?)`, created, created); err != nil {
|
||||||
|
t.Fatalf("insert legacy instance: %v", err)
|
||||||
|
}
|
||||||
|
store, err := NewStore(pool)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewStore migrates legacy table: %v", err)
|
||||||
|
}
|
||||||
|
instance, err := store.Get(context.Background(), "legacy")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Get legacy instance: %v", err)
|
||||||
|
}
|
||||||
|
if instance.DataScopeKind != "" || instance.EffectiveDataScopeKind() != ScopeTask {
|
||||||
|
t.Fatalf("legacy scopes = explicit %q, effective %q, want empty and task", instance.DataScopeKind, instance.EffectiveDataScopeKind())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEnableWorkspaceDataRequiresCurrentOwnerReview(t *testing.T) {
|
||||||
|
store := newTestStore(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
if _, err := store.db.Exec(`CREATE TABLE workspaces (id TEXT PRIMARY KEY, owner_id TEXT NOT NULL DEFAULT '')`); err != nil {
|
||||||
|
t.Fatalf("create workspaces table: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := store.db.Exec(`INSERT INTO workspaces (id, owner_id) VALUES ('workspace-1', 'owner-1')`); err != nil {
|
||||||
|
t.Fatalf("insert workspace: %v", err)
|
||||||
|
}
|
||||||
|
if err := store.Create(ctx, Instance{
|
||||||
|
ID: "instance-1", PluginID: "canvas", SourceKind: SourceLocalCanvas,
|
||||||
|
ScopeKind: ScopeTask, WorkspaceID: "workspace-1", TaskID: "task-1", Status: StatusActive,
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("create task canvas: %v", err)
|
||||||
|
}
|
||||||
|
declared := json.RawMessage(`{"reads":["tasks"]}`)
|
||||||
|
if err := store.CreateRelease(ctx, Release{
|
||||||
|
ID: "release-1", PluginID: "canvas", InstanceID: "instance-1", PackageDigest: "digest-1",
|
||||||
|
SourceKind: SourceLocalCanvas, ManifestJSON: json.RawMessage(`{}`), DeclaredPermissionsJSON: declared,
|
||||||
|
ArtifactPath: "releases/one", ArtifactBytes: 1, ValidationStatus: ValidationValid,
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("create active release: %v", err)
|
||||||
|
}
|
||||||
|
if err := store.AddGrant(ctx, Grant{InstanceID: "instance-1", PermissionKind: "api_read", Resource: "tasks", ScopeCeiling: ScopeTask, ApprovedBy: "owner-1"}); err != nil {
|
||||||
|
t.Fatalf("add task grant: %v", err)
|
||||||
|
}
|
||||||
|
if err := store.AddGrant(ctx, Grant{InstanceID: "instance-1", PermissionKind: "api_write", Resource: "messages", ScopeCeiling: ScopeTask, ApprovedBy: "owner-1"}); err != nil {
|
||||||
|
t.Fatalf("add stale task grant: %v", err)
|
||||||
|
}
|
||||||
|
if err := store.SetActiveRelease(ctx, "instance-1", "release-1"); err != nil {
|
||||||
|
t.Fatalf("activate release: %v", err)
|
||||||
|
}
|
||||||
|
instanceBeforeReview, err := store.Get(ctx, "instance-1")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("get instance before review: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
workspaceGrant := []Grant{{InstanceID: "instance-1", PermissionKind: "api_read", Resource: "tasks", ScopeCeiling: ScopeWorkspace}}
|
||||||
|
apply := func(userID string, generation int64, grants []Grant) error {
|
||||||
|
return store.WithTransaction(ctx, func(tx *sqlx.Tx) error {
|
||||||
|
return store.EnableWorkspaceDataReviewedTx(ctx, tx, "instance-1", "workspace-1", userID, grants, "release-1", permissionDigest(string(declared)), generation)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if err := apply("owner-2", instanceBeforeReview.GrantGeneration, workspaceGrant); !errors.Is(err, ErrWorkspaceOwnerRequired) {
|
||||||
|
t.Fatalf("foreign owner confirmation = %v, want ErrWorkspaceOwnerRequired", err)
|
||||||
|
}
|
||||||
|
if err := apply("owner-1", instanceBeforeReview.GrantGeneration+1, workspaceGrant); !errors.Is(err, ErrStaleWorkspaceDataReview) {
|
||||||
|
t.Fatalf("stale review confirmation = %v, want ErrStaleWorkspaceDataReview", err)
|
||||||
|
}
|
||||||
|
undeclared := append(append([]Grant(nil), workspaceGrant...), Grant{InstanceID: "instance-1", PermissionKind: "api_write", Resource: "messages", ScopeCeiling: ScopeWorkspace})
|
||||||
|
if err := apply("owner-1", instanceBeforeReview.GrantGeneration, undeclared); !errors.Is(err, ErrInvalidScope) {
|
||||||
|
t.Fatalf("undeclared grant confirmation = %v, want ErrInvalidScope", err)
|
||||||
|
}
|
||||||
|
if err := apply("owner-1", instanceBeforeReview.GrantGeneration, workspaceGrant); err != nil {
|
||||||
|
t.Fatalf("owner confirmation: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
instance, err := store.Get(ctx, "instance-1")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("get upgraded instance: %v", err)
|
||||||
|
}
|
||||||
|
if instance.ScopeKind != ScopeTask || instance.DataScopeKind != ScopeWorkspace || instance.ActiveReleaseID != "release-1" || instance.GrantGeneration != instanceBeforeReview.GrantGeneration+1 {
|
||||||
|
t.Fatalf("upgraded instance = %+v, want task placement, workspace data, same release, generation increment", instance)
|
||||||
|
}
|
||||||
|
grants, err := store.ListGrants(ctx, "instance-1")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("list upgraded grants: %v", err)
|
||||||
|
}
|
||||||
|
if len(grants) != 1 || grants[0].ScopeCeiling != ScopeWorkspace || grants[0].ApprovedBy != "owner-1" {
|
||||||
|
t.Fatalf("upgraded grants = %+v, want one owner-approved workspace task grant", grants)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestVerifyWorkspaceOwnerAllowsAuthDisabledUnownedWorkspace(t *testing.T) {
|
||||||
|
store := newTestStore(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
if _, err := store.db.Exec(`CREATE TABLE workspaces (id TEXT PRIMARY KEY, owner_id TEXT NOT NULL DEFAULT '')`); err != nil {
|
||||||
|
t.Fatalf("create workspaces table: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := store.db.Exec(`INSERT INTO workspaces (id, owner_id) VALUES ('workspace-1', '')`); err != nil {
|
||||||
|
t.Fatalf("insert unowned workspace: %v", err)
|
||||||
|
}
|
||||||
|
if err := store.WithTransaction(ctx, func(tx *sqlx.Tx) error {
|
||||||
|
return verifyWorkspaceOwnerTx(ctx, tx, "workspace-1", userstore.DefaultUserID)
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("default user review of unowned workspace: %v", err)
|
||||||
|
}
|
||||||
|
if err := store.WithTransaction(ctx, func(tx *sqlx.Tx) error {
|
||||||
|
return verifyWorkspaceOwnerTx(ctx, tx, "workspace-1", "member-1")
|
||||||
|
}); !errors.Is(err, ErrWorkspaceOwnerRequired) {
|
||||||
|
t.Fatalf("ordinary user review of unowned workspace = %v, want ErrWorkspaceOwnerRequired", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestCreateInstanceAdmissionIsAtomicAtTaskAndWorkspaceLimits(t *testing.T) {
|
func TestCreateInstanceAdmissionIsAtomicAtTaskAndWorkspaceLimits(t *testing.T) {
|
||||||
store := newTestStore(t)
|
store := newTestStore(t)
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ type CapabilityBinding struct {
|
|||||||
WebAppKey string
|
WebAppKey string
|
||||||
Placement string
|
Placement string
|
||||||
ScopeKind string
|
ScopeKind string
|
||||||
|
DataScopeKind string
|
||||||
WorkspaceID string
|
WorkspaceID string
|
||||||
TaskID string
|
TaskID string
|
||||||
SessionID string
|
SessionID string
|
||||||
|
|||||||
@@ -143,7 +143,7 @@ func projectWebAppEvent(source *bus.Event) (webapp.EventInput, bool) {
|
|||||||
func isProjectableWebAppEvent(eventType string) bool {
|
func isProjectableWebAppEvent(eventType string) bool {
|
||||||
switch eventType {
|
switch eventType {
|
||||||
case events.CanvasCreated, events.CanvasReleaseActivated, events.CanvasReleasePermissionRequired,
|
case events.CanvasCreated, events.CanvasReleaseActivated, events.CanvasReleasePermissionRequired,
|
||||||
events.CanvasPromoted, events.CanvasArchived, events.CanvasRestored, events.CanvasRemoved,
|
events.CanvasPromoted, events.CanvasWorkspaceDataEnabled, events.CanvasArchived, events.CanvasRestored, events.CanvasRemoved,
|
||||||
events.TaskCreated, events.TaskUpdated, events.TaskStateChanged, events.TaskDeleted, events.TaskMoved,
|
events.TaskCreated, events.TaskUpdated, events.TaskStateChanged, events.TaskDeleted, events.TaskMoved,
|
||||||
events.TaskQueuePromoted, events.TaskDependenciesResolved, events.TaskDependencyFailed,
|
events.TaskQueuePromoted, events.TaskDependenciesResolved, events.TaskDependencyFailed,
|
||||||
events.WorkspaceCreated, events.WorkspaceUpdated, events.WorkspaceDeleted,
|
events.WorkspaceCreated, events.WorkspaceUpdated, events.WorkspaceDeleted,
|
||||||
@@ -160,7 +160,7 @@ func isProjectableWebAppEvent(eventType string) bool {
|
|||||||
|
|
||||||
func publicEventFields(eventType string) []string {
|
func publicEventFields(eventType string) []string {
|
||||||
canvasFields := []string{
|
canvasFields := []string{
|
||||||
"canvas_id", "workspace_id", "task_id", "scope_kind", "status", "title", "plugin_id", "plugin_instance_id",
|
"canvas_id", "workspace_id", "task_id", "scope_kind", "data_scope_kind", "status", "title", "plugin_id", "plugin_instance_id",
|
||||||
"release_id", "active_release_id", "validation_status", "validation_error", "source_actor_kind", "source_user_id",
|
"release_id", "active_release_id", "validation_status", "validation_error", "source_actor_kind", "source_user_id",
|
||||||
"source_task_id", "source_session_id", "placement", "protocol_version",
|
"source_task_id", "source_session_id", "placement", "protocol_version",
|
||||||
}
|
}
|
||||||
@@ -234,7 +234,7 @@ func webAppBusEventMatchesInstance(item instances.Instance, scope webapp.EventSc
|
|||||||
if scope.WorkspaceID != "" && item.WorkspaceID != scope.WorkspaceID {
|
if scope.WorkspaceID != "" && item.WorkspaceID != scope.WorkspaceID {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
switch item.ScopeKind {
|
switch item.EffectiveDataScopeKind() {
|
||||||
case instances.ScopeInstance:
|
case instances.ScopeInstance:
|
||||||
return true
|
return true
|
||||||
case instances.ScopeWorkspace:
|
case instances.ScopeWorkspace:
|
||||||
@@ -290,7 +290,7 @@ func webAppEventMatchesBinding(scope webapp.EventScope, binding webapp.Capabilit
|
|||||||
if scope.InstanceID != "" && scope.InstanceID != binding.InstanceID {
|
if scope.InstanceID != "" && scope.InstanceID != binding.InstanceID {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
switch binding.ScopeKind {
|
switch instances.EffectiveDataScopeKind(binding.ScopeKind, binding.DataScopeKind) {
|
||||||
case instances.ScopeInstance:
|
case instances.ScopeInstance:
|
||||||
return true
|
return true
|
||||||
case instances.ScopeWorkspace:
|
case instances.ScopeWorkspace:
|
||||||
|
|||||||
@@ -10,9 +10,11 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func TestProjectWebAppEventDropsInternalPayloadFields(t *testing.T) {
|
func TestProjectWebAppEventDropsInternalPayloadFields(t *testing.T) {
|
||||||
event := bus.NewEvent(events.CanvasReleaseActivated, "canvas", map[string]any{
|
event := bus.NewEvent(events.CanvasWorkspaceDataEnabled, "canvas", map[string]any{
|
||||||
"canvas_id": "canvas-1",
|
"canvas_id": "canvas-1",
|
||||||
"workspace_id": "workspace-1",
|
"workspace_id": "workspace-1",
|
||||||
|
"scope_kind": "task",
|
||||||
|
"data_scope_kind": "workspace",
|
||||||
"release_id": "release-1",
|
"release_id": "release-1",
|
||||||
"active_release_id": "release-1",
|
"active_release_id": "release-1",
|
||||||
"secret": strings.Repeat("s", 128),
|
"secret": strings.Repeat("s", 128),
|
||||||
@@ -36,6 +38,10 @@ func TestProjectWebAppEventDropsInternalPayloadFields(t *testing.T) {
|
|||||||
if projected.Scope.WorkspaceID != "workspace-1" || projected.Scope.InstanceID != "" {
|
if projected.Scope.WorkspaceID != "workspace-1" || projected.Scope.InstanceID != "" {
|
||||||
t.Fatalf("projected scope = %+v", projected.Scope)
|
t.Fatalf("projected scope = %+v", projected.Scope)
|
||||||
}
|
}
|
||||||
|
projectedData, ok := projected.Data.(map[string]any)
|
||||||
|
if !ok || projectedData["data_scope_kind"] != "workspace" {
|
||||||
|
t.Fatalf("projected data = %#v, want workspace data scope", projected.Data)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestProjectWebAppEventDropsDependencyProjectionFields covers the refresh
|
// TestProjectWebAppEventDropsDependencyProjectionFields covers the refresh
|
||||||
|
|||||||
@@ -166,6 +166,7 @@ type webAppContext struct {
|
|||||||
WebAppKey string `json:"web_app_key"`
|
WebAppKey string `json:"web_app_key"`
|
||||||
Placement string `json:"placement"`
|
Placement string `json:"placement"`
|
||||||
ScopeKind string `json:"scope_kind"`
|
ScopeKind string `json:"scope_kind"`
|
||||||
|
DataScopeKind string `json:"data_scope_kind"`
|
||||||
WorkspaceID string `json:"workspace_id,omitempty"`
|
WorkspaceID string `json:"workspace_id,omitempty"`
|
||||||
TaskID string `json:"task_id,omitempty"`
|
TaskID string `json:"task_id,omitempty"`
|
||||||
SessionID string `json:"session_id,omitempty"`
|
SessionID string `json:"session_id,omitempty"`
|
||||||
@@ -190,6 +191,7 @@ func (s *Service) handleWebAppContext(w http.ResponseWriter, r *http.Request, bi
|
|||||||
WebAppKey: binding.WebAppKey,
|
WebAppKey: binding.WebAppKey,
|
||||||
Placement: binding.Placement,
|
Placement: binding.Placement,
|
||||||
ScopeKind: binding.ScopeKind,
|
ScopeKind: binding.ScopeKind,
|
||||||
|
DataScopeKind: instances.EffectiveDataScopeKind(binding.ScopeKind, binding.DataScopeKind),
|
||||||
WorkspaceID: binding.WorkspaceID,
|
WorkspaceID: binding.WorkspaceID,
|
||||||
TaskID: binding.TaskID,
|
TaskID: binding.TaskID,
|
||||||
SessionID: binding.SessionID,
|
SessionID: binding.SessionID,
|
||||||
@@ -364,6 +366,9 @@ func (s *Service) validateWebAppBinding(ctx context.Context, binding webapp.Capa
|
|||||||
}
|
}
|
||||||
instance, err := store.Get(ctx, binding.InstanceID)
|
instance, err := store.Get(ctx, binding.InstanceID)
|
||||||
if err != nil || instance.Status != instances.StatusActive || instance.PluginID != binding.PluginID ||
|
if err != nil || instance.Status != instances.StatusActive || instance.PluginID != binding.PluginID ||
|
||||||
|
instance.ScopeKind != binding.ScopeKind ||
|
||||||
|
instance.EffectiveDataScopeKind() != instances.EffectiveDataScopeKind(binding.ScopeKind, binding.DataScopeKind) ||
|
||||||
|
instance.WorkspaceID != binding.WorkspaceID || instance.TaskID != binding.TaskID ||
|
||||||
instance.ActiveReleaseID != binding.ReleaseID || instance.GrantGeneration != binding.GrantGeneration {
|
instance.ActiveReleaseID != binding.ReleaseID || instance.GrantGeneration != binding.GrantGeneration {
|
||||||
return webapp.ErrRuntimeTokenStale
|
return webapp.ErrRuntimeTokenStale
|
||||||
}
|
}
|
||||||
@@ -395,13 +400,14 @@ func (s *Service) validateWebAppPermissions(ctx context.Context, binding webapp.
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
dataScope := instances.EffectiveDataScopeKind(binding.ScopeKind, binding.DataScopeKind)
|
||||||
for _, permission := range webAppDeclaredPermissions(m, binding.WebAppKey) {
|
for _, permission := range webAppDeclaredPermissions(m, binding.WebAppKey) {
|
||||||
if !webAppGrantCovers(permission, binding.ScopeKind, grants) {
|
if !webAppGrantCovers(permission, dataScope, grants) {
|
||||||
return errors.New("web app permission grant is stale")
|
return errors.New("web app permission grant is stale")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for _, origin := range binding.NetworkOrigins {
|
for _, origin := range binding.NetworkOrigins {
|
||||||
if !webAppOriginGranted(origin, binding.ScopeKind, grants) {
|
if !webAppOriginGranted(origin, dataScope, grants) {
|
||||||
return errors.New("web app network grant is stale")
|
return errors.New("web app network grant is stale")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ func (s *Service) listWebAppTasks(ctx context.Context, w http.ResponseWriter, r
|
|||||||
IncludeArchived: includeArchived,
|
IncludeArchived: includeArchived,
|
||||||
IncludeEphemeral: true,
|
IncludeEphemeral: true,
|
||||||
}
|
}
|
||||||
if binding.ScopeKind == instances.ScopeTask {
|
if instances.EffectiveDataScopeKind(binding.ScopeKind, binding.DataScopeKind) == instances.ScopeTask {
|
||||||
// Resolved via the non-attaching fetchTask, and dependencies attached
|
// Resolved via the non-attaching fetchTask, and dependencies attached
|
||||||
// only below, after the discard checks: matching the attachment rule
|
// only below, after the discard checks: matching the attachment rule
|
||||||
// the non-task-scope branch below already documents for itself.
|
// the non-task-scope branch below already documents for itself.
|
||||||
@@ -278,7 +278,7 @@ func (s *Service) listWebAppWorkflows(ctx context.Context, w http.ResponseWriter
|
|||||||
}
|
}
|
||||||
workflows = append(workflows, items...)
|
workflows = append(workflows, items...)
|
||||||
}
|
}
|
||||||
if binding.ScopeKind == instances.ScopeTask {
|
if instances.EffectiveDataScopeKind(binding.ScopeKind, binding.DataScopeKind) == instances.ScopeTask {
|
||||||
task, err := host.fetchTaskForScopeCheck(ctx, binding.TaskID)
|
task, err := host.fetchTaskForScopeCheck(ctx, binding.TaskID)
|
||||||
if err != nil || task == nil {
|
if err != nil || task == nil {
|
||||||
writeWebAppError(w, webAppProtocolStatus(err), webAppErrorCode(err))
|
writeWebAppError(w, webAppProtocolStatus(err), webAppErrorCode(err))
|
||||||
@@ -318,7 +318,7 @@ func (s *Service) listWebAppWorkflowSteps(ctx context.Context, w http.ResponseWr
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if binding.ScopeKind == instances.ScopeTask {
|
if instances.EffectiveDataScopeKind(binding.ScopeKind, binding.DataScopeKind) == instances.ScopeTask {
|
||||||
task, err := host.fetchTaskForScopeCheck(ctx, binding.TaskID)
|
task, err := host.fetchTaskForScopeCheck(ctx, binding.TaskID)
|
||||||
if err != nil || task == nil || task.WorkflowID != workflowID {
|
if err != nil || task == nil || task.WorkflowID != workflowID {
|
||||||
writeWebAppError(w, http.StatusNotFound, "not_found")
|
writeWebAppError(w, http.StatusNotFound, "not_found")
|
||||||
@@ -380,13 +380,14 @@ func (s *Service) webAppWorkflowWorkspaceIDs(ctx context.Context, r *http.Reques
|
|||||||
}
|
}
|
||||||
|
|
||||||
func webAppTaskMatches(ctx context.Context, host *pluginHost, binding webapp.CapabilityBinding, task pluginsdk.Task) bool {
|
func webAppTaskMatches(ctx context.Context, host *pluginHost, binding webapp.CapabilityBinding, task pluginsdk.Task) bool {
|
||||||
if binding.ScopeKind == instances.ScopeInstance {
|
dataScope := instances.EffectiveDataScopeKind(binding.ScopeKind, binding.DataScopeKind)
|
||||||
|
if dataScope == instances.ScopeInstance {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
if task.WorkspaceID != binding.WorkspaceID {
|
if task.WorkspaceID != binding.WorkspaceID {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
switch binding.ScopeKind {
|
switch dataScope {
|
||||||
case instances.ScopeWorkspace:
|
case instances.ScopeWorkspace:
|
||||||
return true
|
return true
|
||||||
case instances.ScopeTask:
|
case instances.ScopeTask:
|
||||||
|
|||||||
@@ -135,6 +135,47 @@ func TestListWebAppTasks_DerivesDependenciesAfterScopeNarrowing(t *testing.T) {
|
|||||||
require.Contains(t, recorder.Body.String(), `"blocked":true`)
|
require.Contains(t, recorder.Body.String(), `"blocked":true`)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestListWebAppTasks_TaskPlacementUsesWorkspaceDataScopeAndPagination(t *testing.T) {
|
||||||
|
d := newTestDataHost(manifest.Capabilities{APIRead: []string{"tasks"}})
|
||||||
|
d.tasks.workspaces = []*taskmodels.Workspace{{ID: "ws-1"}, {ID: "ws-2"}}
|
||||||
|
d.tasks.tasksByWorkspace = map[string][]*taskmodels.Task{
|
||||||
|
"ws-1": {
|
||||||
|
{ID: "task-a", WorkspaceID: "ws-1", CreatedAt: time.Date(2026, 1, 3, 0, 0, 0, 0, time.UTC)},
|
||||||
|
{ID: "task-b", WorkspaceID: "ws-1", CreatedAt: time.Date(2026, 1, 2, 0, 0, 0, 0, time.UTC)},
|
||||||
|
},
|
||||||
|
"ws-2": {{ID: "foreign", WorkspaceID: "ws-2", CreatedAt: time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)}},
|
||||||
|
}
|
||||||
|
svc := &Service{taskData: d.tasks}
|
||||||
|
binding := webapp.CapabilityBinding{
|
||||||
|
ScopeKind: instances.ScopeTask, DataScopeKind: instances.ScopeWorkspace,
|
||||||
|
WorkspaceID: "ws-1", TaskID: "task-a", Permissions: []string{"api_read:tasks"},
|
||||||
|
}
|
||||||
|
|
||||||
|
first := httptest.NewRecorder()
|
||||||
|
svc.handleWebAppProtocol(first, httptest.NewRequest(http.MethodGet, "/?limit=1", nil), "", binding, "v1/data/tasks")
|
||||||
|
require.Equal(t, http.StatusOK, first.Code, first.Body.String())
|
||||||
|
var firstPage webAppPage[webAppTask]
|
||||||
|
require.NoError(t, json.Unmarshal(first.Body.Bytes(), &firstPage))
|
||||||
|
require.Len(t, firstPage.Items, 1)
|
||||||
|
require.Equal(t, "task-a", firstPage.Items[0].ID)
|
||||||
|
require.True(t, firstPage.PageInfo.HasMore)
|
||||||
|
require.NotEmpty(t, firstPage.PageInfo.NextCursor)
|
||||||
|
|
||||||
|
second := httptest.NewRecorder()
|
||||||
|
secondRequest := httptest.NewRequest(http.MethodGet, "/?limit=1&cursor="+firstPage.PageInfo.NextCursor, nil)
|
||||||
|
svc.handleWebAppProtocol(second, secondRequest, "", binding, "v1/data/tasks")
|
||||||
|
require.Equal(t, http.StatusOK, second.Code, second.Body.String())
|
||||||
|
var secondPage webAppPage[webAppTask]
|
||||||
|
require.NoError(t, json.Unmarshal(second.Body.Bytes(), &secondPage))
|
||||||
|
require.Len(t, secondPage.Items, 1)
|
||||||
|
require.Equal(t, "task-b", secondPage.Items[0].ID)
|
||||||
|
|
||||||
|
foreign := httptest.NewRecorder()
|
||||||
|
foreignRequest := httptest.NewRequest(http.MethodGet, "/?workspace_id=ws-2", nil)
|
||||||
|
svc.handleWebAppProtocol(foreign, foreignRequest, "", binding, "v1/data/tasks")
|
||||||
|
require.Equal(t, http.StatusForbidden, foreign.Code)
|
||||||
|
}
|
||||||
|
|
||||||
func TestListWebAppTasks_FanOutRefusalBecomesResponseTooLarge(t *testing.T) {
|
func TestListWebAppTasks_FanOutRefusalBecomesResponseTooLarge(t *testing.T) {
|
||||||
d := newTestDataHost(manifest.Capabilities{APIRead: []string{"tasks"}})
|
d := newTestDataHost(manifest.Capabilities{APIRead: []string{"tasks"}})
|
||||||
task := &taskmodels.Task{ID: "task-1", WorkspaceID: "ws-1"}
|
task := &taskmodels.Task{ID: "task-1", WorkspaceID: "ws-1"}
|
||||||
|
|||||||
@@ -119,7 +119,11 @@ func dependencyRedactionScopeFromBinding(binding webapp.CapabilityBinding, direc
|
|||||||
for _, id := range directlyReadableIDs {
|
for _, id := range directlyReadableIDs {
|
||||||
ids[id] = struct{}{}
|
ids[id] = struct{}{}
|
||||||
}
|
}
|
||||||
return dependencyRedactionScope{kind: binding.ScopeKind, workspaceID: binding.WorkspaceID, directlyReadableIDs: ids}
|
return dependencyRedactionScope{
|
||||||
|
kind: instances.EffectiveDataScopeKind(binding.ScopeKind, binding.DataScopeKind),
|
||||||
|
workspaceID: binding.WorkspaceID,
|
||||||
|
directlyReadableIDs: ids,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// admits reports whether ref may be disclosed as a directly readable task
|
// admits reports whether ref may be disclosed as a directly readable task
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ func TestWebAppProtocolContextDoesNotExposeCredentials(t *testing.T) {
|
|||||||
binding := webapp.CapabilityBinding{
|
binding := webapp.CapabilityBinding{
|
||||||
UserID: "user-1", InstanceID: "instance-1", PluginID: "plugin-1",
|
UserID: "user-1", InstanceID: "instance-1", PluginID: "plugin-1",
|
||||||
ReleaseID: "release-1", WebAppKey: "board", Placement: "task-canvas",
|
ReleaseID: "release-1", WebAppKey: "board", Placement: "task-canvas",
|
||||||
ScopeKind: "task", WorkspaceID: "workspace-1", TaskID: "task-1",
|
ScopeKind: "task", DataScopeKind: "workspace", WorkspaceID: "workspace-1", TaskID: "task-1",
|
||||||
Permissions: []string{"api_read:tasks", "state"},
|
Permissions: []string{"api_read:tasks", "state"},
|
||||||
}
|
}
|
||||||
request := httptest.NewRequest(http.MethodGet, "/", nil)
|
request := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||||
@@ -49,7 +49,7 @@ func TestWebAppProtocolContextDoesNotExposeCredentials(t *testing.T) {
|
|||||||
if _, ok := body["token"]; ok {
|
if _, ok := body["token"]; ok {
|
||||||
t.Fatal("context exposed token")
|
t.Fatal("context exposed token")
|
||||||
}
|
}
|
||||||
if body["instance_id"] != "instance-1" || body["protocol_version"] != float64(1) {
|
if body["instance_id"] != "instance-1" || body["protocol_version"] != float64(1) || body["scope_kind"] != "task" || body["data_scope_kind"] != "workspace" {
|
||||||
t.Fatalf("context = %#v", body)
|
t.Fatalf("context = %#v", body)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -85,7 +85,7 @@ func (s *Service) listWebAppWorkflowTransitionGroups(ctx context.Context, w http
|
|||||||
writeWebAppError(w, http.StatusNotFound, "not_found")
|
writeWebAppError(w, http.StatusNotFound, "not_found")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if binding.ScopeKind != instances.ScopeWorkspace || binding.WorkspaceID == "" || !host.capabilities.CanRead(resourceTasks) || !host.capabilities.CanRead(resourceWorkflows) {
|
if instances.EffectiveDataScopeKind(binding.ScopeKind, binding.DataScopeKind) != instances.ScopeWorkspace || binding.WorkspaceID == "" || !host.capabilities.CanRead(resourceTasks) || !host.capabilities.CanRead(resourceWorkflows) {
|
||||||
writeWebAppError(w, http.StatusForbidden, "plugin_permission_denied")
|
writeWebAppError(w, http.StatusForbidden, "plugin_permission_denied")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -71,14 +71,16 @@ func TestWebAppTransitionGroupsRequireWorkspaceAndBothGrants(t *testing.T) {
|
|||||||
return recorder
|
return recorder
|
||||||
}
|
}
|
||||||
require.Equal(t, http.StatusForbidden, call("wf-1").Code)
|
require.Equal(t, http.StatusForbidden, call("wf-1").Code)
|
||||||
binding.ScopeKind = instances.ScopeWorkspace
|
binding.DataScopeKind = instances.ScopeWorkspace
|
||||||
|
require.Equal(t, http.StatusOK, call("wf-1").Code)
|
||||||
|
require.Equal(t, 1, source.groupCalls)
|
||||||
binding.Permissions = []string{"api_read:workflows"}
|
binding.Permissions = []string{"api_read:workflows"}
|
||||||
require.Equal(t, http.StatusForbidden, call("wf-1").Code)
|
require.Equal(t, http.StatusForbidden, call("wf-1").Code)
|
||||||
binding.Permissions = []string{"api_read:tasks", "api_read:workflows"}
|
binding.Permissions = []string{"api_read:tasks", "api_read:workflows"}
|
||||||
require.Equal(t, http.StatusNotFound, call("foreign-wf").Code)
|
require.Equal(t, http.StatusNotFound, call("foreign-wf").Code)
|
||||||
require.Zero(t, source.groupCalls)
|
require.Equal(t, 1, source.groupCalls)
|
||||||
allowed := call("wf-1")
|
allowed := call("wf-1")
|
||||||
require.Equal(t, http.StatusOK, allowed.Code, allowed.Body.String())
|
require.Equal(t, http.StatusOK, allowed.Code, allowed.Body.String())
|
||||||
require.Equal(t, 1, source.groupCalls)
|
require.Equal(t, 2, source.groupCalls)
|
||||||
require.Contains(t, allowed.Body.String(), `"count":2`)
|
require.Contains(t, allowed.Body.String(), `"count":2`)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,7 +28,8 @@ func TestCreateCanvasBuiltin(t *testing.T) {
|
|||||||
"source directory",
|
"source directory",
|
||||||
"authorized live Kandev data",
|
"authorized live Kandev data",
|
||||||
"awaits permission review",
|
"awaits permission review",
|
||||||
"requires promotion",
|
"adds workspace navigation",
|
||||||
|
"Do not claim that promotion is required to preview",
|
||||||
"local build alone does not publish",
|
"local build alone does not publish",
|
||||||
} {
|
} {
|
||||||
if !strings.Contains(prompt.Content, want) {
|
if !strings.Contains(prompt.Content, want) {
|
||||||
|
|||||||
@@ -107,6 +107,7 @@ const (
|
|||||||
ActionCanvasReleaseActivated = "canvas.release.activated"
|
ActionCanvasReleaseActivated = "canvas.release.activated"
|
||||||
ActionCanvasReleasePermissionRequired = "canvas.release.permission_required"
|
ActionCanvasReleasePermissionRequired = "canvas.release.permission_required"
|
||||||
ActionCanvasPromoted = "canvas.promoted"
|
ActionCanvasPromoted = "canvas.promoted"
|
||||||
|
ActionCanvasWorkspaceDataEnabled = "canvas.workspace_data_enabled"
|
||||||
ActionCanvasArchived = "canvas.archived"
|
ActionCanvasArchived = "canvas.archived"
|
||||||
ActionCanvasRestored = "canvas.restored"
|
ActionCanvasRestored = "canvas.restored"
|
||||||
ActionCanvasRemoved = "canvas.removed"
|
ActionCanvasRemoved = "canvas.removed"
|
||||||
|
|||||||
@@ -4,12 +4,18 @@ import { IconDots } from "@tabler/icons-react";
|
|||||||
import { Button } from "@kandev/ui/button";
|
import { Button } from "@kandev/ui/button";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import type { Canvas } from "@/lib/api/domains/canvas-api";
|
import type { Canvas } from "@/lib/api/domains/canvas-api";
|
||||||
import { CanvasPromotionDialog, CanvasReleaseDialog } from "./canvas-lifecycle-dialogs";
|
import {
|
||||||
|
CanvasPromotionDialog,
|
||||||
|
CanvasReleaseDialog,
|
||||||
|
CanvasWorkspaceDataDialog,
|
||||||
|
} from "./canvas-lifecycle-dialogs";
|
||||||
|
|
||||||
export function CanvasHostDialogs({
|
export function CanvasHostDialogs({
|
||||||
canvas,
|
canvas,
|
||||||
promotionOpen,
|
promotionOpen,
|
||||||
onPromotionOpenChange,
|
onPromotionOpenChange,
|
||||||
|
workspaceDataOpen,
|
||||||
|
onWorkspaceDataOpenChange,
|
||||||
releasesOpen,
|
releasesOpen,
|
||||||
onReleasesOpenChange,
|
onReleasesOpenChange,
|
||||||
onPromotionCompleted,
|
onPromotionCompleted,
|
||||||
@@ -18,6 +24,8 @@ export function CanvasHostDialogs({
|
|||||||
canvas: Canvas | null;
|
canvas: Canvas | null;
|
||||||
promotionOpen: boolean;
|
promotionOpen: boolean;
|
||||||
onPromotionOpenChange: (open: boolean) => void;
|
onPromotionOpenChange: (open: boolean) => void;
|
||||||
|
workspaceDataOpen: boolean;
|
||||||
|
onWorkspaceDataOpenChange: (open: boolean) => void;
|
||||||
releasesOpen: boolean;
|
releasesOpen: boolean;
|
||||||
onReleasesOpenChange: (open: boolean) => void;
|
onReleasesOpenChange: (open: boolean) => void;
|
||||||
onPromotionCompleted: () => void;
|
onPromotionCompleted: () => void;
|
||||||
@@ -31,6 +39,12 @@ export function CanvasHostDialogs({
|
|||||||
onOpenChange={onPromotionOpenChange}
|
onOpenChange={onPromotionOpenChange}
|
||||||
onCompleted={onPromotionCompleted}
|
onCompleted={onPromotionCompleted}
|
||||||
/>
|
/>
|
||||||
|
<CanvasWorkspaceDataDialog
|
||||||
|
canvas={canvas?.scope_kind === "task" ? canvas : null}
|
||||||
|
open={workspaceDataOpen}
|
||||||
|
onOpenChange={onWorkspaceDataOpenChange}
|
||||||
|
onCompleted={onChanged}
|
||||||
|
/>
|
||||||
<CanvasReleaseDialog
|
<CanvasReleaseDialog
|
||||||
canvas={canvas}
|
canvas={canvas}
|
||||||
open={releasesOpen}
|
open={releasesOpen}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { cleanup, render, screen } from "@testing-library/react";
|
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
|
||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
import { DropdownMenu, DropdownMenuContent } from "@kandev/ui/dropdown-menu";
|
import { DropdownMenu, DropdownMenuContent } from "@kandev/ui/dropdown-menu";
|
||||||
@@ -8,6 +8,12 @@ const COPY: Record<string, string> = {
|
|||||||
"canvases:editCanvas": "Edit canvas",
|
"canvases:editCanvas": "Edit canvas",
|
||||||
"canvases:releasesAndPermissions": "Releases and permissions",
|
"canvases:releasesAndPermissions": "Releases and permissions",
|
||||||
"canvases:promoteCanvas": "Promote canvas",
|
"canvases:promoteCanvas": "Promote canvas",
|
||||||
|
"canvases:enableWorkspaceData": "Enable workspace data",
|
||||||
|
"canvases:enableWorkspaceDataHelp": "Enable declared workspace data access.",
|
||||||
|
"canvases:taskDataScope": "Task data",
|
||||||
|
"canvases:workspaceDataScope": "Workspace data",
|
||||||
|
"canvases:taskPlacementScope": "Task",
|
||||||
|
"canvases:workspacePlacementScope": "Workspace",
|
||||||
"canvases:canvasActions": "Canvas actions",
|
"canvases:canvasActions": "Canvas actions",
|
||||||
"canvases:canvases": "Canvases",
|
"canvases:canvases": "Canvases",
|
||||||
"canvases:openInNewTab": "Open in new tab",
|
"canvases:openInNewTab": "Open in new tab",
|
||||||
@@ -45,6 +51,7 @@ vi.mock("@/components/plugins/canvas-page", () => ({ CanvasPage: () => null }));
|
|||||||
vi.mock("./canvas-lifecycle-dialogs", () => ({
|
vi.mock("./canvas-lifecycle-dialogs", () => ({
|
||||||
CanvasPromotionDialog: () => null,
|
CanvasPromotionDialog: () => null,
|
||||||
CanvasReleaseDialog: () => null,
|
CanvasReleaseDialog: () => null,
|
||||||
|
CanvasWorkspaceDataDialog: () => null,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@@ -160,12 +167,56 @@ describe("canvas host action guidance", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("legacy workspace data action", () => {
|
||||||
|
it("offers the legacy workspace data review on desktop and mobile actions", () => {
|
||||||
|
const legacyCanvas = {
|
||||||
|
...canvas,
|
||||||
|
status: "active" as const,
|
||||||
|
active_release_status: "valid" as const,
|
||||||
|
};
|
||||||
|
const onEnableWorkspaceData = vi.fn();
|
||||||
|
render(
|
||||||
|
<>
|
||||||
|
<CanvasDesktopActions
|
||||||
|
canvas={legacyCanvas}
|
||||||
|
editing={false}
|
||||||
|
onEdit={vi.fn()}
|
||||||
|
onPromote={vi.fn()}
|
||||||
|
onReleases={vi.fn()}
|
||||||
|
onShare={vi.fn()}
|
||||||
|
onEnableWorkspaceData={onEnableWorkspaceData}
|
||||||
|
/>
|
||||||
|
<MobileCanvasActions
|
||||||
|
canvas={legacyCanvas}
|
||||||
|
canvases={[legacyCanvas]}
|
||||||
|
open
|
||||||
|
onOpenChange={vi.fn()}
|
||||||
|
onEdit={vi.fn()}
|
||||||
|
onPromote={vi.fn()}
|
||||||
|
onReleases={vi.fn()}
|
||||||
|
onShare={vi.fn()}
|
||||||
|
onRename={vi.fn()}
|
||||||
|
onEnableWorkspaceData={onEnableWorkspaceData}
|
||||||
|
onSelectCanvas={vi.fn()}
|
||||||
|
editing={false}
|
||||||
|
/>
|
||||||
|
</>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const buttons = screen.getAllByRole("button", { name: "Enable workspace data" });
|
||||||
|
expect(buttons).toHaveLength(2);
|
||||||
|
buttons.forEach((button) => fireEvent.click(button));
|
||||||
|
expect(onEnableWorkspaceData).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("canvas host chrome", () => {
|
describe("canvas host chrome", () => {
|
||||||
it("keeps state content in the body and leaves the shared header for title/actions", () => {
|
it("keeps state content in the body and leaves the shared header for title/actions", () => {
|
||||||
render(
|
render(
|
||||||
<>
|
<>
|
||||||
<CanvasHostHeader
|
<CanvasHostHeader
|
||||||
title="Task canvas"
|
title="Task canvas"
|
||||||
|
dataScopeLabel="Workspace data"
|
||||||
isMobile={false}
|
isMobile={false}
|
||||||
menuOpen={false}
|
menuOpen={false}
|
||||||
onOpenActions={vi.fn()}
|
onOpenActions={vi.fn()}
|
||||||
@@ -177,6 +228,7 @@ describe("canvas host chrome", () => {
|
|||||||
|
|
||||||
expect(screen.getByTestId("canvas-host-header").textContent).toContain("Task canvas");
|
expect(screen.getByTestId("canvas-host-header").textContent).toContain("Task canvas");
|
||||||
expect(screen.getByTestId("canvas-host-header").textContent).toContain("Release actions");
|
expect(screen.getByTestId("canvas-host-header").textContent).toContain("Release actions");
|
||||||
|
expect(screen.getByTestId("canvas-data-scope").textContent).toBe("Workspace data");
|
||||||
expect(screen.getByTestId("canvas-host-state").textContent).toContain("Canvas unavailable");
|
expect(screen.getByTestId("canvas-host-state").textContent).toContain("Canvas unavailable");
|
||||||
expect(
|
expect(
|
||||||
screen
|
screen
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
import {
|
import {
|
||||||
|
IconDatabase,
|
||||||
IconEdit,
|
IconEdit,
|
||||||
IconExternalLink,
|
IconExternalLink,
|
||||||
IconLayoutGrid,
|
IconLayoutGrid,
|
||||||
@@ -15,7 +16,11 @@ import { Button } from "@kandev/ui/button";
|
|||||||
import { MobilePickerSheet } from "@/components/task/mobile/mobile-picker-sheet";
|
import { MobilePickerSheet } from "@/components/task/mobile/mobile-picker-sheet";
|
||||||
import { PanelHeaderBarSplit } from "@/components/task/panel-primitives";
|
import { PanelHeaderBarSplit } from "@/components/task/panel-primitives";
|
||||||
import { CanvasPage } from "@/components/plugins/canvas-page";
|
import { CanvasPage } from "@/components/plugins/canvas-page";
|
||||||
import { canvasHref, type Canvas } from "@/lib/api/domains/canvas-api";
|
import {
|
||||||
|
canvasCanEnableWorkspaceData,
|
||||||
|
canvasHref,
|
||||||
|
type Canvas,
|
||||||
|
} from "@/lib/api/domains/canvas-api";
|
||||||
import { CanvasMobileActionsButton } from "./canvas-host-actions";
|
import { CanvasMobileActionsButton } from "./canvas-host-actions";
|
||||||
import { canvasLockHelp, canvasPromotionHelp } from "./canvas-host-desktop-actions";
|
import { canvasLockHelp, canvasPromotionHelp } from "./canvas-host-desktop-actions";
|
||||||
export { CanvasHostDialogs, CanvasMobileActionsButton } from "./canvas-host-actions";
|
export { CanvasHostDialogs, CanvasMobileActionsButton } from "./canvas-host-actions";
|
||||||
@@ -137,6 +142,7 @@ export function CanvasHostBody({
|
|||||||
|
|
||||||
export function CanvasHostHeader({
|
export function CanvasHostHeader({
|
||||||
title,
|
title,
|
||||||
|
dataScopeLabel,
|
||||||
isMobile,
|
isMobile,
|
||||||
menuOpen,
|
menuOpen,
|
||||||
onOpenActions,
|
onOpenActions,
|
||||||
@@ -145,6 +151,7 @@ export function CanvasHostHeader({
|
|||||||
overflowActions,
|
overflowActions,
|
||||||
}: {
|
}: {
|
||||||
title: string;
|
title: string;
|
||||||
|
dataScopeLabel?: string;
|
||||||
isMobile: boolean;
|
isMobile: boolean;
|
||||||
menuOpen: boolean;
|
menuOpen: boolean;
|
||||||
onOpenActions: () => void;
|
onOpenActions: () => void;
|
||||||
@@ -157,7 +164,16 @@ export function CanvasHostHeader({
|
|||||||
data-testid="canvas-host-header"
|
data-testid="canvas-host-header"
|
||||||
left={
|
left={
|
||||||
<div className="flex min-w-0 items-center gap-1">
|
<div className="flex min-w-0 items-center gap-1">
|
||||||
<span className="truncate text-sm font-medium">{title}</span>
|
<span className="block min-w-0 flex-1 truncate text-sm font-medium">{title}</span>
|
||||||
|
{dataScopeLabel && (
|
||||||
|
<span
|
||||||
|
className="max-w-20 shrink-0 truncate text-xs text-muted-foreground"
|
||||||
|
data-testid="canvas-data-scope"
|
||||||
|
title={dataScopeLabel}
|
||||||
|
>
|
||||||
|
{dataScopeLabel}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
{renameAction}
|
{renameAction}
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
@@ -327,6 +343,35 @@ function MobileCanvasPromoteAction({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function MobileCanvasWorkspaceDataAction({
|
||||||
|
canvas,
|
||||||
|
onEnable,
|
||||||
|
t,
|
||||||
|
}: {
|
||||||
|
canvas: Canvas | null;
|
||||||
|
onEnable?: () => void;
|
||||||
|
t: (key: string) => string;
|
||||||
|
}) {
|
||||||
|
if (!canvas || !onEnable || !canvasCanEnableWorkspaceData(canvas)) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<MobileCanvasAction
|
||||||
|
description={t("canvases:enableWorkspaceDataHelp")}
|
||||||
|
testId="canvas-action-enable-workspace-data-help"
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
className="min-h-11 w-full justify-start cursor-pointer"
|
||||||
|
onClick={onEnable}
|
||||||
|
data-testid="canvas-action-enable-workspace-data"
|
||||||
|
>
|
||||||
|
<IconDatabase className="mr-2 h-4 w-4" />
|
||||||
|
{t("canvases:enableWorkspaceData")}
|
||||||
|
</Button>
|
||||||
|
</MobileCanvasAction>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function MobileCanvasShareAction({
|
function MobileCanvasShareAction({
|
||||||
onShare,
|
onShare,
|
||||||
t,
|
t,
|
||||||
@@ -377,6 +422,7 @@ export function MobileCanvasActions({
|
|||||||
onReleases,
|
onReleases,
|
||||||
onShare,
|
onShare,
|
||||||
onRename,
|
onRename,
|
||||||
|
onEnableWorkspaceData,
|
||||||
onSelectCanvas,
|
onSelectCanvas,
|
||||||
editing,
|
editing,
|
||||||
}: {
|
}: {
|
||||||
@@ -389,6 +435,7 @@ export function MobileCanvasActions({
|
|||||||
onReleases: () => void;
|
onReleases: () => void;
|
||||||
onShare: () => void;
|
onShare: () => void;
|
||||||
onRename: () => void;
|
onRename: () => void;
|
||||||
|
onEnableWorkspaceData?: () => void;
|
||||||
onSelectCanvas: (canvas: Canvas) => void;
|
onSelectCanvas: (canvas: Canvas) => void;
|
||||||
editing: boolean;
|
editing: boolean;
|
||||||
}) {
|
}) {
|
||||||
@@ -427,6 +474,7 @@ export function MobileCanvasActions({
|
|||||||
)}
|
)}
|
||||||
<MobileCanvasReleasesAction onReleases={onReleases} t={t} />
|
<MobileCanvasReleasesAction onReleases={onReleases} t={t} />
|
||||||
{canvas && <MobileCanvasShareAction onShare={onShare} t={t} />}
|
{canvas && <MobileCanvasShareAction onShare={onShare} t={t} />}
|
||||||
|
<MobileCanvasWorkspaceDataAction canvas={canvas} onEnable={onEnableWorkspaceData} t={t} />
|
||||||
{canvas?.scope_kind === "task" && (
|
{canvas?.scope_kind === "task" && (
|
||||||
<MobileCanvasPromoteAction
|
<MobileCanvasPromoteAction
|
||||||
canvas={canvas}
|
canvas={canvas}
|
||||||
|
|||||||
@@ -1,13 +1,19 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
import { IconEdit, IconListDetails, IconShare3, IconSparkles } from "@tabler/icons-react";
|
import {
|
||||||
|
IconDatabase,
|
||||||
|
IconEdit,
|
||||||
|
IconListDetails,
|
||||||
|
IconShare3,
|
||||||
|
IconSparkles,
|
||||||
|
} from "@tabler/icons-react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Button } from "@kandev/ui/button";
|
import { Button } from "@kandev/ui/button";
|
||||||
import { DropdownMenuItem } from "@kandev/ui/dropdown-menu";
|
import { DropdownMenuItem } from "@kandev/ui/dropdown-menu";
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip";
|
import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip";
|
||||||
import { PanelHeaderOverflowMenu } from "@/components/task/panel-primitives";
|
import { PanelHeaderOverflowMenu } from "@/components/task/panel-primitives";
|
||||||
import { type Canvas } from "@/lib/api/domains/canvas-api";
|
import { canvasCanEnableWorkspaceData, type Canvas } from "@/lib/api/domains/canvas-api";
|
||||||
|
|
||||||
export function canvasLockHelp(canvas: Canvas, t: (key: string) => string): string {
|
export function canvasLockHelp(canvas: Canvas, t: (key: string) => string): string {
|
||||||
return canvas.status === "archived"
|
return canvas.status === "archived"
|
||||||
@@ -59,6 +65,7 @@ export function CanvasDesktopActions({
|
|||||||
onPromote,
|
onPromote,
|
||||||
onReleases,
|
onReleases,
|
||||||
onShare,
|
onShare,
|
||||||
|
onEnableWorkspaceData,
|
||||||
}: {
|
}: {
|
||||||
canvas: Canvas;
|
canvas: Canvas;
|
||||||
editing: boolean;
|
editing: boolean;
|
||||||
@@ -66,6 +73,7 @@ export function CanvasDesktopActions({
|
|||||||
onPromote: () => void;
|
onPromote: () => void;
|
||||||
onReleases: () => void;
|
onReleases: () => void;
|
||||||
onShare: () => void;
|
onShare: () => void;
|
||||||
|
onEnableWorkspaceData?: () => void;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const lifecycleLocked = canvas.status === "archived" || canvas.status === "disabled";
|
const lifecycleLocked = canvas.status === "archived" || canvas.status === "disabled";
|
||||||
@@ -112,6 +120,24 @@ export function CanvasDesktopActions({
|
|||||||
{t("canvases:shareCanvas")}
|
{t("canvases:shareCanvas")}
|
||||||
</Button>
|
</Button>
|
||||||
</CanvasDesktopActionTooltip>
|
</CanvasDesktopActionTooltip>
|
||||||
|
{onEnableWorkspaceData && canvasCanEnableWorkspaceData(canvas) && (
|
||||||
|
<CanvasDesktopActionTooltip
|
||||||
|
description={t("canvases:enableWorkspaceDataHelp")}
|
||||||
|
disabled={false}
|
||||||
|
testId="canvas-action-enable-workspace-data-tooltip-trigger"
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="cursor-pointer"
|
||||||
|
onClick={onEnableWorkspaceData}
|
||||||
|
data-testid="canvas-action-enable-workspace-data"
|
||||||
|
>
|
||||||
|
<IconDatabase className="mr-1.5 h-3.5 w-3.5" />
|
||||||
|
{t("canvases:enableWorkspaceData")}
|
||||||
|
</Button>
|
||||||
|
</CanvasDesktopActionTooltip>
|
||||||
|
)}
|
||||||
{canvas.scope_kind === "task" && (
|
{canvas.scope_kind === "task" && (
|
||||||
<CanvasDesktopActionTooltip
|
<CanvasDesktopActionTooltip
|
||||||
description={promoteDescription}
|
description={promoteDescription}
|
||||||
@@ -140,9 +166,33 @@ type CanvasDesktopOverflowActionsProps = {
|
|||||||
onPromote: () => void;
|
onPromote: () => void;
|
||||||
onReleases: () => void;
|
onReleases: () => void;
|
||||||
onShare: () => void;
|
onShare: () => void;
|
||||||
|
onEnableWorkspaceData?: () => void;
|
||||||
omitPrimaryAction?: boolean;
|
omitPrimaryAction?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function CanvasWorkspaceDataMenuAction({
|
||||||
|
canvas,
|
||||||
|
onEnableWorkspaceData,
|
||||||
|
t,
|
||||||
|
}: {
|
||||||
|
canvas: Canvas;
|
||||||
|
onEnableWorkspaceData?: () => void;
|
||||||
|
t: (key: string) => string;
|
||||||
|
}) {
|
||||||
|
if (!onEnableWorkspaceData || !canvasCanEnableWorkspaceData(canvas)) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DropdownMenuItem
|
||||||
|
className="cursor-pointer gap-2"
|
||||||
|
onSelect={onEnableWorkspaceData}
|
||||||
|
data-testid="canvas-action-enable-workspace-data"
|
||||||
|
>
|
||||||
|
<IconDatabase className="size-4" />
|
||||||
|
{t("canvases:enableWorkspaceData")}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function CanvasDesktopPrimaryAction({
|
export function CanvasDesktopPrimaryAction({
|
||||||
canvas,
|
canvas,
|
||||||
editing,
|
editing,
|
||||||
@@ -197,6 +247,7 @@ function CanvasDesktopOverflowMenuItemsContent({
|
|||||||
onPromote,
|
onPromote,
|
||||||
onReleases,
|
onReleases,
|
||||||
onShare,
|
onShare,
|
||||||
|
onEnableWorkspaceData,
|
||||||
omitPrimaryAction,
|
omitPrimaryAction,
|
||||||
t,
|
t,
|
||||||
}: CanvasDesktopOverflowActionsProps & { t: (key: string) => string }) {
|
}: CanvasDesktopOverflowActionsProps & { t: (key: string) => string }) {
|
||||||
@@ -238,6 +289,11 @@ function CanvasDesktopOverflowMenuItemsContent({
|
|||||||
<IconShare3 className="size-4" />
|
<IconShare3 className="size-4" />
|
||||||
{t("canvases:shareCanvas")}
|
{t("canvases:shareCanvas")}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
|
<CanvasWorkspaceDataMenuAction
|
||||||
|
canvas={canvas}
|
||||||
|
onEnableWorkspaceData={onEnableWorkspaceData}
|
||||||
|
t={t}
|
||||||
|
/>
|
||||||
{canvas.scope_kind === "task" && !omitPrimaryAction && (
|
{canvas.scope_kind === "task" && !omitPrimaryAction && (
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
className="cursor-pointer gap-2"
|
className="cursor-pointer gap-2"
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ export function CanvasHostFrame({
|
|||||||
embedded,
|
embedded,
|
||||||
isMobile,
|
isMobile,
|
||||||
title,
|
title,
|
||||||
|
mobileTitleSlot,
|
||||||
|
dataScopeLabel,
|
||||||
menuOpen,
|
menuOpen,
|
||||||
setMenuOpen,
|
setMenuOpen,
|
||||||
desktopActions,
|
desktopActions,
|
||||||
@@ -25,6 +27,8 @@ export function CanvasHostFrame({
|
|||||||
embedded: boolean;
|
embedded: boolean;
|
||||||
isMobile: boolean;
|
isMobile: boolean;
|
||||||
title: string;
|
title: string;
|
||||||
|
mobileTitleSlot?: ReactNode;
|
||||||
|
dataScopeLabel?: string;
|
||||||
menuOpen: boolean;
|
menuOpen: boolean;
|
||||||
setMenuOpen: (open: boolean) => void;
|
setMenuOpen: (open: boolean) => void;
|
||||||
desktopActions: ReactNode;
|
desktopActions: ReactNode;
|
||||||
@@ -43,6 +47,7 @@ export function CanvasHostFrame({
|
|||||||
<PanelRoot data-testid="canvas-host-panel">
|
<PanelRoot data-testid="canvas-host-panel">
|
||||||
<CanvasHostHeader
|
<CanvasHostHeader
|
||||||
title={title}
|
title={title}
|
||||||
|
dataScopeLabel={dataScopeLabel}
|
||||||
isMobile={isMobile}
|
isMobile={isMobile}
|
||||||
menuOpen={menuOpen}
|
menuOpen={menuOpen}
|
||||||
onOpenActions={() => setMenuOpen(true)}
|
onOpenActions={() => setMenuOpen(true)}
|
||||||
@@ -61,13 +66,16 @@ export function CanvasHostFrame({
|
|||||||
<PageShell
|
<PageShell
|
||||||
title={title}
|
title={title}
|
||||||
titleSlot={
|
titleSlot={
|
||||||
!isMobile ? (
|
isMobile ? (
|
||||||
|
mobileTitleSlot
|
||||||
|
) : (
|
||||||
<span className="flex min-w-0 items-center gap-1">
|
<span className="flex min-w-0 items-center gap-1">
|
||||||
<span className="truncate text-sm font-medium">{title}</span>
|
<span className="truncate text-sm font-medium">{title}</span>
|
||||||
{renameAction}
|
{renameAction}
|
||||||
</span>
|
</span>
|
||||||
) : undefined
|
)
|
||||||
}
|
}
|
||||||
|
subtitle={isMobile ? undefined : dataScopeLabel}
|
||||||
backHref="/"
|
backHref="/"
|
||||||
backLabel={t("sidebar:home")}
|
backLabel={t("sidebar:home")}
|
||||||
scroll="none"
|
scroll="none"
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ import {
|
|||||||
MobileCanvasActions,
|
MobileCanvasActions,
|
||||||
type CanvasHostState,
|
type CanvasHostState,
|
||||||
} from "./canvas-host-components";
|
} from "./canvas-host-components";
|
||||||
import { type Canvas } from "@/lib/api/domains/canvas-api";
|
import { canvasDataScope, type Canvas } from "@/lib/api/domains/canvas-api";
|
||||||
|
|
||||||
type CanvasHostRouteViewProps = {
|
type CanvasHostRouteViewProps = {
|
||||||
canvasId: string;
|
canvasId: string;
|
||||||
@@ -31,17 +31,20 @@ type CanvasHostRouteViewProps = {
|
|||||||
error: string | null;
|
error: string | null;
|
||||||
menuOpen: boolean;
|
menuOpen: boolean;
|
||||||
promotionOpen: boolean;
|
promotionOpen: boolean;
|
||||||
|
workspaceDataOpen: boolean;
|
||||||
releasesOpen: boolean;
|
releasesOpen: boolean;
|
||||||
shareOpen: boolean;
|
shareOpen: boolean;
|
||||||
renameOpen: boolean;
|
renameOpen: boolean;
|
||||||
editing: boolean;
|
editing: boolean;
|
||||||
setMenuOpen: (open: boolean) => void;
|
setMenuOpen: (open: boolean) => void;
|
||||||
setPromotionOpen: (open: boolean) => void;
|
setPromotionOpen: (open: boolean) => void;
|
||||||
|
setWorkspaceDataOpen: (open: boolean) => void;
|
||||||
setReleasesOpen: (open: boolean) => void;
|
setReleasesOpen: (open: boolean) => void;
|
||||||
setShareOpen: (open: boolean) => void;
|
setShareOpen: (open: boolean) => void;
|
||||||
setRenameOpen: (open: boolean) => void;
|
setRenameOpen: (open: boolean) => void;
|
||||||
onEdit: () => void;
|
onEdit: () => void;
|
||||||
onPromote: () => void;
|
onPromote: () => void;
|
||||||
|
onEnableWorkspaceData: () => void;
|
||||||
onReleases: () => void;
|
onReleases: () => void;
|
||||||
onShare: () => void;
|
onShare: () => void;
|
||||||
onRename: () => void;
|
onRename: () => void;
|
||||||
@@ -57,6 +60,8 @@ function CanvasHostRouteDialogs({
|
|||||||
canvas,
|
canvas,
|
||||||
promotionOpen,
|
promotionOpen,
|
||||||
setPromotionOpen,
|
setPromotionOpen,
|
||||||
|
workspaceDataOpen,
|
||||||
|
setWorkspaceDataOpen,
|
||||||
releasesOpen,
|
releasesOpen,
|
||||||
setReleasesOpen,
|
setReleasesOpen,
|
||||||
shareOpen,
|
shareOpen,
|
||||||
@@ -70,6 +75,8 @@ function CanvasHostRouteDialogs({
|
|||||||
| "canvas"
|
| "canvas"
|
||||||
| "promotionOpen"
|
| "promotionOpen"
|
||||||
| "setPromotionOpen"
|
| "setPromotionOpen"
|
||||||
|
| "workspaceDataOpen"
|
||||||
|
| "setWorkspaceDataOpen"
|
||||||
| "releasesOpen"
|
| "releasesOpen"
|
||||||
| "setReleasesOpen"
|
| "setReleasesOpen"
|
||||||
| "shareOpen"
|
| "shareOpen"
|
||||||
@@ -85,6 +92,8 @@ function CanvasHostRouteDialogs({
|
|||||||
canvas={canvas}
|
canvas={canvas}
|
||||||
promotionOpen={promotionOpen}
|
promotionOpen={promotionOpen}
|
||||||
onPromotionOpenChange={setPromotionOpen}
|
onPromotionOpenChange={setPromotionOpen}
|
||||||
|
workspaceDataOpen={workspaceDataOpen}
|
||||||
|
onWorkspaceDataOpenChange={setWorkspaceDataOpen}
|
||||||
releasesOpen={releasesOpen}
|
releasesOpen={releasesOpen}
|
||||||
onReleasesOpenChange={setReleasesOpen}
|
onReleasesOpenChange={setReleasesOpen}
|
||||||
onPromotionCompleted={onPromotionCompleted}
|
onPromotionCompleted={onPromotionCompleted}
|
||||||
@@ -134,6 +143,7 @@ function CanvasHostRouteMobileActions({
|
|||||||
setMenuOpen,
|
setMenuOpen,
|
||||||
onEdit,
|
onEdit,
|
||||||
onPromote,
|
onPromote,
|
||||||
|
onEnableWorkspaceData,
|
||||||
onReleases,
|
onReleases,
|
||||||
onShare,
|
onShare,
|
||||||
onRename,
|
onRename,
|
||||||
@@ -147,6 +157,7 @@ function CanvasHostRouteMobileActions({
|
|||||||
| "setMenuOpen"
|
| "setMenuOpen"
|
||||||
| "onEdit"
|
| "onEdit"
|
||||||
| "onPromote"
|
| "onPromote"
|
||||||
|
| "onEnableWorkspaceData"
|
||||||
| "onReleases"
|
| "onReleases"
|
||||||
| "onShare"
|
| "onShare"
|
||||||
| "onRename"
|
| "onRename"
|
||||||
@@ -160,6 +171,7 @@ function CanvasHostRouteMobileActions({
|
|||||||
onOpenChange={setMenuOpen}
|
onOpenChange={setMenuOpen}
|
||||||
onEdit={onEdit}
|
onEdit={onEdit}
|
||||||
onPromote={onPromote}
|
onPromote={onPromote}
|
||||||
|
onEnableWorkspaceData={onEnableWorkspaceData}
|
||||||
onReleases={onReleases}
|
onReleases={onReleases}
|
||||||
onShare={onShare}
|
onShare={onShare}
|
||||||
onRename={onRename}
|
onRename={onRename}
|
||||||
@@ -187,6 +199,7 @@ function createCanvasDesktopActions(canvas: Canvas | null, props: CanvasHostRout
|
|||||||
onPromote: props.onPromote,
|
onPromote: props.onPromote,
|
||||||
onReleases: props.onReleases,
|
onReleases: props.onReleases,
|
||||||
onShare: props.onShare,
|
onShare: props.onShare,
|
||||||
|
onEnableWorkspaceData: props.onEnableWorkspaceData,
|
||||||
};
|
};
|
||||||
return {
|
return {
|
||||||
actions: <CanvasDesktopActions {...actionProps} />,
|
actions: <CanvasDesktopActions {...actionProps} />,
|
||||||
@@ -196,10 +209,35 @@ function createCanvasDesktopActions(canvas: Canvas | null, props: CanvasHostRout
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function useCanvasHeaderPresentation(canvas: Canvas | null, isMobile: boolean) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const title = canvas?.title || t("canvases:canvas");
|
||||||
|
const dataScopeLabel = canvas
|
||||||
|
? t(
|
||||||
|
canvasDataScope(canvas) === "workspace"
|
||||||
|
? "canvases:workspaceDataScope"
|
||||||
|
: "canvases:taskDataScope",
|
||||||
|
)
|
||||||
|
: undefined;
|
||||||
|
const mobileTitleSlot =
|
||||||
|
isMobile && dataScopeLabel ? (
|
||||||
|
<span className="flex min-w-0 items-center gap-1.5">
|
||||||
|
<span className="truncate text-sm font-medium">{title}</span>
|
||||||
|
<span
|
||||||
|
className="shrink-0 text-xs font-normal text-muted-foreground"
|
||||||
|
data-testid="canvas-data-scope"
|
||||||
|
>
|
||||||
|
{dataScopeLabel}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
) : undefined;
|
||||||
|
return { title, dataScopeLabel, mobileTitleSlot };
|
||||||
|
}
|
||||||
|
|
||||||
export function CanvasHostRouteView(props: CanvasHostRouteViewProps) {
|
export function CanvasHostRouteView(props: CanvasHostRouteViewProps) {
|
||||||
const { canvas, isMobile, menuOpen, setMenuOpen } = props;
|
const { canvas, isMobile, menuOpen, setMenuOpen } = props;
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const title = canvas?.title || t("canvases:canvas");
|
const { title, dataScopeLabel, mobileTitleSlot } = useCanvasHeaderPresentation(canvas, isMobile);
|
||||||
const desktopActions = createCanvasDesktopActions(canvas, props);
|
const desktopActions = createCanvasDesktopActions(canvas, props);
|
||||||
const renameAction = canvas ? (
|
const renameAction = canvas ? (
|
||||||
<Button
|
<Button
|
||||||
@@ -236,6 +274,7 @@ export function CanvasHostRouteView(props: CanvasHostRouteViewProps) {
|
|||||||
setMenuOpen={setMenuOpen}
|
setMenuOpen={setMenuOpen}
|
||||||
onEdit={props.onEdit}
|
onEdit={props.onEdit}
|
||||||
onPromote={props.onPromote}
|
onPromote={props.onPromote}
|
||||||
|
onEnableWorkspaceData={props.onEnableWorkspaceData}
|
||||||
onReleases={props.onReleases}
|
onReleases={props.onReleases}
|
||||||
onShare={props.onShare}
|
onShare={props.onShare}
|
||||||
onRename={props.onRename}
|
onRename={props.onRename}
|
||||||
@@ -249,6 +288,8 @@ export function CanvasHostRouteView(props: CanvasHostRouteViewProps) {
|
|||||||
canvas={canvas}
|
canvas={canvas}
|
||||||
promotionOpen={props.promotionOpen}
|
promotionOpen={props.promotionOpen}
|
||||||
setPromotionOpen={props.setPromotionOpen}
|
setPromotionOpen={props.setPromotionOpen}
|
||||||
|
workspaceDataOpen={props.workspaceDataOpen}
|
||||||
|
setWorkspaceDataOpen={props.setWorkspaceDataOpen}
|
||||||
releasesOpen={props.releasesOpen}
|
releasesOpen={props.releasesOpen}
|
||||||
setReleasesOpen={props.setReleasesOpen}
|
setReleasesOpen={props.setReleasesOpen}
|
||||||
shareOpen={props.shareOpen}
|
shareOpen={props.shareOpen}
|
||||||
@@ -265,6 +306,8 @@ export function CanvasHostRouteView(props: CanvasHostRouteViewProps) {
|
|||||||
embedded={props.embedded}
|
embedded={props.embedded}
|
||||||
isMobile={isMobile}
|
isMobile={isMobile}
|
||||||
title={title}
|
title={title}
|
||||||
|
mobileTitleSlot={mobileTitleSlot}
|
||||||
|
dataScopeLabel={dataScopeLabel}
|
||||||
menuOpen={menuOpen}
|
menuOpen={menuOpen}
|
||||||
setMenuOpen={setMenuOpen}
|
setMenuOpen={setMenuOpen}
|
||||||
desktopActions={desktopActions.actions}
|
desktopActions={desktopActions.actions}
|
||||||
|
|||||||
@@ -28,6 +28,12 @@ const RUNTIME_URL_ATTRIBUTE = "data-runtime-url";
|
|||||||
|
|
||||||
vi.mock("@/lib/api/domains/canvas-api", () => ({
|
vi.mock("@/lib/api/domains/canvas-api", () => ({
|
||||||
canvasHref: (canvasId: string) => `/canvases/${canvasId}`,
|
canvasHref: (canvasId: string) => `/canvases/${canvasId}`,
|
||||||
|
canvasDataScope: (value: Canvas) => value.data_scope_kind ?? value.scope_kind,
|
||||||
|
canvasCanEnableWorkspaceData: (value: Canvas) =>
|
||||||
|
value.scope_kind === "task" &&
|
||||||
|
(value.data_scope_kind ?? value.scope_kind) === "task" &&
|
||||||
|
value.status === "active" &&
|
||||||
|
value.active_release_status === "valid",
|
||||||
getCanvas: mockGetCanvas,
|
getCanvas: mockGetCanvas,
|
||||||
getCanvasRuntime: mockGetCanvasRuntime,
|
getCanvasRuntime: mockGetCanvasRuntime,
|
||||||
listTaskCanvases: mockListTaskCanvases,
|
listTaskCanvases: mockListTaskCanvases,
|
||||||
@@ -40,15 +46,23 @@ vi.mock("@/components/page-shell", () => ({
|
|||||||
actions,
|
actions,
|
||||||
children,
|
children,
|
||||||
overflowMenuItems,
|
overflowMenuItems,
|
||||||
|
subtitle,
|
||||||
|
titleSlot,
|
||||||
topbarTestId,
|
topbarTestId,
|
||||||
}: {
|
}: {
|
||||||
actions?: ReactNode;
|
actions?: ReactNode;
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
overflowMenuItems?: ReactNode;
|
overflowMenuItems?: ReactNode;
|
||||||
|
subtitle?: string;
|
||||||
|
titleSlot?: ReactNode;
|
||||||
topbarTestId?: string;
|
topbarTestId?: string;
|
||||||
}) => (
|
}) => (
|
||||||
<div>
|
<div>
|
||||||
<div data-testid={topbarTestId}>{mockIsMobile.value ? actions : null}</div>
|
<div data-testid={topbarTestId}>
|
||||||
|
{titleSlot}
|
||||||
|
{subtitle}
|
||||||
|
{mockIsMobile.value ? actions : null}
|
||||||
|
</div>
|
||||||
{overflowMenuItems && mockRenderPageShellOverflow.value ? (
|
{overflowMenuItems && mockRenderPageShellOverflow.value ? (
|
||||||
<DropdownMenu open>
|
<DropdownMenu open>
|
||||||
<DropdownMenuContent data-testid="page-shell-overflow">
|
<DropdownMenuContent data-testid="page-shell-overflow">
|
||||||
@@ -77,6 +91,7 @@ vi.mock("@/components/plugins/canvas-page", () => ({
|
|||||||
vi.mock("@/components/settings/canvas-lifecycle-dialogs", () => ({
|
vi.mock("@/components/settings/canvas-lifecycle-dialogs", () => ({
|
||||||
CanvasPromotionDialog: () => null,
|
CanvasPromotionDialog: () => null,
|
||||||
CanvasReleaseDialog: () => null,
|
CanvasReleaseDialog: () => null,
|
||||||
|
CanvasWorkspaceDataDialog: () => null,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("@/hooks/use-responsive-breakpoint", () => ({
|
vi.mock("@/hooks/use-responsive-breakpoint", () => ({
|
||||||
@@ -240,7 +255,9 @@ describe("CanvasHostRoute runtime recovery", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("CanvasHostRoute mobile data scope", () => {
|
||||||
it("lets a mobile focused host switch to another applicable canvas", async () => {
|
it("lets a mobile focused host switch to another applicable canvas", async () => {
|
||||||
mockIsMobile.value = true;
|
mockIsMobile.value = true;
|
||||||
const otherCanvas = {
|
const otherCanvas = {
|
||||||
@@ -261,4 +278,13 @@ describe("CanvasHostRoute runtime recovery", () => {
|
|||||||
|
|
||||||
expect(mockPush).toHaveBeenCalledWith("/canvases/canvas-2");
|
expect(mockPush).toHaveBeenCalledWith("/canvases/canvas-2");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("shows the data scope beside the title on mobile", async () => {
|
||||||
|
mockIsMobile.value = true;
|
||||||
|
mockGetCanvas.mockReset().mockResolvedValue({ ...canvas, data_scope_kind: "workspace" });
|
||||||
|
|
||||||
|
render(<CanvasHostRoute canvasId="canvas-1" />);
|
||||||
|
|
||||||
|
expect((await screen.findByTestId("canvas-data-scope")).textContent).toBe("Workspace data");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -389,6 +389,21 @@ async function editCanvasFromHost(options: CanvasHostEditOptions): Promise<void>
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function useRecordCanvasPresentation(canvas: Canvas | null, userId: string | null) {
|
||||||
|
useEffect(() => {
|
||||||
|
if (!canvas || canvas.scope_kind !== "task" || !canvas.task_id || !userId) return;
|
||||||
|
recordCanvasPresentation(
|
||||||
|
{
|
||||||
|
userId,
|
||||||
|
workspaceId: canvas.workspace_id,
|
||||||
|
taskId: canvas.task_id,
|
||||||
|
canvasId: canvas.id,
|
||||||
|
},
|
||||||
|
"manual",
|
||||||
|
);
|
||||||
|
}, [canvas, userId]);
|
||||||
|
}
|
||||||
|
|
||||||
export function CanvasHostRoute({
|
export function CanvasHostRoute({
|
||||||
canvasId,
|
canvasId,
|
||||||
embedded = false,
|
embedded = false,
|
||||||
@@ -414,23 +429,13 @@ export function CanvasHostRoute({
|
|||||||
const hostCanvases = useCanvasHostCanvases(canvas);
|
const hostCanvases = useCanvasHostCanvases(canvas);
|
||||||
const [menuOpen, setMenuOpen] = useState(false);
|
const [menuOpen, setMenuOpen] = useState(false);
|
||||||
const [promotionOpen, setPromotionOpen] = useState(false);
|
const [promotionOpen, setPromotionOpen] = useState(false);
|
||||||
|
const [workspaceDataOpen, setWorkspaceDataOpen] = useState(false);
|
||||||
const [releasesOpen, setReleasesOpen] = useState(false);
|
const [releasesOpen, setReleasesOpen] = useState(false);
|
||||||
const [shareOpen, setShareOpen] = useState(false);
|
const [shareOpen, setShareOpen] = useState(false);
|
||||||
const [renameOpen, setRenameOpen] = useState(false);
|
const [renameOpen, setRenameOpen] = useState(false);
|
||||||
const [editing, setEditing] = useState(false);
|
const [editing, setEditing] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useRecordCanvasPresentation(canvas, presentationUserId);
|
||||||
if (!canvas || canvas.scope_kind !== "task" || !canvas.task_id || !presentationUserId) return;
|
|
||||||
recordCanvasPresentation(
|
|
||||||
{
|
|
||||||
userId: presentationUserId,
|
|
||||||
workspaceId: canvas.workspace_id,
|
|
||||||
taskId: canvas.task_id,
|
|
||||||
canvasId: canvas.id,
|
|
||||||
},
|
|
||||||
"manual",
|
|
||||||
);
|
|
||||||
}, [canvas, presentationUserId]);
|
|
||||||
|
|
||||||
const edit = () =>
|
const edit = () =>
|
||||||
editCanvasFromHost({
|
editCanvasFromHost({
|
||||||
@@ -461,17 +466,23 @@ export function CanvasHostRoute({
|
|||||||
error={error}
|
error={error}
|
||||||
menuOpen={menuOpen}
|
menuOpen={menuOpen}
|
||||||
promotionOpen={promotionOpen}
|
promotionOpen={promotionOpen}
|
||||||
|
workspaceDataOpen={workspaceDataOpen}
|
||||||
releasesOpen={releasesOpen}
|
releasesOpen={releasesOpen}
|
||||||
shareOpen={shareOpen}
|
shareOpen={shareOpen}
|
||||||
renameOpen={renameOpen}
|
renameOpen={renameOpen}
|
||||||
editing={editing}
|
editing={editing}
|
||||||
setMenuOpen={setMenuOpen}
|
setMenuOpen={setMenuOpen}
|
||||||
setPromotionOpen={setPromotionOpen}
|
setPromotionOpen={setPromotionOpen}
|
||||||
|
setWorkspaceDataOpen={setWorkspaceDataOpen}
|
||||||
setReleasesOpen={setReleasesOpen}
|
setReleasesOpen={setReleasesOpen}
|
||||||
setShareOpen={setShareOpen}
|
setShareOpen={setShareOpen}
|
||||||
setRenameOpen={setRenameOpen}
|
setRenameOpen={setRenameOpen}
|
||||||
onEdit={() => void edit()}
|
onEdit={() => void edit()}
|
||||||
onPromote={() => setPromotionOpen(true)}
|
onPromote={() => setPromotionOpen(true)}
|
||||||
|
onEnableWorkspaceData={() => {
|
||||||
|
setMenuOpen(false);
|
||||||
|
setWorkspaceDataOpen(true);
|
||||||
|
}}
|
||||||
onReleases={() => setReleasesOpen(true)}
|
onReleases={() => setReleasesOpen(true)}
|
||||||
onShare={() => setShareOpen(true)}
|
onShare={() => setShareOpen(true)}
|
||||||
onRename={() => {
|
onRename={() => {
|
||||||
|
|||||||
@@ -4,9 +4,14 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|||||||
import { ApiError } from "@/lib/api/client";
|
import { ApiError } from "@/lib/api/client";
|
||||||
import type { Canvas, CanvasRelease } from "@/lib/api/domains/canvas-api";
|
import type { Canvas, CanvasRelease } from "@/lib/api/domains/canvas-api";
|
||||||
|
|
||||||
|
const ENABLE_WORKSPACE_DATA_TEXT = vi.hoisted(() => "Enable workspace data");
|
||||||
|
const READ_TASK_DATA_TEXT = vi.hoisted(() => "Read task data");
|
||||||
|
|
||||||
const {
|
const {
|
||||||
mockRequestCanvasPromotion,
|
mockRequestCanvasPromotion,
|
||||||
mockConfirmCanvasPromotion,
|
mockConfirmCanvasPromotion,
|
||||||
|
mockRequestCanvasWorkspaceData,
|
||||||
|
mockEnableCanvasWorkspaceData,
|
||||||
mockListCanvasReleases,
|
mockListCanvasReleases,
|
||||||
mockApproveCanvasRelease,
|
mockApproveCanvasRelease,
|
||||||
mockRejectCanvasRelease,
|
mockRejectCanvasRelease,
|
||||||
@@ -14,6 +19,8 @@ const {
|
|||||||
} = vi.hoisted(() => ({
|
} = vi.hoisted(() => ({
|
||||||
mockRequestCanvasPromotion: vi.fn(),
|
mockRequestCanvasPromotion: vi.fn(),
|
||||||
mockConfirmCanvasPromotion: vi.fn(),
|
mockConfirmCanvasPromotion: vi.fn(),
|
||||||
|
mockRequestCanvasWorkspaceData: vi.fn(),
|
||||||
|
mockEnableCanvasWorkspaceData: vi.fn(),
|
||||||
mockListCanvasReleases: vi.fn(),
|
mockListCanvasReleases: vi.fn(),
|
||||||
mockApproveCanvasRelease: vi.fn(),
|
mockApproveCanvasRelease: vi.fn(),
|
||||||
mockRejectCanvasRelease: vi.fn(),
|
mockRejectCanvasRelease: vi.fn(),
|
||||||
@@ -28,6 +35,24 @@ const translate = vi.hoisted(() => {
|
|||||||
"canvases:promoteCanvasDescription": "Review permissions before promotion.",
|
"canvases:promoteCanvasDescription": "Review permissions before promotion.",
|
||||||
"canvases:loadingPermissions": "Loading requested permissions",
|
"canvases:loadingPermissions": "Loading requested permissions",
|
||||||
"canvases:promotionScopeChange": "Promotion changes the canvas scope.",
|
"canvases:promotionScopeChange": "Promotion changes the canvas scope.",
|
||||||
|
"canvases:promotionDataAccessPreserved":
|
||||||
|
"Promotion changes placement only. Data access stays the same.",
|
||||||
|
"canvases:promotionDataAccessChanges": "Promotion expands data access.",
|
||||||
|
"canvases:promotionSourceScope": "Placement before promotion",
|
||||||
|
"canvases:promotionTargetScope": "Placement after promotion",
|
||||||
|
"canvases:promotionCurrentDataScope": "Data access before promotion",
|
||||||
|
"canvases:promotionTargetDataScope": "Data access after promotion",
|
||||||
|
"canvases:taskDataScope": "Task data",
|
||||||
|
"canvases:workspaceDataScope": "Workspace data",
|
||||||
|
"canvases:taskPlacementScope": "Task",
|
||||||
|
"canvases:workspacePlacementScope": "Workspace",
|
||||||
|
"canvases:enableWorkspaceData": ENABLE_WORKSPACE_DATA_TEXT,
|
||||||
|
"canvases:enableWorkspaceDataHelp": "Enable declared permissions for this workspace.",
|
||||||
|
"canvases:workspaceDataReviewDescription": "Review permissions for {{title}}.",
|
||||||
|
"canvases:workspaceDataReviewScopeChange":
|
||||||
|
"Only data access changes. The canvas stays in this task.",
|
||||||
|
"canvases:enablingWorkspaceData": "Enabling workspace data",
|
||||||
|
"canvases:activeRelease": "Active release",
|
||||||
"canvases:noAdditionalPermissions": "No additional permissions were requested.",
|
"canvases:noAdditionalPermissions": "No additional permissions were requested.",
|
||||||
"canvases:permissionDeclaration": "Declared permissions",
|
"canvases:permissionDeclaration": "Declared permissions",
|
||||||
"canvases:missingPermissions": "Permissions still needed",
|
"canvases:missingPermissions": "Permissions still needed",
|
||||||
@@ -50,13 +75,15 @@ const translate = vi.hoisted(() => {
|
|||||||
"canvases:permissionExternalOrigins": "External origins",
|
"canvases:permissionExternalOrigins": "External origins",
|
||||||
"canvases:permissionExternalOrigin": "External HTTPS origin",
|
"canvases:permissionExternalOrigin": "External HTTPS origin",
|
||||||
"canvases:permissionSharedState": "Read and write shared canvas state",
|
"canvases:permissionSharedState": "Read and write shared canvas state",
|
||||||
"canvases:permissionReadTasks": "Read task data",
|
"canvases:permissionReadTasks": READ_TASK_DATA_TEXT,
|
||||||
"canvases:permissionReadWorkflows": "Read workflow data",
|
"canvases:permissionReadWorkflows": "Read workflow data",
|
||||||
"canvases:permissionWriteTasks": "Write task data",
|
"canvases:permissionWriteTasks": "Write task data",
|
||||||
"canvases:permissionWriteMessages": "Write task messages",
|
"canvases:permissionWriteMessages": "Write task messages",
|
||||||
"canvases:permissionEventTaskUpdated": "Receive task update events",
|
"canvases:permissionEventTaskUpdated": "Receive task update events",
|
||||||
"canvases:permissionEventWorkflowUpdated": "Receive workflow update events",
|
"canvases:permissionEventWorkflowUpdated": "Receive workflow update events",
|
||||||
"canvases:unsupportedPermission": "Unsupported permission: {{value}}",
|
"canvases:unsupportedPermission": "Unsupported permission: {{value}}",
|
||||||
|
"canvases:workspaceDataReviewStale": "The review changed. Open it again.",
|
||||||
|
"canvases:workspaceOwnerRequired": "Only the workspace owner can enable workspace data.",
|
||||||
"canvases:selectRelease": "Select a release to review",
|
"canvases:selectRelease": "Select a release to review",
|
||||||
"canvases:releaseOption": "{{date}} ({{status}})",
|
"canvases:releaseOption": "{{date}} ({{status}})",
|
||||||
"canvases:releaseDate": "Created",
|
"canvases:releaseDate": "Created",
|
||||||
@@ -71,11 +98,9 @@ const translate = vi.hoisted(() => {
|
|||||||
"canvases:sourceActorSystem": "Kandev",
|
"canvases:sourceActorSystem": "Kandev",
|
||||||
"canvases:sourceActorUnknown": "Unknown author",
|
"canvases:sourceActorUnknown": "Unknown author",
|
||||||
"canvases:sharedState": "Shared state",
|
"canvases:sharedState": "Shared state",
|
||||||
"canvases:promotionSourceScope": "Source scope",
|
|
||||||
"canvases:promotionSourceActor": "Source actor",
|
"canvases:promotionSourceActor": "Source actor",
|
||||||
"canvases:promotionSourceTask": "Source task",
|
"canvases:promotionSourceTask": "Source task",
|
||||||
"canvases:promotionSourceSession": "Source session",
|
"canvases:promotionSourceSession": "Source session",
|
||||||
"canvases:promotionTargetScope": "Target scope",
|
|
||||||
"canvases:promotionPlacement": "Workspace placement",
|
"canvases:promotionPlacement": "Workspace placement",
|
||||||
"common:cancel": "Cancel",
|
"common:cancel": "Cancel",
|
||||||
"common:close": "Close",
|
"common:close": "Close",
|
||||||
@@ -114,13 +139,19 @@ vi.mock("@kandev/ui/dialog", () => ({
|
|||||||
vi.mock("@/lib/api/domains/canvas-api", () => ({
|
vi.mock("@/lib/api/domains/canvas-api", () => ({
|
||||||
approveCanvasRelease: mockApproveCanvasRelease,
|
approveCanvasRelease: mockApproveCanvasRelease,
|
||||||
confirmCanvasPromotion: mockConfirmCanvasPromotion,
|
confirmCanvasPromotion: mockConfirmCanvasPromotion,
|
||||||
|
enableCanvasWorkspaceData: mockEnableCanvasWorkspaceData,
|
||||||
listCanvasReleases: mockListCanvasReleases,
|
listCanvasReleases: mockListCanvasReleases,
|
||||||
rejectCanvasRelease: mockRejectCanvasRelease,
|
rejectCanvasRelease: mockRejectCanvasRelease,
|
||||||
|
requestCanvasWorkspaceData: mockRequestCanvasWorkspaceData,
|
||||||
requestCanvasPromotion: mockRequestCanvasPromotion,
|
requestCanvasPromotion: mockRequestCanvasPromotion,
|
||||||
rollbackCanvas: mockRollbackCanvas,
|
rollbackCanvas: mockRollbackCanvas,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
import { CanvasPromotionDialog, CanvasReleaseDialog } from "./canvas-lifecycle-dialogs";
|
import {
|
||||||
|
CanvasPromotionDialog,
|
||||||
|
CanvasReleaseDialog,
|
||||||
|
CanvasWorkspaceDataDialog,
|
||||||
|
} from "./canvas-lifecycle-dialogs";
|
||||||
|
|
||||||
const canvas: Canvas = {
|
const canvas: Canvas = {
|
||||||
id: "canvas-1",
|
id: "canvas-1",
|
||||||
@@ -143,6 +174,8 @@ const COPY = {
|
|||||||
rollbackRelease: "Roll back release",
|
rollbackRelease: "Roll back release",
|
||||||
promotingCanvas: "Promoting canvas",
|
promotingCanvas: "Promoting canvas",
|
||||||
confirmPromotion: "Confirm promotion",
|
confirmPromotion: "Confirm promotion",
|
||||||
|
enableWorkspaceData: ENABLE_WORKSPACE_DATA_TEXT,
|
||||||
|
enablingWorkspaceData: "Enabling workspace data",
|
||||||
cancel: "Cancel",
|
cancel: "Cancel",
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
@@ -180,6 +213,8 @@ beforeEach(() => {
|
|||||||
source_session_name: "Source session name",
|
source_session_name: "Source session name",
|
||||||
current_scope: "task",
|
current_scope: "task",
|
||||||
target_scope: "workspace",
|
target_scope: "workspace",
|
||||||
|
current_data_scope_kind: "task",
|
||||||
|
target_data_scope_kind: "workspace",
|
||||||
placement: "workspace_sidebar",
|
placement: "workspace_sidebar",
|
||||||
permissions: {
|
permissions: {
|
||||||
reads: [TASKS_READ_PERMISSION],
|
reads: [TASKS_READ_PERMISSION],
|
||||||
@@ -190,6 +225,16 @@ beforeEach(() => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
mockConfirmCanvasPromotion.mockReset();
|
mockConfirmCanvasPromotion.mockReset();
|
||||||
|
mockRequestCanvasWorkspaceData.mockReset().mockResolvedValue({
|
||||||
|
canvas,
|
||||||
|
active_release_id: "release-1",
|
||||||
|
permission_digest: "digest-1",
|
||||||
|
grant_generation: 7,
|
||||||
|
current_data_scope_kind: "task",
|
||||||
|
target_data_scope_kind: "workspace",
|
||||||
|
permissions: { reads: ["tasks"] },
|
||||||
|
});
|
||||||
|
mockEnableCanvasWorkspaceData.mockReset();
|
||||||
mockListCanvasReleases.mockReset().mockResolvedValue({ releases: [] });
|
mockListCanvasReleases.mockReset().mockResolvedValue({ releases: [] });
|
||||||
mockApproveCanvasRelease.mockReset();
|
mockApproveCanvasRelease.mockReset();
|
||||||
mockRejectCanvasRelease.mockReset();
|
mockRejectCanvasRelease.mockReset();
|
||||||
@@ -206,7 +251,7 @@ describe("CanvasPromotionDialog", () => {
|
|||||||
render(<CanvasPromotionDialog canvas={canvas} open onOpenChange={vi.fn()} />);
|
render(<CanvasPromotionDialog canvas={canvas} open onOpenChange={vi.fn()} />);
|
||||||
|
|
||||||
await waitFor(() =>
|
await waitFor(() =>
|
||||||
expect(screen.getByTestId("canvas-promotion-source-scope").textContent).toContain("task"),
|
expect(screen.getByTestId("canvas-promotion-source-scope").textContent).toContain("Task"),
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(screen.getByTestId("canvas-promotion-source-task").textContent).toContain(
|
expect(screen.getByTestId("canvas-promotion-source-task").textContent).toContain(
|
||||||
@@ -219,7 +264,7 @@ describe("CanvasPromotionDialog", () => {
|
|||||||
"workspace_sidebar",
|
"workspace_sidebar",
|
||||||
);
|
);
|
||||||
expect(screen.getByText("API reads")).toBeTruthy();
|
expect(screen.getByText("API reads")).toBeTruthy();
|
||||||
expect(screen.getByText("Read task data")).toBeTruthy();
|
expect(screen.getByText(READ_TASK_DATA_TEXT)).toBeTruthy();
|
||||||
expect(screen.getByText("API writes")).toBeTruthy();
|
expect(screen.getByText("API writes")).toBeTruthy();
|
||||||
expect(screen.getByText("Write task data")).toBeTruthy();
|
expect(screen.getByText("Write task data")).toBeTruthy();
|
||||||
expect(screen.getByText("Events")).toBeTruthy();
|
expect(screen.getByText("Events")).toBeTruthy();
|
||||||
@@ -227,6 +272,11 @@ describe("CanvasPromotionDialog", () => {
|
|||||||
expect(screen.getByText("Shared state")).toBeTruthy();
|
expect(screen.getByText("Shared state")).toBeTruthy();
|
||||||
expect(screen.getByText("External origins")).toBeTruthy();
|
expect(screen.getByText("External origins")).toBeTruthy();
|
||||||
expect(screen.getByText("https://example.test")).toBeTruthy();
|
expect(screen.getByText("https://example.test")).toBeTruthy();
|
||||||
|
expect(screen.getByTestId("canvas-promotion-current-data-scope").textContent).toBe("Task data");
|
||||||
|
expect(screen.getByTestId("canvas-promotion-target-data-scope").textContent).toBe(
|
||||||
|
"Workspace data",
|
||||||
|
);
|
||||||
|
expect(screen.getByText("Promotion expands data access.")).toBeTruthy();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("maps stable API error codes to localized copy", async () => {
|
it("maps stable API error codes to localized copy", async () => {
|
||||||
@@ -309,6 +359,72 @@ describe("CanvasPromotionDialog", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("CanvasWorkspaceDataDialog", () => {
|
||||||
|
it("shows the active release, current and target data scopes, and declared permissions", async () => {
|
||||||
|
render(<CanvasWorkspaceDataDialog canvas={canvas} open onOpenChange={vi.fn()} />);
|
||||||
|
|
||||||
|
expect((await screen.findByTestId("canvas-workspace-data-release")).textContent).toBe(
|
||||||
|
"release-1",
|
||||||
|
);
|
||||||
|
expect(screen.getByTestId("canvas-workspace-data-current-scope").textContent).toBe("Task data");
|
||||||
|
expect(screen.getByTestId("canvas-workspace-data-target-scope").textContent).toBe(
|
||||||
|
"Workspace data",
|
||||||
|
);
|
||||||
|
expect(screen.getByText(READ_TASK_DATA_TEXT)).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("enables exactly the reviewed release and grant generation", async () => {
|
||||||
|
const updated = { ...canvas, data_scope_kind: "workspace" };
|
||||||
|
const onCompleted = vi.fn();
|
||||||
|
const onOpenChange = vi.fn();
|
||||||
|
mockEnableCanvasWorkspaceData.mockResolvedValue(updated);
|
||||||
|
|
||||||
|
render(
|
||||||
|
<CanvasWorkspaceDataDialog
|
||||||
|
canvas={canvas}
|
||||||
|
open
|
||||||
|
onOpenChange={onOpenChange}
|
||||||
|
onCompleted={onCompleted}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
fireEvent.click(await screen.findByRole("button", { name: COPY.enableWorkspaceData }));
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(mockEnableCanvasWorkspaceData).toHaveBeenCalledWith(canvas.id, {
|
||||||
|
expected_release_id: "release-1",
|
||||||
|
expected_permission_digest: "digest-1",
|
||||||
|
expected_grant_generation: 7,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
await waitFor(() => expect(onCompleted).toHaveBeenCalledWith(updated));
|
||||||
|
expect(onOpenChange).toHaveBeenCalledWith(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("cancels without changing workspace data access", async () => {
|
||||||
|
const onOpenChange = vi.fn();
|
||||||
|
render(<CanvasWorkspaceDataDialog canvas={canvas} open onOpenChange={onOpenChange} />);
|
||||||
|
|
||||||
|
fireEvent.click(await screen.findByRole("button", { name: COPY.cancel }));
|
||||||
|
|
||||||
|
expect(mockEnableCanvasWorkspaceData).not.toHaveBeenCalled();
|
||||||
|
expect(onOpenChange).toHaveBeenCalledWith(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses a full-height phone review with one scroll region and safe-area actions", async () => {
|
||||||
|
responsive.isMobile = true;
|
||||||
|
render(<CanvasWorkspaceDataDialog canvas={canvas} open onOpenChange={vi.fn()} />);
|
||||||
|
|
||||||
|
await screen.findByTestId("canvas-workspace-data-release");
|
||||||
|
expect(screen.getByTestId("canvas-workspace-data-dialog").className).toContain("h-dvh");
|
||||||
|
expect(screen.getByTestId("canvas-workspace-data-review-scroll").className).toContain(
|
||||||
|
"overflow-y-auto",
|
||||||
|
);
|
||||||
|
expect(screen.getByRole("button", { name: COPY.enableWorkspaceData }).className).toContain(
|
||||||
|
"min-h-12",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("CanvasReleaseDialog validation", () => {
|
describe("CanvasReleaseDialog validation", () => {
|
||||||
it("localizes stable release validation codes", async () => {
|
it("localizes stable release validation codes", async () => {
|
||||||
mockListCanvasReleases.mockResolvedValue({
|
mockListCanvasReleases.mockResolvedValue({
|
||||||
@@ -353,7 +469,7 @@ describe("CanvasReleaseDialog permission review", () => {
|
|||||||
expect(screen.getByTestId("canvas-release-permissions-release-pending")).toBeTruthy(),
|
expect(screen.getByTestId("canvas-release-permissions-release-pending")).toBeTruthy(),
|
||||||
);
|
);
|
||||||
expect(screen.getByText("Declared permissions")).toBeTruthy();
|
expect(screen.getByText("Declared permissions")).toBeTruthy();
|
||||||
expect(screen.getByText("Read task data")).toBeTruthy();
|
expect(screen.getByText(READ_TASK_DATA_TEXT)).toBeTruthy();
|
||||||
expect(screen.getByText("Write task messages")).toBeTruthy();
|
expect(screen.getByText("Write task messages")).toBeTruthy();
|
||||||
expect(screen.getByText(EXTERNAL_ORIGIN)).toBeTruthy();
|
expect(screen.getByText(EXTERNAL_ORIGIN)).toBeTruthy();
|
||||||
expect(screen.getByText("New")).toBeTruthy();
|
expect(screen.getByText("New")).toBeTruthy();
|
||||||
|
|||||||
@@ -11,11 +11,15 @@ import {
|
|||||||
DialogTitle,
|
DialogTitle,
|
||||||
} from "@kandev/ui/dialog";
|
} from "@kandev/ui/dialog";
|
||||||
import { Button } from "@kandev/ui/button";
|
import { Button } from "@kandev/ui/button";
|
||||||
|
import { useResponsiveBreakpoint } from "@/hooks/use-responsive-breakpoint";
|
||||||
import {
|
import {
|
||||||
|
enableCanvasWorkspaceData,
|
||||||
confirmCanvasPromotion,
|
confirmCanvasPromotion,
|
||||||
|
requestCanvasWorkspaceData,
|
||||||
requestCanvasPromotion,
|
requestCanvasPromotion,
|
||||||
type Canvas,
|
type Canvas,
|
||||||
type CanvasPromotionPreview,
|
type CanvasPromotionPreview,
|
||||||
|
type CanvasWorkspaceDataPreview,
|
||||||
} from "@/lib/api/domains/canvas-api";
|
} from "@/lib/api/domains/canvas-api";
|
||||||
import { canvasErrorMessage } from "@/lib/api/domains/canvas-error-copy";
|
import { canvasErrorMessage } from "@/lib/api/domains/canvas-error-copy";
|
||||||
import { useCanvasLifecycleRevision } from "@/lib/canvas-lifecycle";
|
import { useCanvasLifecycleRevision } from "@/lib/canvas-lifecycle";
|
||||||
@@ -31,6 +35,12 @@ export { CanvasReleaseDialog } from "./canvas-release-review";
|
|||||||
|
|
||||||
const CANVAS_ACTION_FAILED_KEY = "canvases:actionFailed";
|
const CANVAS_ACTION_FAILED_KEY = "canvases:actionFailed";
|
||||||
const canvasActionClassName = controlSizingClassName("standard", "cursor-pointer");
|
const canvasActionClassName = controlSizingClassName("standard", "cursor-pointer");
|
||||||
|
const promotionScopeKeys = {
|
||||||
|
workspaceData: "canvases:workspaceDataScope",
|
||||||
|
taskData: "canvases:taskDataScope",
|
||||||
|
workspacePlacement: "canvases:workspacePlacementScope",
|
||||||
|
taskPlacement: "canvases:taskPlacementScope",
|
||||||
|
} as const;
|
||||||
|
|
||||||
function useCanvasPromotion(
|
function useCanvasPromotion(
|
||||||
canvas: Canvas | null,
|
canvas: Canvas | null,
|
||||||
@@ -104,40 +114,137 @@ type PromotionMetadataRow = {
|
|||||||
testId: string;
|
testId: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function promotionScopeLabel(
|
||||||
|
scope: string | undefined,
|
||||||
|
workspaceKey: string,
|
||||||
|
taskKey: string,
|
||||||
|
t: (key: string) => string,
|
||||||
|
) {
|
||||||
|
if (scope === "workspace") return t(workspaceKey);
|
||||||
|
if (scope === "task") return t(taskKey);
|
||||||
|
return scope;
|
||||||
|
}
|
||||||
|
|
||||||
function buildPromotionMetadataRows(
|
function buildPromotionMetadataRows(
|
||||||
canvas: Canvas | null,
|
canvas: Canvas | null,
|
||||||
preview: CanvasPromotionPreview,
|
preview: CanvasPromotionPreview,
|
||||||
t: (key: string) => string,
|
t: (key: string) => string,
|
||||||
): PromotionMetadataRow[] {
|
): PromotionMetadataRow[] {
|
||||||
const sourceTaskID =
|
const placementRow = optionalPromotionRow(
|
||||||
preview.source_task_id ?? preview.origin_task_id ?? canvas?.origin_task_id ?? canvas?.task_id;
|
preview.placement,
|
||||||
const sourceTask = sourceTaskID ? canvasSourceLabel(preview.source_task_title, t) : undefined;
|
"canvases:promotionPlacement",
|
||||||
const sourceSessionID = preview.source_session_id ?? canvas?.created_by_session_id;
|
"canvas-promotion-placement",
|
||||||
const sourceSession = sourceSessionID
|
t,
|
||||||
? canvasSourceLabel(preview.source_session_name, t)
|
);
|
||||||
: undefined;
|
|
||||||
const row = (
|
|
||||||
value: string | undefined,
|
|
||||||
labelKey: string,
|
|
||||||
testId: string,
|
|
||||||
): PromotionMetadataRow | null => (value ? { label: t(labelKey), value, testId } : null);
|
|
||||||
|
|
||||||
return [
|
return [
|
||||||
row(
|
...buildPromotionPlacementRows(canvas, preview, t),
|
||||||
preview.current_scope ?? canvas?.scope_kind,
|
...buildPromotionSourceRows(canvas, preview, t),
|
||||||
"canvases:promotionSourceScope",
|
...buildPromotionDataScopeRows(canvas, preview, t),
|
||||||
"canvas-promotion-source-scope",
|
...(placementRow ? [placementRow] : []),
|
||||||
),
|
];
|
||||||
row(
|
}
|
||||||
preview.source_actor_kind ? canvasSourceActorLabel(preview.source_actor_kind, t) : undefined,
|
|
||||||
|
function buildPromotionPlacementRows(
|
||||||
|
canvas: Canvas | null,
|
||||||
|
preview: CanvasPromotionPreview,
|
||||||
|
t: (key: string) => string,
|
||||||
|
): PromotionMetadataRow[] {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
label: t("canvases:promotionSourceScope"),
|
||||||
|
value:
|
||||||
|
promotionScopeLabel(
|
||||||
|
preview.current_scope ?? canvas?.scope_kind,
|
||||||
|
promotionScopeKeys.workspacePlacement,
|
||||||
|
promotionScopeKeys.taskPlacement,
|
||||||
|
t,
|
||||||
|
) ?? "",
|
||||||
|
testId: "canvas-promotion-source-scope",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: t("canvases:promotionTargetScope"),
|
||||||
|
value:
|
||||||
|
promotionScopeLabel(
|
||||||
|
preview.target_scope,
|
||||||
|
promotionScopeKeys.workspacePlacement,
|
||||||
|
promotionScopeKeys.taskPlacement,
|
||||||
|
t,
|
||||||
|
) ?? "",
|
||||||
|
testId: "canvas-promotion-target-scope",
|
||||||
|
},
|
||||||
|
].filter((row) => row.value !== "");
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildPromotionSourceRows(
|
||||||
|
canvas: Canvas | null,
|
||||||
|
preview: CanvasPromotionPreview,
|
||||||
|
t: (key: string) => string,
|
||||||
|
): PromotionMetadataRow[] {
|
||||||
|
const taskID =
|
||||||
|
preview.source_task_id ?? preview.origin_task_id ?? canvas?.origin_task_id ?? canvas?.task_id;
|
||||||
|
const sessionID = preview.source_session_id ?? canvas?.created_by_session_id;
|
||||||
|
const actor = preview.source_actor_kind
|
||||||
|
? canvasSourceActorLabel(preview.source_actor_kind, t)
|
||||||
|
: undefined;
|
||||||
|
const task = taskID ? canvasSourceLabel(preview.source_task_title, t) : undefined;
|
||||||
|
const session = sessionID ? canvasSourceLabel(preview.source_session_name, t) : undefined;
|
||||||
|
return [
|
||||||
|
optionalPromotionRow(
|
||||||
|
actor,
|
||||||
"canvases:promotionSourceActor",
|
"canvases:promotionSourceActor",
|
||||||
"canvas-promotion-source-actor",
|
"canvas-promotion-source-actor",
|
||||||
|
t,
|
||||||
),
|
),
|
||||||
row(sourceTask, "canvases:promotionSourceTask", "canvas-promotion-source-task"),
|
optionalPromotionRow(task, "canvases:promotionSourceTask", "canvas-promotion-source-task", t),
|
||||||
row(sourceSession, "canvases:promotionSourceSession", "canvas-promotion-source-session"),
|
optionalPromotionRow(
|
||||||
row(preview.target_scope, "canvases:promotionTargetScope", "canvas-promotion-target-scope"),
|
session,
|
||||||
row(preview.placement, "canvases:promotionPlacement", "canvas-promotion-placement"),
|
"canvases:promotionSourceSession",
|
||||||
].filter((item): item is PromotionMetadataRow => item !== null);
|
"canvas-promotion-source-session",
|
||||||
|
t,
|
||||||
|
),
|
||||||
|
].filter((row): row is PromotionMetadataRow => row !== null);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildPromotionDataScopeRows(
|
||||||
|
canvas: Canvas | null,
|
||||||
|
preview: CanvasPromotionPreview,
|
||||||
|
t: (key: string) => string,
|
||||||
|
): PromotionMetadataRow[] {
|
||||||
|
const currentScope =
|
||||||
|
preview.current_data_scope_kind ?? canvas?.data_scope_kind ?? canvas?.scope_kind;
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
label: t("canvases:promotionCurrentDataScope"),
|
||||||
|
value:
|
||||||
|
promotionScopeLabel(
|
||||||
|
currentScope,
|
||||||
|
promotionScopeKeys.workspaceData,
|
||||||
|
promotionScopeKeys.taskData,
|
||||||
|
t,
|
||||||
|
) ?? "",
|
||||||
|
testId: "canvas-promotion-current-data-scope",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: t("canvases:promotionTargetDataScope"),
|
||||||
|
value:
|
||||||
|
promotionScopeLabel(
|
||||||
|
preview.target_data_scope_kind,
|
||||||
|
promotionScopeKeys.workspaceData,
|
||||||
|
promotionScopeKeys.taskData,
|
||||||
|
t,
|
||||||
|
) ?? "",
|
||||||
|
testId: "canvas-promotion-target-data-scope",
|
||||||
|
},
|
||||||
|
].filter((row) => row.value !== "");
|
||||||
|
}
|
||||||
|
|
||||||
|
function optionalPromotionRow(
|
||||||
|
value: string | undefined,
|
||||||
|
labelKey: string,
|
||||||
|
testId: string,
|
||||||
|
t: (key: string) => string,
|
||||||
|
): PromotionMetadataRow | null {
|
||||||
|
return value ? { label: t(labelKey), value, testId } : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function PromotionMetadata({
|
function PromotionMetadata({
|
||||||
@@ -198,6 +305,11 @@ export function CanvasPromotionDialog({
|
|||||||
{preview && (
|
{preview && (
|
||||||
<div className="space-y-3 text-sm">
|
<div className="space-y-3 text-sm">
|
||||||
<p>{t("canvases:promotionScopeChange")}</p>
|
<p>{t("canvases:promotionScopeChange")}</p>
|
||||||
|
<p>
|
||||||
|
{preview.current_data_scope_kind === preview.target_data_scope_kind
|
||||||
|
? t("canvases:promotionDataAccessPreserved")
|
||||||
|
: t("canvases:promotionDataAccessChanges")}
|
||||||
|
</p>
|
||||||
<PromotionMetadata canvas={canvas} preview={preview} />
|
<PromotionMetadata canvas={canvas} preview={preview} />
|
||||||
{permissionGroups.length > 0 ? (
|
{permissionGroups.length > 0 ? (
|
||||||
<div className="max-h-48 space-y-3 overflow-y-auto rounded-md border p-3">
|
<div className="max-h-48 space-y-3 overflow-y-auto rounded-md border p-3">
|
||||||
@@ -235,3 +347,221 @@ export function CanvasPromotionDialog({
|
|||||||
</Dialog>
|
</Dialog>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function useCanvasWorkspaceDataReview(
|
||||||
|
canvas: Canvas | null,
|
||||||
|
open: boolean,
|
||||||
|
onOpenChange: (open: boolean) => void,
|
||||||
|
onCompleted?: (canvas: Canvas) => void,
|
||||||
|
) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const lifecycleRevision = useCanvasLifecycleRevision();
|
||||||
|
const [preview, setPreview] = useState<CanvasWorkspaceDataPreview | null>(null);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [confirming, setConfirming] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const canvasId = canvas?.id;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open || !canvasId) return;
|
||||||
|
let cancelled = false;
|
||||||
|
setLoading(true);
|
||||||
|
setPreview(null);
|
||||||
|
setError(null);
|
||||||
|
requestCanvasWorkspaceData(canvasId)
|
||||||
|
.then((value) => {
|
||||||
|
if (!cancelled) setPreview(value);
|
||||||
|
})
|
||||||
|
.catch((reason: unknown) => {
|
||||||
|
if (!cancelled) setError(canvasErrorMessage(reason, t, CANVAS_ACTION_FAILED_KEY));
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
if (!cancelled) setLoading(false);
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [canvasId, lifecycleRevision, open, t]);
|
||||||
|
|
||||||
|
const permissionGroups = buildCanvasPermissionGroups(preview?.permissions, undefined, t);
|
||||||
|
const unsupportedPermissions = hasUnsupportedPermissions(permissionGroups);
|
||||||
|
const confirm = useCallback(async () => {
|
||||||
|
if (
|
||||||
|
!canvas ||
|
||||||
|
!preview?.active_release_id ||
|
||||||
|
!preview.permission_digest ||
|
||||||
|
preview.grant_generation === undefined ||
|
||||||
|
unsupportedPermissions
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setConfirming(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const updated = await enableCanvasWorkspaceData(canvas.id, {
|
||||||
|
expected_release_id: preview.active_release_id,
|
||||||
|
expected_permission_digest: preview.permission_digest,
|
||||||
|
expected_grant_generation: preview.grant_generation,
|
||||||
|
});
|
||||||
|
onCompleted?.(updated);
|
||||||
|
onOpenChange(false);
|
||||||
|
} catch (reason: unknown) {
|
||||||
|
setError(canvasErrorMessage(reason, t, CANVAS_ACTION_FAILED_KEY));
|
||||||
|
} finally {
|
||||||
|
setConfirming(false);
|
||||||
|
}
|
||||||
|
}, [canvas, onCompleted, onOpenChange, preview, t, unsupportedPermissions]);
|
||||||
|
|
||||||
|
return { preview, loading, confirming, error, confirm, unsupportedPermissions };
|
||||||
|
}
|
||||||
|
|
||||||
|
function WorkspaceDataReviewDetails({
|
||||||
|
preview,
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
t,
|
||||||
|
}: {
|
||||||
|
preview: CanvasWorkspaceDataPreview | null;
|
||||||
|
loading: boolean;
|
||||||
|
error: string | null;
|
||||||
|
t: (key: string) => string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="min-h-0 flex-1 space-y-3 overflow-y-auto overscroll-contain p-4 sm:p-6"
|
||||||
|
data-testid="canvas-workspace-data-review-scroll"
|
||||||
|
>
|
||||||
|
{loading && <p role="status">{t("canvases:loadingPermissions")}</p>}
|
||||||
|
{error && (
|
||||||
|
<p role="alert" className="text-sm text-destructive">
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{preview && (
|
||||||
|
<>
|
||||||
|
<p className="text-sm">{t("canvases:workspaceDataReviewScopeChange")}</p>
|
||||||
|
<dl className="grid gap-2 rounded-md bg-muted/40 p-3 text-sm">
|
||||||
|
<div className="grid grid-cols-[auto_minmax(0,1fr)] gap-3">
|
||||||
|
<dt className="text-muted-foreground">{t("canvases:activeRelease")}</dt>
|
||||||
|
<dd className="min-w-0 break-all" data-testid="canvas-workspace-data-release">
|
||||||
|
{preview.active_release_id}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-[auto_minmax(0,1fr)] gap-3">
|
||||||
|
<dt className="text-muted-foreground">{t("canvases:promotionCurrentDataScope")}</dt>
|
||||||
|
<dd data-testid="canvas-workspace-data-current-scope">
|
||||||
|
{t(
|
||||||
|
preview.current_data_scope_kind === "workspace"
|
||||||
|
? "canvases:workspaceDataScope"
|
||||||
|
: "canvases:taskDataScope",
|
||||||
|
)}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-[auto_minmax(0,1fr)] gap-3">
|
||||||
|
<dt className="text-muted-foreground">{t("canvases:promotionTargetDataScope")}</dt>
|
||||||
|
<dd data-testid="canvas-workspace-data-target-scope">
|
||||||
|
{t(
|
||||||
|
preview.target_data_scope_kind === "task"
|
||||||
|
? "canvases:taskDataScope"
|
||||||
|
: "canvases:workspaceDataScope",
|
||||||
|
)}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
<CanvasPermissionSummary permissions={preview.permissions} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function WorkspaceDataReviewFooter({
|
||||||
|
isMobile,
|
||||||
|
disabled,
|
||||||
|
confirming,
|
||||||
|
onCancel,
|
||||||
|
onConfirm,
|
||||||
|
t,
|
||||||
|
}: {
|
||||||
|
isMobile: boolean;
|
||||||
|
disabled: boolean;
|
||||||
|
confirming: boolean;
|
||||||
|
onCancel: () => void;
|
||||||
|
onConfirm: () => void;
|
||||||
|
t: (key: string) => string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<DialogFooter
|
||||||
|
className={
|
||||||
|
isMobile
|
||||||
|
? "shrink-0 grid grid-cols-2 border-t border-border/70 p-3 pb-[max(0.75rem,env(safe-area-inset-bottom))]"
|
||||||
|
: "shrink-0 flex-row border-t border-border/70 p-4 sm:p-6"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Button variant="outline" className={canvasActionClassName} onClick={onCancel}>
|
||||||
|
{t("common:cancel")}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
className={`${canvasActionClassName}${isMobile ? " min-h-12" : ""}`}
|
||||||
|
disabled={disabled}
|
||||||
|
onClick={onConfirm}
|
||||||
|
>
|
||||||
|
{confirming ? t("canvases:enablingWorkspaceData") : t("canvases:enableWorkspaceData")}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CanvasWorkspaceDataDialog({
|
||||||
|
canvas,
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
onCompleted,
|
||||||
|
}: {
|
||||||
|
canvas: Canvas | null;
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
onCompleted?: (canvas: Canvas) => void;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { isMobile } = useResponsiveBreakpoint();
|
||||||
|
const { preview, loading, confirming, error, confirm, unsupportedPermissions } =
|
||||||
|
useCanvasWorkspaceDataReview(canvas, open, onOpenChange, onCompleted);
|
||||||
|
const surfaceClassName = isMobile
|
||||||
|
? "!left-0 !top-0 !h-dvh !max-h-dvh !w-screen !max-w-none !translate-x-0 !translate-y-0 flex flex-col gap-0 overflow-hidden rounded-none p-0 [padding-top:max(1rem,env(safe-area-inset-top))]"
|
||||||
|
: "flex max-h-[min(90dvh,48rem)] w-full flex-col gap-0 overflow-hidden p-0 sm:max-w-lg";
|
||||||
|
|
||||||
|
// The footer uses a labeled Cancel action instead of an icon-only close button.
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent
|
||||||
|
data-testid="canvas-workspace-data-dialog"
|
||||||
|
className={surfaceClassName}
|
||||||
|
showCloseButton={false}
|
||||||
|
>
|
||||||
|
<DialogHeader className="shrink-0 border-b border-border/70 p-4 text-left sm:p-6">
|
||||||
|
<DialogTitle>{t("canvases:enableWorkspaceData")}</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
{t("canvases:workspaceDataReviewDescription", { title: canvas?.title ?? "" })}
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<WorkspaceDataReviewDetails preview={preview} loading={loading} error={error} t={t} />
|
||||||
|
<WorkspaceDataReviewFooter
|
||||||
|
isMobile={isMobile}
|
||||||
|
disabled={
|
||||||
|
!preview ||
|
||||||
|
!preview.active_release_id ||
|
||||||
|
!preview.permission_digest ||
|
||||||
|
preview.grant_generation === undefined ||
|
||||||
|
unsupportedPermissions ||
|
||||||
|
confirming
|
||||||
|
}
|
||||||
|
confirming={confirming}
|
||||||
|
onCancel={() => onOpenChange(false)}
|
||||||
|
onConfirm={() => void confirm()}
|
||||||
|
t={t}
|
||||||
|
/>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -76,7 +76,7 @@ test.describe("authenticated same-origin canvas runtime", () => {
|
|||||||
true,
|
true,
|
||||||
);
|
);
|
||||||
|
|
||||||
await frame.getByTestId("canvas-fixture-continue").click();
|
await frame.getByTestId("canvas-fixture-continue").dispatchEvent("click");
|
||||||
await expect(frame.getByTestId("canvas-fixture-message-status")).toHaveText("accepted");
|
await expect(frame.getByTestId("canvas-fixture-message-status")).toHaveText("accepted");
|
||||||
await expect
|
await expect
|
||||||
.poll(async () =>
|
.poll(async () =>
|
||||||
|
|||||||
@@ -20,6 +20,8 @@ export type CanvasRecord = {
|
|||||||
workspace_id: string;
|
workspace_id: string;
|
||||||
task_id?: string;
|
task_id?: string;
|
||||||
scope_kind?: string;
|
scope_kind?: string;
|
||||||
|
data_scope_kind?: string;
|
||||||
|
grant_generation?: number;
|
||||||
status?: string;
|
status?: string;
|
||||||
active_release_id?: string;
|
active_release_id?: string;
|
||||||
active_release_status?: string;
|
active_release_status?: string;
|
||||||
@@ -47,6 +49,31 @@ export type SeededCanvas = {
|
|||||||
session: SessionPage;
|
session: SessionPage;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export async function seedCanvasWorkspacePreview(apiClient: ApiClient, seedData: SeedData) {
|
||||||
|
const previewTask = await apiClient.seedTask(
|
||||||
|
seedData.workspaceId,
|
||||||
|
"E2E Canvas Workspace Preview Task",
|
||||||
|
{ workflow_id: seedData.workflowId, workflow_step_id: seedData.startStepId },
|
||||||
|
);
|
||||||
|
const foreignWorkspaceName = "E2E Canvas Foreign Workspace";
|
||||||
|
try {
|
||||||
|
const foreignWorkspace = await apiClient.createWorkspace(foreignWorkspaceName);
|
||||||
|
return {
|
||||||
|
workspaceTaskId: previewTask.task_id,
|
||||||
|
foreignWorkspaceId: foreignWorkspace.id,
|
||||||
|
cleanup: async () => {
|
||||||
|
await apiClient.deleteTask(previewTask.task_id).catch(() => undefined);
|
||||||
|
await apiClient
|
||||||
|
.deleteWorkspace(foreignWorkspace.id, foreignWorkspaceName)
|
||||||
|
.catch(() => undefined);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
await apiClient.deleteTask(previewTask.task_id).catch(() => undefined);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function expectCanvasFrameFillsHost(page: Page): Promise<void> {
|
export async function expectCanvasFrameFillsHost(page: Page): Promise<void> {
|
||||||
await expect
|
await expect
|
||||||
.poll(
|
.poll(
|
||||||
@@ -182,6 +209,7 @@ export async function waitForSessionWorkspace(
|
|||||||
export type CanvasSourceOptions = {
|
export type CanvasSourceOptions = {
|
||||||
noPermissions?: boolean;
|
noPermissions?: boolean;
|
||||||
minimalPermissions?: boolean;
|
minimalPermissions?: boolean;
|
||||||
|
foreignWorkspaceId?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
function canvasCapabilities(options?: CanvasSourceOptions): string[] {
|
function canvasCapabilities(options?: CanvasSourceOptions): string[] {
|
||||||
@@ -230,10 +258,12 @@ function canvasManifest(canvas: CanvasRecord, options?: CanvasSourceOptions): st
|
|||||||
].join("\n");
|
].join("\n");
|
||||||
}
|
}
|
||||||
|
|
||||||
function canvasFixtureScript(canvas: CanvasRecord): string {
|
function canvasFixtureScript(canvas: CanvasRecord, options?: CanvasSourceOptions): string {
|
||||||
const taskID = JSON.stringify(canvas.task_id ?? "");
|
const taskID = JSON.stringify(canvas.task_id ?? "");
|
||||||
|
const foreignWorkspaceID = JSON.stringify(options?.foreignWorkspaceId ?? "");
|
||||||
return String.raw`(() => {
|
return String.raw`(() => {
|
||||||
const taskId = ${taskID};
|
const taskId = ${taskID};
|
||||||
|
const foreignWorkspaceId = ${foreignWorkspaceID};
|
||||||
const text = (testId, value) => {
|
const text = (testId, value) => {
|
||||||
const element = document.querySelector('[data-testid="' + testId + '"]');
|
const element = document.querySelector('[data-testid="' + testId + '"]');
|
||||||
if (element) element.textContent = String(value);
|
if (element) element.textContent = String(value);
|
||||||
@@ -260,6 +290,23 @@ function canvasFixtureScript(canvas: CanvasRecord): string {
|
|||||||
let streamReader;
|
let streamReader;
|
||||||
let streamCompletion = Promise.resolve();
|
let streamCompletion = Promise.resolve();
|
||||||
|
|
||||||
|
const renderTasks = (tasks) => {
|
||||||
|
const list = document.querySelector('[data-testid="canvas-fixture-task-list"]');
|
||||||
|
if (!list) return;
|
||||||
|
list.replaceChildren();
|
||||||
|
tasks.forEach((candidate) => {
|
||||||
|
const item = document.createElement("article");
|
||||||
|
item.className = "task-item";
|
||||||
|
const title = document.createElement("h2");
|
||||||
|
title.textContent = candidate.title || "Workspace task";
|
||||||
|
const status = document.createElement("span");
|
||||||
|
status.className = "task-status";
|
||||||
|
status.textContent = candidate.status || "Open";
|
||||||
|
item.append(title, status);
|
||||||
|
list.append(item);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
const renderAppearance = () => {
|
const renderAppearance = () => {
|
||||||
const root = document.documentElement;
|
const root = document.documentElement;
|
||||||
const styles = getComputedStyle(root);
|
const styles = getComputedStyle(root);
|
||||||
@@ -275,12 +322,22 @@ function canvasFixtureScript(canvas: CanvasRecord): string {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const loadProjection = async () => {
|
const loadProjection = async () => {
|
||||||
const [context, taskPage, workflowPage] = await Promise.all([
|
const [context, tasks, workflowPage] = await Promise.all([
|
||||||
api("./_kandev/v1/context"),
|
api("./_kandev/v1/context"),
|
||||||
api("./_kandev/v1/data/tasks?limit=10"),
|
(async () => {
|
||||||
|
const items = [];
|
||||||
|
let cursor = "";
|
||||||
|
do {
|
||||||
|
const query = new URLSearchParams({ limit: "1" });
|
||||||
|
if (cursor) query.set("cursor", cursor);
|
||||||
|
const page = await api("./_kandev/v1/data/tasks?" + query.toString());
|
||||||
|
items.push(...(page.items || []));
|
||||||
|
cursor = page.page_info?.next_cursor || "";
|
||||||
|
} while (cursor);
|
||||||
|
return items;
|
||||||
|
})(),
|
||||||
api("./_kandev/v1/data/workflows?limit=10"),
|
api("./_kandev/v1/data/workflows?limit=10"),
|
||||||
]);
|
]);
|
||||||
const tasks = taskPage.items || [];
|
|
||||||
task = tasks.find((candidate) => candidate.id === taskId) || tasks[0];
|
task = tasks.find((candidate) => candidate.id === taskId) || tasks[0];
|
||||||
const workflow = (workflowPage.items || []).find(
|
const workflow = (workflowPage.items || []).find(
|
||||||
(candidate) => candidate.id === task?.workflow_id,
|
(candidate) => candidate.id === task?.workflow_id,
|
||||||
@@ -293,8 +350,19 @@ function canvasFixtureScript(canvas: CanvasRecord): string {
|
|||||||
}
|
}
|
||||||
text("canvas-fixture-context", context.task_id || "workspace");
|
text("canvas-fixture-context", context.task_id || "workspace");
|
||||||
text("canvas-fixture-task-count", tasks.length);
|
text("canvas-fixture-task-count", tasks.length);
|
||||||
|
text("canvas-fixture-task-ids", tasks.map((candidate) => candidate.id).join(","));
|
||||||
|
renderTasks(tasks);
|
||||||
text("canvas-fixture-workflow-count", workflowPage.items?.length || 0);
|
text("canvas-fixture-workflow-count", workflowPage.items?.length || 0);
|
||||||
text("canvas-fixture-step-id", task?.workflow_step_id || "");
|
text("canvas-fixture-step-id", task?.workflow_step_id || "");
|
||||||
|
text("canvas-fixture-refresh-status", "refreshed");
|
||||||
|
if (foreignWorkspaceId) {
|
||||||
|
try {
|
||||||
|
await api("./_kandev/v1/data/tasks?workspace_id=" + encodeURIComponent(foreignWorkspaceId));
|
||||||
|
text("canvas-fixture-foreign-workspace-status", "unexpected-allowed");
|
||||||
|
} catch (error) {
|
||||||
|
text("canvas-fixture-foreign-workspace-status", "denied:" + error.status);
|
||||||
|
}
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const parseEvent = (block) => {
|
const parseEvent = (block) => {
|
||||||
@@ -425,6 +493,12 @@ function canvasFixtureScript(canvas: CanvasRecord): string {
|
|||||||
document.querySelector('[data-testid="canvas-fixture-resync"]')?.addEventListener("click", () => {
|
document.querySelector('[data-testid="canvas-fixture-resync"]')?.addEventListener("click", () => {
|
||||||
void connectEvents("old-generation:1");
|
void connectEvents("old-generation:1");
|
||||||
});
|
});
|
||||||
|
document.querySelector('[data-testid="canvas-fixture-refresh"]')?.addEventListener("click", () => {
|
||||||
|
text("canvas-fixture-refresh-status", "refreshing");
|
||||||
|
void loadProjection().catch((error) => {
|
||||||
|
text("canvas-fixture-refresh-status", "error:" + error.status);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
text("canvas-fixture-script", "inline-ready");
|
text("canvas-fixture-script", "inline-ready");
|
||||||
renderAppearance();
|
renderAppearance();
|
||||||
@@ -445,33 +519,39 @@ export function writeCanvasSource(
|
|||||||
[
|
[
|
||||||
"<!doctype html>",
|
"<!doctype html>",
|
||||||
'<html lang="en">',
|
'<html lang="en">',
|
||||||
' <head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>E2E Plugin Canvas</title><link rel="stylesheet" href="./styles.css"><script src="./appearance.js"></script></head>',
|
' <head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>E2E Plugin Canvas</title><script src="./appearance.js"></script>',
|
||||||
|
" <style>",
|
||||||
|
" :root { font-family: Inter, ui-sans-serif, system-ui, sans-serif; color: #172033; background: #f7f8fc; font-synthesis: none; }",
|
||||||
|
" * { box-sizing: border-box; } body { margin: 0; padding: 32px; } .canvas-shell { max-width: 920px; margin: 0 auto; }",
|
||||||
|
" .canvas-header { margin-bottom: 24px; } .eyebrow { margin: 0 0 8px; color: #6257df; font-size: 12px; font-weight: 700; letter-spacing: .1em; text-transform: uppercase; }",
|
||||||
|
" h1 { margin: 0; font-size: clamp(24px, 4vw, 34px); line-height: 1.2; letter-spacing: -.03em; } .summary { margin: 8px 0 0; color: #687187; font-size: 14px; }",
|
||||||
|
" .canvas-card { padding: 20px; border: 1px solid #e2e6ef; border-radius: 16px; background: white; box-shadow: 0 10px 32px #27345b0a; }",
|
||||||
|
" .list-heading { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-bottom: 14px; } .list-heading h2 { margin: 0; font-size: 15px; }",
|
||||||
|
" .task-count { color: #687187; font-size: 12px; } .task-list { display: grid; gap: 10px; }",
|
||||||
|
" .task-item { display: flex; align-items: center; justify-content: space-between; gap: 12px; min-height: 58px; padding: 12px 14px; border: 1px solid #e8ebf2; border-radius: 10px; background: #fff; }",
|
||||||
|
" .task-item h2 { margin: 0; font-size: 14px; font-weight: 600; } .task-status { flex: none; padding: 4px 8px; border-radius: 999px; background: #f0efff; color: #5046c7; font-size: 11px; font-weight: 600; text-transform: capitalize; }",
|
||||||
|
' .canvas-actions { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 16px; } button { min-height: 34px; padding: 7px 11px; border: 1px solid #dfe3ec; border-radius: 8px; background: white; color: #384158; font: inherit; font-size: 12px; cursor: pointer; -webkit-tap-highlight-color: transparent; } button:hover { background: #f6f7fb; } button[data-testid="canvas-fixture-refresh"] { border-color: #5548e8; background: #5548e8; color: white; }',
|
||||||
|
" .diagnostics { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; clip-path: inset(50%); }",
|
||||||
|
' @media (max-width: 600px) { body { padding: 20px 16px; } .canvas-card { padding: 16px; } .task-item { align-items: flex-start; flex-direction: column; gap: 8px; } .canvas-actions button:not([data-testid="canvas-fixture-refresh"]) { display: none; } }',
|
||||||
|
" </style></head>",
|
||||||
" <body>",
|
" <body>",
|
||||||
' <main class="canvas-shell" data-testid="canvas-fixture-content">',
|
' <main class="canvas-shell" data-testid="canvas-fixture-content">',
|
||||||
' <header class="canvas-header"><p class="eyebrow" data-testid="canvas-fixture-eyebrow">E2E Plugin Canvas</p><h1>E2E Plugin Canvas</h1></header>',
|
' <header class="canvas-header"><p class="eyebrow" data-testid="canvas-fixture-eyebrow">Workspace preview</p><h1>Your workspace tasks</h1><p class="summary">A live view of work across this workspace.</p></header>',
|
||||||
' <section class="canvas-card">',
|
' <section class="canvas-card">',
|
||||||
' <p data-testid="canvas-fixture-script">loading</p>',
|
' <div class="list-heading"><h2>All workspace tasks</h2><span class="task-count"><span data-testid="canvas-fixture-task-count">0</span> tasks</span></div>',
|
||||||
' <p data-testid="canvas-fixture-context">loading</p>',
|
' <div class="task-list" data-testid="canvas-fixture-task-list" aria-label="Workspace tasks"></div>',
|
||||||
' <p data-testid="canvas-fixture-task-count">0</p>',
|
' <div class="canvas-actions"><button type="button" data-testid="canvas-fixture-refresh">Refresh list</button></div>',
|
||||||
' <p data-testid="canvas-fixture-workflow-count">0</p>',
|
' <div class="diagnostics" aria-hidden="true">',
|
||||||
' <p data-testid="canvas-fixture-step-id">loading</p>',
|
' <p data-testid="canvas-fixture-script">loading</p><p data-testid="canvas-fixture-context">loading</p><p data-testid="canvas-fixture-task-ids"></p>',
|
||||||
' <p data-testid="canvas-fixture-message-status">idle</p>',
|
' <p data-testid="canvas-fixture-refresh-status">idle</p><p data-testid="canvas-fixture-foreign-workspace-status">not-tested</p><p data-testid="canvas-fixture-workflow-count">0</p><p data-testid="canvas-fixture-step-id">loading</p>',
|
||||||
' <p data-testid="canvas-fixture-move-status">idle</p>',
|
' <p data-testid="canvas-fixture-message-status">idle</p><p data-testid="canvas-fixture-move-status">idle</p><p data-testid="canvas-fixture-state-status">idle</p><p data-testid="canvas-fixture-sse-status">loading</p>',
|
||||||
' <p data-testid="canvas-fixture-state-status">idle</p>',
|
' <p data-testid="canvas-fixture-sse-events">0</p><p data-testid="canvas-fixture-sse-resync">idle</p><p data-testid="canvas-fixture-appearance-mode">loading</p><p data-testid="canvas-fixture-appearance-background">loading</p><p data-testid="canvas-fixture-appearance-color-scheme">loading</p>',
|
||||||
' <p data-testid="canvas-fixture-sse-status">loading</p>',
|
' <button type="button" data-testid="canvas-fixture-continue">Continue</button><button type="button" data-testid="canvas-fixture-move">Move workflow step</button><button type="button" data-testid="canvas-fixture-state">Recover state</button>',
|
||||||
' <p data-testid="canvas-fixture-sse-events">0</p>',
|
' <button type="button" data-testid="canvas-fixture-reconnect">Reconnect events</button><button type="button" data-testid="canvas-fixture-resync">Force resync</button>',
|
||||||
' <p data-testid="canvas-fixture-sse-resync">idle</p>',
|
" </div>",
|
||||||
' <p data-testid="canvas-fixture-appearance-mode">loading</p>',
|
|
||||||
' <p data-testid="canvas-fixture-appearance-background">loading</p>',
|
|
||||||
' <p data-testid="canvas-fixture-appearance-color-scheme">loading</p>',
|
|
||||||
' <button type="button" data-testid="canvas-fixture-continue">Continue</button>',
|
|
||||||
' <button type="button" data-testid="canvas-fixture-move">Move workflow step</button>',
|
|
||||||
' <button type="button" data-testid="canvas-fixture-state">Recover state</button>',
|
|
||||||
' <button type="button" data-testid="canvas-fixture-reconnect">Reconnect events</button>',
|
|
||||||
' <button type="button" data-testid="canvas-fixture-resync">Force resync</button>',
|
|
||||||
" </section>",
|
" </section>",
|
||||||
" </main>",
|
" </main>",
|
||||||
` <script>${canvasFixtureScript(canvas)}</script>`,
|
` <script>${canvasFixtureScript(canvas, options)}</script>`,
|
||||||
' <script src="./script.js"></script>',
|
' <script src="./script.js"></script>',
|
||||||
" </body>",
|
" </body>",
|
||||||
"</html>",
|
"</html>",
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
publishTaskCanvas,
|
publishTaskCanvas,
|
||||||
removeCanvas,
|
removeCanvas,
|
||||||
promoteCanvas,
|
promoteCanvas,
|
||||||
|
seedCanvasWorkspacePreview,
|
||||||
seedTaskCanvas,
|
seedTaskCanvas,
|
||||||
waitForTaskCanvas,
|
waitForTaskCanvas,
|
||||||
} from "./canvas-fixture";
|
} from "./canvas-fixture";
|
||||||
@@ -348,6 +349,117 @@ test.describe("Plugin-backed canvases on mobile", () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("reviews workspace data for a legacy task canvas before enabling it", async ({
|
||||||
|
testPage,
|
||||||
|
apiClient,
|
||||||
|
backend,
|
||||||
|
seedData,
|
||||||
|
}) => {
|
||||||
|
test.setTimeout(150_000);
|
||||||
|
|
||||||
|
const releaseFeature = await enableCanvasFeature(backend, apiClient, seedData.workspaceId);
|
||||||
|
const canvasIds: string[] = [];
|
||||||
|
try {
|
||||||
|
const seeded = await seedTaskCanvas(testPage, apiClient, seedData, true);
|
||||||
|
canvasIds.push(seeded.canvas.id);
|
||||||
|
const activeCanvas = await approvePendingCanvasThroughHost(
|
||||||
|
testPage,
|
||||||
|
apiClient,
|
||||||
|
seeded.canvas,
|
||||||
|
);
|
||||||
|
const legacyCanvas = { ...activeCanvas, data_scope_kind: "task" };
|
||||||
|
const permissionDigest = "legacy-task-canvas-review";
|
||||||
|
const grantGeneration = activeCanvas.grant_generation ?? 0;
|
||||||
|
let workspaceDataEnabled = false;
|
||||||
|
let submittedReview: Record<string, unknown> | undefined;
|
||||||
|
const canvasURL = new RegExp(`/api/v1/canvases/${activeCanvas.id}(?:\\?.*)?$`);
|
||||||
|
|
||||||
|
await testPage.route(canvasURL, async (route) => {
|
||||||
|
await route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: "application/json",
|
||||||
|
body: JSON.stringify({
|
||||||
|
...activeCanvas,
|
||||||
|
data_scope_kind: workspaceDataEnabled ? "workspace" : "task",
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
await testPage.route(
|
||||||
|
new RegExp(`/api/v1/canvases/${activeCanvas.id}/workspace-data-preview(?:\\?.*)?$`),
|
||||||
|
async (route) => {
|
||||||
|
await route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: "application/json",
|
||||||
|
body: JSON.stringify({
|
||||||
|
canvas: legacyCanvas,
|
||||||
|
active_release_id: activeCanvas.active_release_id,
|
||||||
|
permission_digest: permissionDigest,
|
||||||
|
grant_generation: grantGeneration,
|
||||||
|
current_data_scope_kind: "task",
|
||||||
|
target_data_scope_kind: "workspace",
|
||||||
|
permissions: {
|
||||||
|
reads: ["tasks"],
|
||||||
|
writes: ["messages"],
|
||||||
|
events: ["task.updated"],
|
||||||
|
shared_state: false,
|
||||||
|
external_origins: [],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
await testPage.route(
|
||||||
|
new RegExp(`/api/v1/canvases/${activeCanvas.id}/workspace-data(?:\\?.*)?$`),
|
||||||
|
async (route) => {
|
||||||
|
submittedReview = route.request().postDataJSON() as Record<string, unknown>;
|
||||||
|
workspaceDataEnabled = true;
|
||||||
|
await route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: "application/json",
|
||||||
|
body: JSON.stringify({ ...legacyCanvas, data_scope_kind: "workspace" }),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
await testPage.goto(canvasHref(activeCanvas.id));
|
||||||
|
await expect(testPage.getByTestId("canvas-data-scope")).toHaveText("Task data");
|
||||||
|
await testPage.getByTestId("canvas-mobile-actions").tap();
|
||||||
|
const actions = testPage.getByTestId("canvas-mobile-actions-sheet");
|
||||||
|
await actions.getByTestId("canvas-action-enable-workspace-data").tap();
|
||||||
|
|
||||||
|
const review = testPage.getByTestId("canvas-workspace-data-dialog");
|
||||||
|
await expect(review).toBeVisible();
|
||||||
|
await expect(review.getByTestId("canvas-workspace-data-release")).toHaveText(
|
||||||
|
activeCanvas.active_release_id ?? "",
|
||||||
|
);
|
||||||
|
await expect(review.getByTestId("canvas-workspace-data-current-scope")).toHaveText(
|
||||||
|
"Task data",
|
||||||
|
);
|
||||||
|
await expect(review.getByTestId("canvas-workspace-data-target-scope")).toHaveText(
|
||||||
|
"Workspace data",
|
||||||
|
);
|
||||||
|
await expect(review.getByText("Read task data")).toBeVisible();
|
||||||
|
await review.getByRole("button", { name: "Enable workspace data", exact: true }).tap();
|
||||||
|
|
||||||
|
await expect(review).toHaveCount(0);
|
||||||
|
await expect(testPage.getByTestId("canvas-data-scope")).toHaveText("Workspace data");
|
||||||
|
expect(submittedReview).toEqual({
|
||||||
|
expected_release_id: activeCanvas.active_release_id,
|
||||||
|
expected_permission_digest: permissionDigest,
|
||||||
|
expected_grant_generation: grantGeneration,
|
||||||
|
});
|
||||||
|
await testPage.getByTestId("canvas-mobile-actions").tap();
|
||||||
|
await expect(
|
||||||
|
testPage
|
||||||
|
.getByTestId("canvas-mobile-actions-sheet")
|
||||||
|
.getByTestId("canvas-action-enable-workspace-data"),
|
||||||
|
).toHaveCount(0);
|
||||||
|
} finally {
|
||||||
|
await Promise.all(canvasIds.map((canvasId) => removeCanvas(apiClient, canvasId)));
|
||||||
|
await releaseFeature();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
test("uses a focused route, workspace navigation, and an inset action drawer", async ({
|
test("uses a focused route, workspace navigation, and an inset action drawer", async ({
|
||||||
testPage,
|
testPage,
|
||||||
apiClient,
|
apiClient,
|
||||||
@@ -358,8 +470,12 @@ test.describe("Plugin-backed canvases on mobile", () => {
|
|||||||
|
|
||||||
const releaseFeature = await enableCanvasFeature(backend, apiClient, seedData.workspaceId);
|
const releaseFeature = await enableCanvasFeature(backend, apiClient, seedData.workspaceId);
|
||||||
const canvasIds: string[] = [];
|
const canvasIds: string[] = [];
|
||||||
|
let workspacePreview: Awaited<ReturnType<typeof seedCanvasWorkspacePreview>> | undefined;
|
||||||
try {
|
try {
|
||||||
const seeded = await seedTaskCanvas(testPage, apiClient, seedData, true);
|
workspacePreview = await seedCanvasWorkspacePreview(apiClient, seedData);
|
||||||
|
const seeded = await seedTaskCanvas(testPage, apiClient, seedData, true, {
|
||||||
|
foreignWorkspaceId: workspacePreview.foreignWorkspaceId,
|
||||||
|
});
|
||||||
canvasIds.push(seeded.canvas.id);
|
canvasIds.push(seeded.canvas.id);
|
||||||
const activeCanvas = await approvePendingCanvasThroughHost(
|
const activeCanvas = await approvePendingCanvasThroughHost(
|
||||||
testPage,
|
testPage,
|
||||||
@@ -390,6 +506,21 @@ test.describe("Plugin-backed canvases on mobile", () => {
|
|||||||
);
|
);
|
||||||
await expect(fixture.getByTestId("canvas-fixture-context")).toHaveText(seeded.taskId);
|
await expect(fixture.getByTestId("canvas-fixture-context")).toHaveText(seeded.taskId);
|
||||||
await expect(fixture.getByTestId("canvas-fixture-sse-status")).toHaveText("connected");
|
await expect(fixture.getByTestId("canvas-fixture-sse-status")).toHaveText("connected");
|
||||||
|
const expectedTaskCount = (await apiClient.listTasks(seedData.workspaceId)).tasks.length;
|
||||||
|
expect(expectedTaskCount).toBeGreaterThanOrEqual(2);
|
||||||
|
await fixture.getByTestId("canvas-fixture-refresh").tap();
|
||||||
|
await expect(fixture.getByTestId("canvas-fixture-refresh-status")).toHaveText("refreshed");
|
||||||
|
await expect(fixture.getByTestId("canvas-fixture-task-count")).toHaveText(
|
||||||
|
String(expectedTaskCount),
|
||||||
|
);
|
||||||
|
await expect(fixture.locator(".task-item")).toHaveCount(expectedTaskCount);
|
||||||
|
await expect(fixture.getByTestId("canvas-fixture-task-ids")).toContainText(seeded.taskId);
|
||||||
|
await expect(fixture.getByTestId("canvas-fixture-task-ids")).toContainText(
|
||||||
|
workspacePreview.workspaceTaskId,
|
||||||
|
);
|
||||||
|
await expect(fixture.getByTestId("canvas-fixture-foreign-workspace-status")).toHaveText(
|
||||||
|
"denied:403",
|
||||||
|
);
|
||||||
|
|
||||||
const actionsButton = testPage.getByTestId("canvas-mobile-actions");
|
const actionsButton = testPage.getByTestId("canvas-mobile-actions");
|
||||||
await expect(actionsButton).toBeVisible();
|
await expect(actionsButton).toBeVisible();
|
||||||
@@ -409,7 +540,7 @@ test.describe("Plugin-backed canvases on mobile", () => {
|
|||||||
const promotionDialog = testPage.getByTestId("canvas-promotion-dialog");
|
const promotionDialog = testPage.getByTestId("canvas-promotion-dialog");
|
||||||
await expect(promotionDialog).toBeVisible();
|
await expect(promotionDialog).toBeVisible();
|
||||||
await expect(promotionDialog.getByTestId("canvas-promotion-target-scope")).toHaveText(
|
await expect(promotionDialog.getByTestId("canvas-promotion-target-scope")).toHaveText(
|
||||||
"workspace",
|
"Workspace",
|
||||||
);
|
);
|
||||||
await promotionDialog.getByRole("button", { name: "Confirm promotion", exact: true }).tap();
|
await promotionDialog.getByRole("button", { name: "Confirm promotion", exact: true }).tap();
|
||||||
|
|
||||||
@@ -480,6 +611,7 @@ test.describe("Plugin-backed canvases on mobile", () => {
|
|||||||
).toBeVisible();
|
).toBeVisible();
|
||||||
} finally {
|
} finally {
|
||||||
await Promise.all(canvasIds.map((canvasId) => removeCanvas(apiClient, canvasId)));
|
await Promise.all(canvasIds.map((canvasId) => removeCanvas(apiClient, canvasId)));
|
||||||
|
await workspacePreview?.cleanup();
|
||||||
await releaseFeature();
|
await releaseFeature();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
expectCanvasFrameFillsHost,
|
expectCanvasFrameFillsHost,
|
||||||
listCanvasReleases,
|
listCanvasReleases,
|
||||||
removeCanvas,
|
removeCanvas,
|
||||||
|
seedCanvasWorkspacePreview,
|
||||||
seedTaskCanvas,
|
seedTaskCanvas,
|
||||||
waitForSessionWorkspace,
|
waitForSessionWorkspace,
|
||||||
writeCanvasSource,
|
writeCanvasSource,
|
||||||
@@ -150,8 +151,12 @@ test.describe("Plugin-backed canvases in the desktop task workbench", () => {
|
|||||||
|
|
||||||
const releaseFeature = await enableCanvasFeature(backend, apiClient, seedData.workspaceId);
|
const releaseFeature = await enableCanvasFeature(backend, apiClient, seedData.workspaceId);
|
||||||
let canvasId: string | undefined;
|
let canvasId: string | undefined;
|
||||||
|
let workspacePreview: Awaited<ReturnType<typeof seedCanvasWorkspacePreview>> | undefined;
|
||||||
try {
|
try {
|
||||||
const seeded = await seedTaskCanvas(testPage, apiClient, seedData);
|
workspacePreview = await seedCanvasWorkspacePreview(apiClient, seedData);
|
||||||
|
const seeded = await seedTaskCanvas(testPage, apiClient, seedData, false, {
|
||||||
|
foreignWorkspaceId: workspacePreview.foreignWorkspaceId,
|
||||||
|
});
|
||||||
canvasId = seeded.canvas.id;
|
canvasId = seeded.canvas.id;
|
||||||
|
|
||||||
await expect(testPage.getByTestId("dockview-task-layout")).toBeVisible();
|
await expect(testPage.getByTestId("dockview-task-layout")).toBeVisible();
|
||||||
@@ -281,7 +286,21 @@ test.describe("Plugin-backed canvases in the desktop task workbench", () => {
|
|||||||
.getByTestId("canvas-fixture-appearance-background")
|
.getByTestId("canvas-fixture-appearance-background")
|
||||||
.textContent();
|
.textContent();
|
||||||
await expect(fixture.getByTestId("canvas-fixture-context")).toHaveText(seeded.taskId);
|
await expect(fixture.getByTestId("canvas-fixture-context")).toHaveText(seeded.taskId);
|
||||||
await expect(fixture.getByTestId("canvas-fixture-task-count")).toHaveText("1");
|
const expectedTaskCount = (await apiClient.listTasks(seedData.workspaceId)).tasks.length;
|
||||||
|
expect(expectedTaskCount).toBeGreaterThanOrEqual(2);
|
||||||
|
await fixture.getByTestId("canvas-fixture-refresh").click();
|
||||||
|
await expect(fixture.getByTestId("canvas-fixture-refresh-status")).toHaveText("refreshed");
|
||||||
|
await expect(fixture.getByTestId("canvas-fixture-task-count")).toHaveText(
|
||||||
|
String(expectedTaskCount),
|
||||||
|
);
|
||||||
|
await expect(fixture.locator(".task-item")).toHaveCount(expectedTaskCount);
|
||||||
|
await expect(fixture.getByTestId("canvas-fixture-task-ids")).toContainText(seeded.taskId);
|
||||||
|
await expect(fixture.getByTestId("canvas-fixture-task-ids")).toContainText(
|
||||||
|
workspacePreview.workspaceTaskId,
|
||||||
|
);
|
||||||
|
await expect(fixture.getByTestId("canvas-fixture-foreign-workspace-status")).toHaveText(
|
||||||
|
"denied:403",
|
||||||
|
);
|
||||||
await expect(fixture.getByTestId("canvas-fixture-workflow-count")).toHaveText("1");
|
await expect(fixture.getByTestId("canvas-fixture-workflow-count")).toHaveText("1");
|
||||||
await expect(fixture.getByTestId("canvas-fixture-step-id")).not.toHaveText("loading");
|
await expect(fixture.getByTestId("canvas-fixture-step-id")).not.toHaveText("loading");
|
||||||
await expect(fixture.getByTestId("canvas-fixture-sse-status")).toHaveText("connected");
|
await expect(fixture.getByTestId("canvas-fixture-sse-status")).toHaveText("connected");
|
||||||
@@ -357,6 +376,7 @@ test.describe("Plugin-backed canvases in the desktop task workbench", () => {
|
|||||||
.not.toBe(lightBackground);
|
.not.toBe(lightBackground);
|
||||||
} finally {
|
} finally {
|
||||||
if (canvasId) await removeCanvas(apiClient, canvasId);
|
if (canvasId) await removeCanvas(apiClient, canvasId);
|
||||||
|
await workspacePreview?.cleanup();
|
||||||
await releaseFeature();
|
await releaseFeature();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import {
|
|||||||
seedTaskCanvas,
|
seedTaskCanvas,
|
||||||
} from "./canvas-fixture";
|
} from "./canvas-fixture";
|
||||||
|
|
||||||
test("task and workspace transition reads stay inside canvas scope", async ({
|
test("workspace transition groups remain available before and after task canvas promotion", async ({
|
||||||
testPage,
|
testPage,
|
||||||
apiClient,
|
apiClient,
|
||||||
backend,
|
backend,
|
||||||
@@ -45,13 +45,16 @@ test("task and workspace transition reads stay inside canvas scope", async ({
|
|||||||
item.to_workflow_step_id === seedData.startStepId,
|
item.to_workflow_step_id === seedData.startStepId,
|
||||||
),
|
),
|
||||||
).toBe(true);
|
).toBe(true);
|
||||||
const taskScopeGroups = await body.evaluate(
|
const prePromotionGroups = await body.evaluate(async (_, flowId) => {
|
||||||
async (_, flowId) =>
|
const response = await fetch(
|
||||||
(await fetch(`./_kandev/v1/data/workflows/${encodeURIComponent(flowId)}/transition-groups`))
|
`./_kandev/v1/data/workflows/${encodeURIComponent(flowId)}/transition-groups?limit=200`,
|
||||||
.status,
|
);
|
||||||
seedData.workflowId,
|
return { status: response.status, body: await response.json() };
|
||||||
|
}, seedData.workflowId);
|
||||||
|
expect(prePromotionGroups.status).toBe(200);
|
||||||
|
expect(prePromotionGroups.body.items.some((item: { count: number }) => item.count > 0)).toBe(
|
||||||
|
true,
|
||||||
);
|
);
|
||||||
expect(taskScopeGroups).toBe(403);
|
|
||||||
const workspace = await promoteCanvas(apiClient, approved);
|
const workspace = await promoteCanvas(apiClient, approved);
|
||||||
await testPage.goto(canvasHref(workspace.id));
|
await testPage.goto(canvasHref(workspace.id));
|
||||||
const groups = await testPage
|
const groups = await testPage
|
||||||
|
|||||||
@@ -2,9 +2,11 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|||||||
import {
|
import {
|
||||||
archiveCanvas,
|
archiveCanvas,
|
||||||
confirmCanvasPromotion,
|
confirmCanvasPromotion,
|
||||||
|
enableCanvasWorkspaceData,
|
||||||
getCanvasRuntime,
|
getCanvasRuntime,
|
||||||
listTaskCanvases,
|
listTaskCanvases,
|
||||||
listWorkspaceCanvases,
|
listWorkspaceCanvases,
|
||||||
|
requestCanvasWorkspaceData,
|
||||||
requestCanvasPromotion,
|
requestCanvasPromotion,
|
||||||
type CanvasPromotionPreview,
|
type CanvasPromotionPreview,
|
||||||
type CanvasRuntimeResponse,
|
type CanvasRuntimeResponse,
|
||||||
@@ -153,7 +155,64 @@ describe("canvas host and lifecycle endpoints", () => {
|
|||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("workspace data scope endpoint", () => {
|
||||||
|
it("reviews and confirms a task canvas workspace data upgrade", async () => {
|
||||||
|
const preview = {
|
||||||
|
canvas: {
|
||||||
|
id: "canvas-1",
|
||||||
|
plugin_instance_id: "instance-1",
|
||||||
|
plugin_id: "plugin-1",
|
||||||
|
workspace_id: "workspace-1",
|
||||||
|
task_id: "task-1",
|
||||||
|
scope_kind: "task",
|
||||||
|
data_scope_kind: "task",
|
||||||
|
title: "Canvas",
|
||||||
|
status: "active",
|
||||||
|
},
|
||||||
|
active_release_id: "release-3",
|
||||||
|
permission_digest: "digest-3",
|
||||||
|
grant_generation: 9,
|
||||||
|
current_data_scope_kind: "task",
|
||||||
|
target_data_scope_kind: "workspace",
|
||||||
|
permissions: { reads: ["tasks"] },
|
||||||
|
};
|
||||||
|
fetchSpy
|
||||||
|
.mockResolvedValueOnce(jsonResponse(preview))
|
||||||
|
.mockResolvedValueOnce(jsonResponse({ ...preview.canvas, data_scope_kind: "workspace" }));
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
requestCanvasWorkspaceData("canvas-1", { baseUrl: API_BASE_URL }),
|
||||||
|
).resolves.toEqual(preview);
|
||||||
|
await enableCanvasWorkspaceData(
|
||||||
|
"canvas-1",
|
||||||
|
{
|
||||||
|
expected_release_id: "release-3",
|
||||||
|
expected_permission_digest: "digest-3",
|
||||||
|
expected_grant_generation: 9,
|
||||||
|
},
|
||||||
|
{ baseUrl: API_BASE_URL },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(fetchSpy.mock.calls[0][0]).toBe(
|
||||||
|
`${API_BASE_URL}/api/v1/canvases/canvas-1/workspace-data-preview`,
|
||||||
|
);
|
||||||
|
expect(fetchSpy.mock.calls[1][0]).toBe(
|
||||||
|
`${API_BASE_URL}/api/v1/canvases/canvas-1/workspace-data`,
|
||||||
|
);
|
||||||
|
expect(fetchSpy.mock.calls[1][1]).toMatchObject({
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({
|
||||||
|
expected_release_id: "release-3",
|
||||||
|
expected_permission_digest: "digest-3",
|
||||||
|
expected_grant_generation: 9,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("canvas archive endpoint", () => {
|
||||||
it("archives with a mutation request and preserves the API response", async () => {
|
it("archives with a mutation request and preserves the API response", async () => {
|
||||||
fetchSpy.mockResolvedValueOnce(jsonResponse({ id: "canvas-1", status: "archived" }));
|
fetchSpy.mockResolvedValueOnce(jsonResponse({ id: "canvas-1", status: "archived" }));
|
||||||
|
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ export type Canvas = {
|
|||||||
origin_task_id?: string;
|
origin_task_id?: string;
|
||||||
created_by_session_id?: string;
|
created_by_session_id?: string;
|
||||||
scope_kind: CanvasScopeKind | string;
|
scope_kind: CanvasScopeKind | string;
|
||||||
|
data_scope_kind?: CanvasScopeKind | string;
|
||||||
title: string;
|
title: string;
|
||||||
status: CanvasStatus | string;
|
status: CanvasStatus | string;
|
||||||
active_release_id?: string;
|
active_release_id?: string;
|
||||||
@@ -53,6 +54,19 @@ export type Canvas = {
|
|||||||
updated_at?: string;
|
updated_at?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export function canvasDataScope(canvas: Pick<Canvas, "scope_kind" | "data_scope_kind">): string {
|
||||||
|
return canvas.data_scope_kind || canvas.scope_kind;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function canvasCanEnableWorkspaceData(canvas: Canvas): boolean {
|
||||||
|
return (
|
||||||
|
canvas.scope_kind === "task" &&
|
||||||
|
canvasDataScope(canvas) === "task" &&
|
||||||
|
canvas.status === "active" &&
|
||||||
|
canvas.active_release_status === "valid"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export type CanvasListResponse = {
|
export type CanvasListResponse = {
|
||||||
canvases: Canvas[];
|
canvases: Canvas[];
|
||||||
total?: number;
|
total?: number;
|
||||||
@@ -68,6 +82,7 @@ export type CanvasRuntimeResponse = {
|
|||||||
canvas?: Canvas;
|
canvas?: Canvas;
|
||||||
binding?: {
|
binding?: {
|
||||||
scope_kind?: CanvasScopeKind | string;
|
scope_kind?: CanvasScopeKind | string;
|
||||||
|
data_scope_kind?: CanvasScopeKind | string;
|
||||||
grant_generation?: number;
|
grant_generation?: number;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
@@ -104,9 +119,21 @@ export type CanvasPromotionPreview = {
|
|||||||
grant_generation?: number;
|
grant_generation?: number;
|
||||||
current_scope?: CanvasScopeKind | string;
|
current_scope?: CanvasScopeKind | string;
|
||||||
target_scope?: CanvasScopeKind | string;
|
target_scope?: CanvasScopeKind | string;
|
||||||
|
current_data_scope_kind?: CanvasScopeKind | string;
|
||||||
|
target_data_scope_kind?: CanvasScopeKind | string;
|
||||||
placement?: string;
|
placement?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type CanvasWorkspaceDataPreview = {
|
||||||
|
canvas: Canvas;
|
||||||
|
active_release_id: string;
|
||||||
|
permission_digest: string;
|
||||||
|
grant_generation: number;
|
||||||
|
current_data_scope_kind: CanvasScopeKind | string;
|
||||||
|
target_data_scope_kind: CanvasScopeKind | string;
|
||||||
|
permissions?: CanvasPermissionReview;
|
||||||
|
};
|
||||||
|
|
||||||
export type CanvasReleaseListResponse = {
|
export type CanvasReleaseListResponse = {
|
||||||
releases: CanvasRelease[];
|
releases: CanvasRelease[];
|
||||||
};
|
};
|
||||||
@@ -198,6 +225,28 @@ export function confirmCanvasPromotion(
|
|||||||
return mutate<Canvas>(canvasPath(canvasId, "/promotion"), "POST", review, options);
|
return mutate<Canvas>(canvasPath(canvasId, "/promotion"), "POST", review, options);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function requestCanvasWorkspaceData(
|
||||||
|
canvasId: string,
|
||||||
|
options?: ApiRequestOptions,
|
||||||
|
): Promise<CanvasWorkspaceDataPreview> {
|
||||||
|
return fetchJson<CanvasWorkspaceDataPreview>(
|
||||||
|
canvasPath(canvasId, "/workspace-data-preview"),
|
||||||
|
options,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function enableCanvasWorkspaceData(
|
||||||
|
canvasId: string,
|
||||||
|
review: {
|
||||||
|
expected_release_id: string;
|
||||||
|
expected_permission_digest: string;
|
||||||
|
expected_grant_generation: number;
|
||||||
|
},
|
||||||
|
options?: ApiRequestOptions,
|
||||||
|
): Promise<Canvas> {
|
||||||
|
return mutate<Canvas>(canvasPath(canvasId, "/workspace-data"), "POST", review, options);
|
||||||
|
}
|
||||||
|
|
||||||
export function listCanvasReleases(
|
export function listCanvasReleases(
|
||||||
canvasId: string,
|
canvasId: string,
|
||||||
options?: ApiRequestOptions,
|
options?: ApiRequestOptions,
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ const CANVAS_API_ERROR_TRANSLATIONS: Record<string, string> = {
|
|||||||
artifact_unavailable: CANVAS_UNAVAILABLE_KEY,
|
artifact_unavailable: CANVAS_UNAVAILABLE_KEY,
|
||||||
active_release_missing: CANVAS_UNAVAILABLE_KEY,
|
active_release_missing: CANVAS_UNAVAILABLE_KEY,
|
||||||
promotion_review_stale: "canvases:actionFailed",
|
promotion_review_stale: "canvases:actionFailed",
|
||||||
|
workspace_data_review_stale: "canvases:workspaceDataReviewStale",
|
||||||
|
workspace_owner_required: "canvases:workspaceOwnerRequired",
|
||||||
canvas_edit_stale: "canvases:actionFailed",
|
canvas_edit_stale: "canvases:actionFailed",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -32,11 +32,25 @@
|
|||||||
"promoteCanvas": "Promote canvas",
|
"promoteCanvas": "Promote canvas",
|
||||||
"promoteCanvasHelp": "Make this task canvas available in workspace navigation.",
|
"promoteCanvasHelp": "Make this task canvas available in workspace navigation.",
|
||||||
"promoteCanvasUnavailable": "Promotion is available after a valid release.",
|
"promoteCanvasUnavailable": "Promotion is available after a valid release.",
|
||||||
|
"taskDataScope": "Task data",
|
||||||
|
"workspaceDataScope": "Workspace data",
|
||||||
|
"taskPlacementScope": "Task",
|
||||||
|
"workspacePlacementScope": "Workspace",
|
||||||
|
"enableWorkspaceData": "Enable workspace data",
|
||||||
|
"enableWorkspaceDataHelp": "Allow this canvas to use its declared permissions across the current workspace. This does not move it into workspace navigation.",
|
||||||
|
"workspaceDataReviewDescription": "Review the active release and its declared permissions for {{title}}.",
|
||||||
|
"workspaceDataReviewScopeChange": "This changes data access only. The canvas stays in its current task.",
|
||||||
|
"workspaceDataReviewStale": "Canvas or permission details changed. Close this review and open it again.",
|
||||||
|
"workspaceOwnerRequired": "Only the current workspace owner can enable workspace data.",
|
||||||
|
"enablingWorkspaceData": "Enabling workspace data",
|
||||||
|
"activeRelease": "Active release",
|
||||||
"openInNewTab": "Open in new tab",
|
"openInNewTab": "Open in new tab",
|
||||||
"openInNewTabHelp": "Open this canvas in a focused browser tab.",
|
"openInNewTabHelp": "Open this canvas in a focused browser tab.",
|
||||||
"promoteCanvasDescription": "Review permissions before moving {{title}} to workspace scope.",
|
"promoteCanvasDescription": "Review permissions before moving {{title}} to workspace scope.",
|
||||||
"loadingPermissions": "Loading requested permissions",
|
"loadingPermissions": "Loading requested permissions",
|
||||||
"promotionScopeChange": "Promotion makes this canvas available in workspace navigation.",
|
"promotionScopeChange": "Promotion makes this canvas available in workspace navigation.",
|
||||||
|
"promotionDataAccessPreserved": "Promotion changes placement only. Workspace data access already applies and will stay the same.",
|
||||||
|
"promotionDataAccessChanges": "Promotion changes placement and expands declared data permissions to the workspace.",
|
||||||
"noAdditionalPermissions": "No additional permissions were requested.",
|
"noAdditionalPermissions": "No additional permissions were requested.",
|
||||||
"sharedState": "Shared state",
|
"sharedState": "Shared state",
|
||||||
"promotingCanvas": "Promoting canvas",
|
"promotingCanvas": "Promoting canvas",
|
||||||
@@ -76,6 +90,8 @@
|
|||||||
"promotionSourceTask": "Source task",
|
"promotionSourceTask": "Source task",
|
||||||
"promotionSourceSession": "Source session",
|
"promotionSourceSession": "Source session",
|
||||||
"promotionTargetScope": "Target scope",
|
"promotionTargetScope": "Target scope",
|
||||||
|
"promotionCurrentDataScope": "Data access before promotion",
|
||||||
|
"promotionTargetDataScope": "Data access after promotion",
|
||||||
"promotionPlacement": "Workspace placement",
|
"promotionPlacement": "Workspace placement",
|
||||||
"permissionReads": "API reads",
|
"permissionReads": "API reads",
|
||||||
"permissionWrites": "API writes",
|
"permissionWrites": "API writes",
|
||||||
|
|||||||
@@ -32,11 +32,25 @@
|
|||||||
"promoteCanvas": "キャンバスを昇格",
|
"promoteCanvas": "キャンバスを昇格",
|
||||||
"promoteCanvasHelp": "このタスクキャンバスをワークスペースのナビゲーションで利用できるようにします。",
|
"promoteCanvasHelp": "このタスクキャンバスをワークスペースのナビゲーションで利用できるようにします。",
|
||||||
"promoteCanvasUnavailable": "有効なリリース後に昇格できます。",
|
"promoteCanvasUnavailable": "有効なリリース後に昇格できます。",
|
||||||
|
"taskDataScope": "タスクデータ",
|
||||||
|
"workspaceDataScope": "ワークスペースデータ",
|
||||||
|
"taskPlacementScope": "タスク",
|
||||||
|
"workspacePlacementScope": "ワークスペース",
|
||||||
|
"enableWorkspaceData": "ワークスペースデータを有効にする",
|
||||||
|
"enableWorkspaceDataHelp": "このキャンバスが宣言済みの権限を現在のワークスペース全体で使用できるようにします。キャンバスの配置は変わりません。",
|
||||||
|
"workspaceDataReviewDescription": "{{title}} のアクティブなリリースと宣言済みの権限を確認します。",
|
||||||
|
"workspaceDataReviewScopeChange": "データアクセスのみを変更します。キャンバスは現在のタスクに残ります。",
|
||||||
|
"workspaceDataReviewStale": "キャンバスまたは権限の情報が変更されました。この画面を閉じて、もう一度開いてください。",
|
||||||
|
"workspaceOwnerRequired": "ワークスペースデータを有効にできるのは現在のワークスペース所有者のみです。",
|
||||||
|
"enablingWorkspaceData": "ワークスペースデータを有効にしています",
|
||||||
|
"activeRelease": "アクティブなリリース",
|
||||||
"openInNewTab": "新しいタブで開く",
|
"openInNewTab": "新しいタブで開く",
|
||||||
"openInNewTabHelp": "このキャンバスを専用のブラウザータブで開きます。",
|
"openInNewTabHelp": "このキャンバスを専用のブラウザータブで開きます。",
|
||||||
"promoteCanvasDescription": "{{title}} をワークスペーススコープに移動する前に権限を確認してください。",
|
"promoteCanvasDescription": "{{title}} をワークスペーススコープに移動する前に権限を確認してください。",
|
||||||
"loadingPermissions": "要求された権限を読み込み中",
|
"loadingPermissions": "要求された権限を読み込み中",
|
||||||
"promotionScopeChange": "昇格すると、このキャンバスがワークスペースのナビゲーションで利用できるようになります。",
|
"promotionScopeChange": "昇格すると、このキャンバスがワークスペースのナビゲーションで利用できるようになります。",
|
||||||
|
"promotionDataAccessPreserved": "昇格では配置のみが変わります。ワークスペースデータへのアクセスはそのまま維持されます。",
|
||||||
|
"promotionDataAccessChanges": "昇格では配置が変わり、宣言済みのデータ権限がワークスペース全体に広がります。",
|
||||||
"noAdditionalPermissions": "追加の権限は要求されていません。",
|
"noAdditionalPermissions": "追加の権限は要求されていません。",
|
||||||
"sharedState": "共有状態",
|
"sharedState": "共有状態",
|
||||||
"promotingCanvas": "キャンバスを昇格中",
|
"promotingCanvas": "キャンバスを昇格中",
|
||||||
@@ -76,6 +90,8 @@
|
|||||||
"promotionSourceTask": "ソースタスク",
|
"promotionSourceTask": "ソースタスク",
|
||||||
"promotionSourceSession": "ソースセッション",
|
"promotionSourceSession": "ソースセッション",
|
||||||
"promotionTargetScope": "ターゲットスコープ",
|
"promotionTargetScope": "ターゲットスコープ",
|
||||||
|
"promotionCurrentDataScope": "昇格前のデータアクセス",
|
||||||
|
"promotionTargetDataScope": "昇格後のデータアクセス",
|
||||||
"promotionPlacement": "ワークスペース内の配置",
|
"promotionPlacement": "ワークスペース内の配置",
|
||||||
"permissionReads": "API 読み取り",
|
"permissionReads": "API 読み取り",
|
||||||
"permissionWrites": "API 書き込み",
|
"permissionWrites": "API 書き込み",
|
||||||
|
|||||||
@@ -32,11 +32,25 @@
|
|||||||
"promoteCanvas": "Ƥŕōḿōţē ćàńvàś",
|
"promoteCanvas": "Ƥŕōḿōţē ćàńvàś",
|
||||||
"promoteCanvasHelp": "Ḿàķē ţĥĩś ţàśķ ćàńvàś àvàĩĺàƀĺē ĩń ŵōŕķśƥàćē ńàvĩĝàţĩōń.",
|
"promoteCanvasHelp": "Ḿàķē ţĥĩś ţàśķ ćàńvàś àvàĩĺàƀĺē ĩń ŵōŕķśƥàćē ńàvĩĝàţĩōń.",
|
||||||
"promoteCanvasUnavailable": "Ƥŕōḿōţĩōń ĩś àvàĩĺàƀĺē àƒţēŕ à vàĺĩď ŕēĺēàśē.",
|
"promoteCanvasUnavailable": "Ƥŕōḿōţĩōń ĩś àvàĩĺàƀĺē àƒţēŕ à vàĺĩď ŕēĺēàśē.",
|
||||||
|
"taskDataScope": "Ţàśķ ďàţà",
|
||||||
|
"workspaceDataScope": "Ŵōŕķśƥàćē ďàţà",
|
||||||
|
"taskPlacementScope": "Ţàśķ",
|
||||||
|
"workspacePlacementScope": "Ŵōŕķśƥàćē",
|
||||||
|
"enableWorkspaceData": "Ēńàƀĺē ŵōŕķśƥàćē ďàţà",
|
||||||
|
"enableWorkspaceDataHelp": "Àĺĺōŵ ţĥĩś ćàńvàś ţō ũśē ĩţś ďēćĺàŕēď ƥēŕḿĩśśĩōńś àćŕōśś ţĥē ćũŕŕēńţ ŵōŕķśƥàćē. Ţĥĩś ďōēś ńōţ ḿōvē ĩţ ĩńţō ŵōŕķśƥàćē ńàvĩĝàţĩōń.",
|
||||||
|
"workspaceDataReviewDescription": "Ŕēvĩēŵ ţĥē àćţĩvē ŕēĺēàśē àńď ĩţś ďēćĺàŕēď ƥēŕḿĩśśĩōńś ƒōŕ {{title}}.",
|
||||||
|
"workspaceDataReviewScopeChange": "Ţĥĩś ćĥàńĝēś ďàţà àććēśś ōńĺŷ. Ţĥē ćàńvàś śţàŷś ĩń ĩţś ćũŕŕēńţ ţàśķ.",
|
||||||
|
"workspaceDataReviewStale": "Ţĥē ćàńvàś ōŕ ƥēŕḿĩśśĩōń ďēţàĩĺś ĥàvē ćĥàńĝēď. Ćĺōśē ţĥĩś ŕēvĩēŵ àńď ōƥēń ĩţ àĝàĩń.",
|
||||||
|
"workspaceOwnerRequired": "Ōńĺŷ ţĥē ćũŕŕēńţ ŵōŕķśƥàćē ōŵńēŕ ćàń ēńàƀĺē ŵōŕķśƥàćē ďàţà.",
|
||||||
|
"enablingWorkspaceData": "Ēńàƀĺĩńĝ ŵōŕķśƥàćē ďàţà",
|
||||||
|
"activeRelease": "Àćţĩvē ŕēĺēàśē",
|
||||||
"openInNewTab": "Ōƥēń ĩń ńēŵ ţàƀ",
|
"openInNewTab": "Ōƥēń ĩń ńēŵ ţàƀ",
|
||||||
"openInNewTabHelp": "Ōƥēń ţĥĩś ćàńvàś ĩń à ƒōćũśēď ƀŕōŵśēŕ ţàƀ.",
|
"openInNewTabHelp": "Ōƥēń ţĥĩś ćàńvàś ĩń à ƒōćũśēď ƀŕōŵśēŕ ţàƀ.",
|
||||||
"promoteCanvasDescription": "Ŕēvĩēŵ ƥēŕḿĩśśĩōńś ƀēƒōŕē ḿōvĩńĝ {{title}} ţō ŵōŕķśƥàćē śćōƥē.",
|
"promoteCanvasDescription": "Ŕēvĩēŵ ƥēŕḿĩśśĩōńś ƀēƒōŕē ḿōvĩńĝ {{title}} ţō ŵōŕķśƥàćē śćōƥē.",
|
||||||
"loadingPermissions": "Ĺōàďĩńĝ ŕēqũēśţēď ƥēŕḿĩśśĩōńś",
|
"loadingPermissions": "Ĺōàďĩńĝ ŕēqũēśţēď ƥēŕḿĩśśĩōńś",
|
||||||
"promotionScopeChange": "Ƥŕōḿōţĩōń ḿàķēś ţĥĩś ćàńvàś àvàĩĺàƀĺē ĩń ŵōŕķśƥàćē ńàvĩĝàţĩōń.",
|
"promotionScopeChange": "Ƥŕōḿōţĩōń ḿàķēś ţĥĩś ćàńvàś àvàĩĺàƀĺē ĩń ŵōŕķśƥàćē ńàvĩĝàţĩōń.",
|
||||||
|
"promotionDataAccessPreserved": "Ƥŕōḿōţĩōń ćĥàńĝēś ƥĺàćēḿēńţ ōńĺŷ. Ŵōŕķśƥàćē ďàţà àććēśś àĺŕēàďŷ àƥƥĺĩēś àńď ŵĩĺĺ śţàŷ ţĥē śàḿē.",
|
||||||
|
"promotionDataAccessChanges": "Ƥŕōḿōţĩōń ćĥàńĝēś ƥĺàćēḿēńţ àńď ēxƥàńďś ďēćĺàŕēď ďàţà ƥēŕḿĩśśĩōńś ţō ţĥē ŵōŕķśƥàćē.",
|
||||||
"noAdditionalPermissions": "Ńō àďďĩţĩōńàĺ ƥēŕḿĩśśĩōńś ŵēŕē ŕēqũēśţēď.",
|
"noAdditionalPermissions": "Ńō àďďĩţĩōńàĺ ƥēŕḿĩśśĩōńś ŵēŕē ŕēqũēśţēď.",
|
||||||
"sharedState": "Śĥàŕēď śţàţē",
|
"sharedState": "Śĥàŕēď śţàţē",
|
||||||
"promotingCanvas": "Ƥŕōḿōţĩńĝ ćàńvàś",
|
"promotingCanvas": "Ƥŕōḿōţĩńĝ ćàńvàś",
|
||||||
@@ -76,6 +90,8 @@
|
|||||||
"promotionSourceTask": "Śōũŕćē ţàśķ",
|
"promotionSourceTask": "Śōũŕćē ţàśķ",
|
||||||
"promotionSourceSession": "Śōũŕćē śēśśĩōń",
|
"promotionSourceSession": "Śōũŕćē śēśśĩōń",
|
||||||
"promotionTargetScope": "Ţàŕĝēţ śćōƥē",
|
"promotionTargetScope": "Ţàŕĝēţ śćōƥē",
|
||||||
|
"promotionCurrentDataScope": "Ďàţà àććēśś ƀēƒōŕē ƥŕōḿōţĩōń",
|
||||||
|
"promotionTargetDataScope": "Ďàţà àććēśś àƒţēŕ ƥŕōḿōţĩōń",
|
||||||
"promotionPlacement": "Ŵōŕķśƥàćē ƥĺàćēḿēńţ",
|
"promotionPlacement": "Ŵōŕķśƥàćē ƥĺàćēḿēńţ",
|
||||||
"permissionReads": "ÀƤĨ ŕēàďś",
|
"permissionReads": "ÀƤĨ ŕēàďś",
|
||||||
"permissionWrites": "ÀƤĨ ŵŕĩţēś",
|
"permissionWrites": "ÀƤĨ ŵŕĩţēś",
|
||||||
|
|||||||
@@ -32,11 +32,25 @@
|
|||||||
"promoteCanvas": "Promover canvas",
|
"promoteCanvas": "Promover canvas",
|
||||||
"promoteCanvasHelp": "Disponibiliza este canvas de tarefa na navegação da área de trabalho.",
|
"promoteCanvasHelp": "Disponibiliza este canvas de tarefa na navegação da área de trabalho.",
|
||||||
"promoteCanvasUnavailable": "A promoção fica disponível depois de uma versão válida.",
|
"promoteCanvasUnavailable": "A promoção fica disponível depois de uma versão válida.",
|
||||||
|
"taskDataScope": "Dados da tarefa",
|
||||||
|
"workspaceDataScope": "Dados do workspace",
|
||||||
|
"taskPlacementScope": "Tarefa",
|
||||||
|
"workspacePlacementScope": "Área de trabalho",
|
||||||
|
"enableWorkspaceData": "Ativar dados do workspace",
|
||||||
|
"enableWorkspaceDataHelp": "Permite que este canvas use as permissões declaradas em todo o workspace atual. Não adiciona o canvas à navegação do workspace.",
|
||||||
|
"workspaceDataReviewDescription": "Reveja a versão ativa e as permissões declaradas de {{title}}.",
|
||||||
|
"workspaceDataReviewScopeChange": "Isto altera apenas o acesso aos dados. O canvas mantém-se na tarefa atual.",
|
||||||
|
"workspaceDataReviewStale": "Os dados do canvas ou das permissões foram alterados. Feche esta revisão e volte a abri-la.",
|
||||||
|
"workspaceOwnerRequired": "Apenas o proprietário atual do workspace pode ativar os dados do workspace.",
|
||||||
|
"enablingWorkspaceData": "A ativar os dados do workspace",
|
||||||
|
"activeRelease": "Versão ativa",
|
||||||
"openInNewTab": "Abrir num novo separador",
|
"openInNewTab": "Abrir num novo separador",
|
||||||
"openInNewTabHelp": "Abre este canvas num separador focado do navegador.",
|
"openInNewTabHelp": "Abre este canvas num separador focado do navegador.",
|
||||||
"promoteCanvasDescription": "Reveja as permissões antes de mover {{title}} para o âmbito do workspace.",
|
"promoteCanvasDescription": "Reveja as permissões antes de mover {{title}} para o âmbito do workspace.",
|
||||||
"loadingPermissions": "A carregar as permissões pedidas",
|
"loadingPermissions": "A carregar as permissões pedidas",
|
||||||
"promotionScopeChange": "A promoção disponibiliza este canvas na navegação do workspace.",
|
"promotionScopeChange": "A promoção disponibiliza este canvas na navegação do workspace.",
|
||||||
|
"promotionDataAccessPreserved": "A promoção altera apenas a localização. O acesso aos dados do workspace mantém-se.",
|
||||||
|
"promotionDataAccessChanges": "A promoção altera a localização e alarga ao workspace as permissões de dados declaradas.",
|
||||||
"noAdditionalPermissions": "Não foram pedidas permissões adicionais.",
|
"noAdditionalPermissions": "Não foram pedidas permissões adicionais.",
|
||||||
"sharedState": "Estado partilhado",
|
"sharedState": "Estado partilhado",
|
||||||
"promotingCanvas": "A promover o canvas",
|
"promotingCanvas": "A promover o canvas",
|
||||||
@@ -76,6 +90,8 @@
|
|||||||
"promotionSourceTask": "Tarefa de origem",
|
"promotionSourceTask": "Tarefa de origem",
|
||||||
"promotionSourceSession": "Sessão de origem",
|
"promotionSourceSession": "Sessão de origem",
|
||||||
"promotionTargetScope": "Âmbito de destino",
|
"promotionTargetScope": "Âmbito de destino",
|
||||||
|
"promotionCurrentDataScope": "Acesso aos dados antes da promoção",
|
||||||
|
"promotionTargetDataScope": "Acesso aos dados depois da promoção",
|
||||||
"promotionPlacement": "Localização no workspace",
|
"promotionPlacement": "Localização no workspace",
|
||||||
"permissionReads": "Leituras da API",
|
"permissionReads": "Leituras da API",
|
||||||
"permissionWrites": "Escritas da API",
|
"permissionWrites": "Escritas da API",
|
||||||
|
|||||||
@@ -32,11 +32,25 @@
|
|||||||
"promoteCanvas": "提升画布",
|
"promoteCanvas": "提升画布",
|
||||||
"promoteCanvasHelp": "让此任务画布显示在工作区导航中。",
|
"promoteCanvasHelp": "让此任务画布显示在工作区导航中。",
|
||||||
"promoteCanvasUnavailable": "有效版本获批准后才能提升。",
|
"promoteCanvasUnavailable": "有效版本获批准后才能提升。",
|
||||||
|
"taskDataScope": "任务数据",
|
||||||
|
"workspaceDataScope": "工作区数据",
|
||||||
|
"taskPlacementScope": "任务",
|
||||||
|
"workspacePlacementScope": "工作区",
|
||||||
|
"enableWorkspaceData": "启用工作区数据",
|
||||||
|
"enableWorkspaceDataHelp": "允许此画布在当前工作区内使用其已声明的权限。此操作不会将画布移至工作区导航。",
|
||||||
|
"workspaceDataReviewDescription": "查看 {{title}} 的活动版本和已声明权限。",
|
||||||
|
"workspaceDataReviewScopeChange": "此操作只会更改数据访问权限。画布仍位于当前任务中。",
|
||||||
|
"workspaceDataReviewStale": "画布或权限信息已更改。请关闭此审核并重新打开。",
|
||||||
|
"workspaceOwnerRequired": "只有当前工作区所有者可以启用工作区数据。",
|
||||||
|
"enablingWorkspaceData": "正在启用工作区数据",
|
||||||
|
"activeRelease": "活动版本",
|
||||||
"openInNewTab": "在新标签页中打开",
|
"openInNewTab": "在新标签页中打开",
|
||||||
"openInNewTabHelp": "在聚焦的浏览器标签页中打开此画布。",
|
"openInNewTabHelp": "在聚焦的浏览器标签页中打开此画布。",
|
||||||
"promoteCanvasDescription": "将 {{title}} 移至工作区范围前,请先查看权限。",
|
"promoteCanvasDescription": "将 {{title}} 移至工作区范围前,请先查看权限。",
|
||||||
"loadingPermissions": "正在加载请求的权限",
|
"loadingPermissions": "正在加载请求的权限",
|
||||||
"promotionScopeChange": "提升后,此画布会出现在工作区导航中。",
|
"promotionScopeChange": "提升后,此画布会出现在工作区导航中。",
|
||||||
|
"promotionDataAccessPreserved": "提升只会更改位置。此画布已经可以访问工作区数据,提升后仍保持不变。",
|
||||||
|
"promotionDataAccessChanges": "提升会更改位置,并将已声明的数据权限扩展到工作区。",
|
||||||
"noAdditionalPermissions": "未请求其他权限。",
|
"noAdditionalPermissions": "未请求其他权限。",
|
||||||
"sharedState": "共享状态",
|
"sharedState": "共享状态",
|
||||||
"promotingCanvas": "正在提升画布",
|
"promotingCanvas": "正在提升画布",
|
||||||
@@ -76,6 +90,8 @@
|
|||||||
"promotionSourceTask": "来源任务",
|
"promotionSourceTask": "来源任务",
|
||||||
"promotionSourceSession": "来源会话",
|
"promotionSourceSession": "来源会话",
|
||||||
"promotionTargetScope": "目标范围",
|
"promotionTargetScope": "目标范围",
|
||||||
|
"promotionCurrentDataScope": "提升前的数据访问范围",
|
||||||
|
"promotionTargetDataScope": "提升后的数据访问范围",
|
||||||
"promotionPlacement": "工作区位置",
|
"promotionPlacement": "工作区位置",
|
||||||
"permissionReads": "API 读取",
|
"permissionReads": "API 读取",
|
||||||
"permissionWrites": "API 写入",
|
"permissionWrites": "API 写入",
|
||||||
|
|||||||
@@ -32,11 +32,25 @@
|
|||||||
"promoteCanvas": "提升畫布",
|
"promoteCanvas": "提升畫布",
|
||||||
"promoteCanvasHelp": "讓此任務畫布顯示在工作區導航中。",
|
"promoteCanvasHelp": "讓此任務畫布顯示在工作區導航中。",
|
||||||
"promoteCanvasUnavailable": "有效版本獲批准後才能提升。",
|
"promoteCanvasUnavailable": "有效版本獲批准後才能提升。",
|
||||||
|
"taskDataScope": "任務數據",
|
||||||
|
"workspaceDataScope": "工作區數據",
|
||||||
|
"taskPlacementScope": "任務",
|
||||||
|
"workspacePlacementScope": "工作區",
|
||||||
|
"enableWorkspaceData": "啓用工作區數據",
|
||||||
|
"enableWorkspaceDataHelp": "允許此畫布在目前工作區內使用其已聲明的權限。此操作不會將畫布移至工作區導航。",
|
||||||
|
"workspaceDataReviewDescription": "查看 {{title}} 的活動版本和已聲明權限。",
|
||||||
|
"workspaceDataReviewScopeChange": "此操作只會更改數據存取權限。畫布仍位於目前任務中。",
|
||||||
|
"workspaceDataReviewStale": "畫布或權限資訊已更改。請關閉此審核並重新開啟。",
|
||||||
|
"workspaceOwnerRequired": "只有目前工作區所有者可以啓用工作區數據。",
|
||||||
|
"enablingWorkspaceData": "正在啓用工作區數據",
|
||||||
|
"activeRelease": "活動版本",
|
||||||
"openInNewTab": "在新標籤頁中開啟",
|
"openInNewTab": "在新標籤頁中開啟",
|
||||||
"openInNewTabHelp": "在聚焦的瀏覽器標籤頁中開啟此畫布。",
|
"openInNewTabHelp": "在聚焦的瀏覽器標籤頁中開啟此畫布。",
|
||||||
"promoteCanvasDescription": "將 {{title}} 移至工作區範圍前,請先查看權限。",
|
"promoteCanvasDescription": "將 {{title}} 移至工作區範圍前,請先查看權限。",
|
||||||
"loadingPermissions": "正在載入請求的權限",
|
"loadingPermissions": "正在載入請求的權限",
|
||||||
"promotionScopeChange": "提升後,此畫布會出現在工作區導航中。",
|
"promotionScopeChange": "提升後,此畫布會出現在工作區導航中。",
|
||||||
|
"promotionDataAccessPreserved": "提升只會更改位置。此畫布已經可以存取工作區數據,提升後仍保持不變。",
|
||||||
|
"promotionDataAccessChanges": "提升會更改位置,並將已聲明的數據權限擴展到工作區。",
|
||||||
"noAdditionalPermissions": "未請求其他權限。",
|
"noAdditionalPermissions": "未請求其他權限。",
|
||||||
"sharedState": "共享狀態",
|
"sharedState": "共享狀態",
|
||||||
"promotingCanvas": "正在提升畫布",
|
"promotingCanvas": "正在提升畫布",
|
||||||
@@ -76,6 +90,8 @@
|
|||||||
"promotionSourceTask": "來源任務",
|
"promotionSourceTask": "來源任務",
|
||||||
"promotionSourceSession": "來源工作階段",
|
"promotionSourceSession": "來源工作階段",
|
||||||
"promotionTargetScope": "目標範圍",
|
"promotionTargetScope": "目標範圍",
|
||||||
|
"promotionCurrentDataScope": "提升前的數據存取範圍",
|
||||||
|
"promotionTargetDataScope": "提升後的數據存取範圍",
|
||||||
"promotionPlacement": "工作區位置",
|
"promotionPlacement": "工作區位置",
|
||||||
"permissionReads": "API 讀取",
|
"permissionReads": "API 讀取",
|
||||||
"permissionWrites": "API 寫入",
|
"permissionWrites": "API 寫入",
|
||||||
|
|||||||
@@ -32,11 +32,25 @@
|
|||||||
"promoteCanvas": "提升畫布",
|
"promoteCanvas": "提升畫布",
|
||||||
"promoteCanvasHelp": "讓此任務畫布顯示在工作區導覽中。",
|
"promoteCanvasHelp": "讓此任務畫布顯示在工作區導覽中。",
|
||||||
"promoteCanvasUnavailable": "有效版本獲批准後才能提升。",
|
"promoteCanvasUnavailable": "有效版本獲批准後才能提升。",
|
||||||
|
"taskDataScope": "任務資料",
|
||||||
|
"workspaceDataScope": "工作區資料",
|
||||||
|
"taskPlacementScope": "任務",
|
||||||
|
"workspacePlacementScope": "工作區",
|
||||||
|
"enableWorkspaceData": "啟用工作區資料",
|
||||||
|
"enableWorkspaceDataHelp": "允許此畫布在目前工作區內使用其已宣告的權限。此操作不會將畫布移至工作區導覽。",
|
||||||
|
"workspaceDataReviewDescription": "檢視 {{title}} 的活動版本和已宣告權限。",
|
||||||
|
"workspaceDataReviewScopeChange": "此操作只會更改資料存取權限。畫布仍位於目前任務中。",
|
||||||
|
"workspaceDataReviewStale": "畫布或權限資訊已更改。請關閉此稽核並重新開啟。",
|
||||||
|
"workspaceOwnerRequired": "只有目前工作區所有者可以啟用工作區資料。",
|
||||||
|
"enablingWorkspaceData": "正在啟用工作區資料",
|
||||||
|
"activeRelease": "活動版本",
|
||||||
"openInNewTab": "在新標籤頁中開啟",
|
"openInNewTab": "在新標籤頁中開啟",
|
||||||
"openInNewTabHelp": "在聚焦的瀏覽器標籤頁中開啟此畫布。",
|
"openInNewTabHelp": "在聚焦的瀏覽器標籤頁中開啟此畫布。",
|
||||||
"promoteCanvasDescription": "將 {{title}} 移至工作區範圍前,請先檢視權限。",
|
"promoteCanvasDescription": "將 {{title}} 移至工作區範圍前,請先檢視權限。",
|
||||||
"loadingPermissions": "正在載入請求的權限",
|
"loadingPermissions": "正在載入請求的權限",
|
||||||
"promotionScopeChange": "提升後,此畫布會出現在工作區導覽中。",
|
"promotionScopeChange": "提升後,此畫布會出現在工作區導覽中。",
|
||||||
|
"promotionDataAccessPreserved": "提升只會更改位置。此畫布已經可以存取工作區資料,提升後仍保持不變。",
|
||||||
|
"promotionDataAccessChanges": "提升會更改位置,並將已宣告的資料權限擴充套件到工作區。",
|
||||||
"noAdditionalPermissions": "未請求其他權限。",
|
"noAdditionalPermissions": "未請求其他權限。",
|
||||||
"sharedState": "共享狀態",
|
"sharedState": "共享狀態",
|
||||||
"promotingCanvas": "正在提升畫布",
|
"promotingCanvas": "正在提升畫布",
|
||||||
@@ -76,6 +90,8 @@
|
|||||||
"promotionSourceTask": "來源任務",
|
"promotionSourceTask": "來源任務",
|
||||||
"promotionSourceSession": "來源工作階段",
|
"promotionSourceSession": "來源工作階段",
|
||||||
"promotionTargetScope": "目標範圍",
|
"promotionTargetScope": "目標範圍",
|
||||||
|
"promotionCurrentDataScope": "提升前的資料存取範圍",
|
||||||
|
"promotionTargetDataScope": "提升後的資料存取範圍",
|
||||||
"promotionPlacement": "工作區位置",
|
"promotionPlacement": "工作區位置",
|
||||||
"permissionReads": "API 讀取",
|
"permissionReads": "API 讀取",
|
||||||
"permissionWrites": "API 寫入",
|
"permissionWrites": "API 寫入",
|
||||||
|
|||||||
@@ -7,6 +7,8 @@
|
|||||||
|
|
||||||
The initial-grant rule is amended by
|
The initial-grant rule is amended by
|
||||||
[owner-authorized canvas creation](2026-09-10-canvas-creation-authority.md).
|
[owner-authorized canvas creation](2026-09-10-canvas-creation-authority.md).
|
||||||
|
Task placement and data scope are separated for owner-authorized canvases by
|
||||||
|
[task canvas workspace data](2026-09-23-task-canvas-workspace-data.md).
|
||||||
The bounded startup acknowledgement is specified in the
|
The bounded startup acknowledgement is specified in the
|
||||||
[runtime design](../specs/plugins/system-design/isolated-web-app-contributions.md#runtime-startup-protocol);
|
[runtime design](../specs/plugins/system-design/isolated-web-app-contributions.md#runtime-startup-protocol);
|
||||||
it adds no privileged host API.
|
it adds no privileged host API.
|
||||||
|
|||||||
@@ -4,6 +4,10 @@
|
|||||||
**Date:** 2026-09-10
|
**Date:** 2026-09-10
|
||||||
**Area:** backend, protocol, security
|
**Area:** backend, protocol, security
|
||||||
|
|
||||||
|
The task-data initial grant ceiling below is amended by
|
||||||
|
[task canvas workspace data](2026-09-23-task-canvas-workspace-data.md).
|
||||||
|
It remains the historical rule for version-1 authority.
|
||||||
|
|
||||||
## Context
|
## Context
|
||||||
|
|
||||||
A user asks their task agent to create a canvas, then must approve its first
|
A user asks their task agent to create a canvas, then must approve its first
|
||||||
|
|||||||
@@ -0,0 +1,72 @@
|
|||||||
|
# ADR-2026-09-23-task-canvas-workspace-data: Separate canvas placement from data scope
|
||||||
|
|
||||||
|
**Status:** accepted
|
||||||
|
**Date:** 2026-09-23
|
||||||
|
**Area:** backend, frontend, protocol, security
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
Users create canvases from tasks and review their behavior before promotion.
|
||||||
|
The original canvas contract binds task placement and data scope together. A
|
||||||
|
task coordinator that calls the supported canvas task-list endpoint therefore
|
||||||
|
receives only its creating task, even when the package declares task reads and
|
||||||
|
the workspace has many tasks. Granting every capability would not remove that
|
||||||
|
scope filter.
|
||||||
|
|
||||||
|
The [owner-authorized creation decision](2026-09-10-canvas-creation-authority.md)
|
||||||
|
already treats the user's request as authority for the first release's exact
|
||||||
|
declared grants. The [same-origin decision](2026-09-19-trusted-same-origin-canvases.md)
|
||||||
|
also establishes that locally run canvas code has the viewer's ordinary browser
|
||||||
|
authority. The relative canvas API should provide a coherent supported path
|
||||||
|
without relying on direct first-party API calls.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
A task canvas has separate placement and data scopes. For a newly
|
||||||
|
owner-authorized local canvas, the first valid release receives only its
|
||||||
|
declared supported capabilities, with data scope limited to its current
|
||||||
|
workspace. It remains placed in the creating task until the user promotes it.
|
||||||
|
The task-list API can show every task in that workspace before promotion;
|
||||||
|
current user authorization still applies. Promotion governs placement and
|
||||||
|
sharing, and still requires human confirmation.
|
||||||
|
|
||||||
|
The data scope is server-owned, persisted, included in runtime bindings, and
|
||||||
|
checked alongside grants on every canvas-protocol request. Agent or manifest
|
||||||
|
input cannot choose a different workspace. Existing task-only releases retain
|
||||||
|
their scope until a workspace owner reviews their exact declared permissions
|
||||||
|
and explicitly enables workspace data access. Later permission increases and
|
||||||
|
imported packages continue through their existing review paths.
|
||||||
|
|
||||||
|
This amends the task-scoped first-grant ceiling in the 2026-09-10 decision and
|
||||||
|
the coupling of task canvas and task data scope in the 2026-08-26 decision. It
|
||||||
|
does not change the same-origin trust decision or grant undeclared capabilities.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
- An owner-created canvas can be evaluated with representative live workspace
|
||||||
|
data and supported actions before it appears in workspace navigation.
|
||||||
|
- Permission review remains tied to the exact capabilities a release uses;
|
||||||
|
there is no blanket read, write, network, secret, or backend permission.
|
||||||
|
- The runtime, persistence, review projection, and authoring guidance must
|
||||||
|
carry placement and data scopes independently.
|
||||||
|
- Existing task canvases require an explicit access upgrade or promotion to
|
||||||
|
show workspace data. No retained release is silently broadened on upgrade.
|
||||||
|
- The upgrade path must check current ownership and a versioned review
|
||||||
|
snapshot atomically, then invalidate older runtime tokens.
|
||||||
|
|
||||||
|
## Alternatives Considered
|
||||||
|
|
||||||
|
- **Grant all possible permissions by default:** Does not remove the task-list
|
||||||
|
scope filter and gives unused writes and network access to generated code.
|
||||||
|
- **Require promotion before representative preview:** Leaves the user unable
|
||||||
|
to judge the app before placing it in workspace navigation.
|
||||||
|
- **Call ordinary Kandev APIs from canvas code:** Works only with browser user
|
||||||
|
authority and bypasses the supported capability contract; capability-only
|
||||||
|
hosts would still show one task.
|
||||||
|
- **Silently widen all existing task canvases:** Changes retained release
|
||||||
|
authority without a review of the code and exact declared capabilities.
|
||||||
|
|
||||||
|
## Related contracts
|
||||||
|
|
||||||
|
- [Workspace preview requirements](../specs/canvases/requirements/task-canvas-workspace-preview.md)
|
||||||
|
- [Workspace preview design](../specs/canvases/system-design/task-canvas-workspace-preview.md)
|
||||||
@@ -0,0 +1,152 @@
|
|||||||
|
---
|
||||||
|
created: 2026-09-23
|
||||||
|
status: complete
|
||||||
|
requirements:
|
||||||
|
- REQ-CANVASES-WORKSPACE-PREVIEW-001
|
||||||
|
- REQ-CANVASES-LOCAL-CREATION-001
|
||||||
|
- REQ-CANVASES-AGENT-WEB-APPS-003
|
||||||
|
system_design:
|
||||||
|
- ../../specs/canvases/system-design/task-canvas-workspace-preview.md
|
||||||
|
- ../../specs/canvases/system-design/local-creation-authority.md
|
||||||
|
- ../../specs/canvases/system-design/agent-authored-web-apps.md
|
||||||
|
- ../../specs/plugins/system-design/canvas-workspace-data-scope.md
|
||||||
|
legacy_specs: []
|
||||||
|
---
|
||||||
|
|
||||||
|
# Implementation Plan: Task canvas workspace data preview
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
Let an owner-created canvas use its declared capabilities across the current
|
||||||
|
workspace while it remains in the creating task. First add the server-owned
|
||||||
|
data-scope and review transaction, then expose the scope and legacy upgrade
|
||||||
|
in the host, authoring guidance, and browser tests. Promotion continues to
|
||||||
|
control workspace placement.
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
### In scope
|
||||||
|
|
||||||
|
- Workspace task lists and all currently supported declared canvas data,
|
||||||
|
write, event, state, and network capabilities before promotion.
|
||||||
|
- A separate server-owned data scope on plugin instances and runtime bindings.
|
||||||
|
- An explicit, reviewed workspace-data upgrade for existing task canvases.
|
||||||
|
- Desktop and phone host scope presentation, localized copy, authoring and
|
||||||
|
public documentation, and focused browser coverage.
|
||||||
|
|
||||||
|
### Out of scope
|
||||||
|
|
||||||
|
- Cross-workspace and installation-wide data access.
|
||||||
|
- Blanket grants for undeclared capabilities, new capability kinds, or
|
||||||
|
managed agent-generated backend code.
|
||||||
|
- Silent widening of retained releases.
|
||||||
|
|
||||||
|
## Technical approach
|
||||||
|
|
||||||
|
1. Add nullable `data_scope_kind` to plugin instance persistence with null
|
||||||
|
fallback to the existing placement `scope_kind`. Create version-2 local
|
||||||
|
canvas authority with workspace data scope and exact workspace-ceiling
|
||||||
|
declared grants. Keep other instances and historical authority unchanged.
|
||||||
|
2. Carry data scope through `webapp.CapabilityBinding`, grant validation, and
|
||||||
|
the relative browser protocol. Use it for tasks, workflows, writes,
|
||||||
|
messages, dependency projection, and events. Bind every operation to the
|
||||||
|
trusted workspace and current user authorization. Follow the
|
||||||
|
[plugin runtime design](../../specs/plugins/system-design/canvas-workspace-data-scope.md).
|
||||||
|
3. Add an owner-reviewed workspace-data upgrade for retained task canvases
|
||||||
|
using the active release ID, permission digest, and grant generation as
|
||||||
|
conditional transaction inputs. Keep placement and package unchanged.
|
||||||
|
4. Project placement and data scope separately in canvas context and host
|
||||||
|
review. Update authoring instructions and public canvas/API docs. Use the
|
||||||
|
existing desktop action menu and phone action drawer for legacy upgrade.
|
||||||
|
|
||||||
|
Likely backend files include `internal/plugins/instances/store.go`,
|
||||||
|
`internal/plugins/webapp/tokens.go`, `internal/plugins/webapp_protocol*.go`,
|
||||||
|
`internal/canvas/authoring.go`, `internal/canvas/service.go`, and the canvas
|
||||||
|
HTTP handler/controller. Existing E2E fixture expectations for one task must
|
||||||
|
change to a multi-task workspace fixture.
|
||||||
|
|
||||||
|
## ASCII UI preview
|
||||||
|
|
||||||
|
`UI-01: Task canvas host, new release (desktop)`. Entry: task canvas panel.
|
||||||
|
The host header stays fixed; the application owns its existing scroll region.
|
||||||
|
The badge reports data scope, while Promote controls placement. Labels are
|
||||||
|
illustrative; the distinction is required by
|
||||||
|
`AC-CANVASES-WORKSPACE-PREVIEW-001.3-.4`.
|
||||||
|
|
||||||
|
```text
|
||||||
|
Task coordinator [Workspace data] [Releases and permissions] [Promote canvas]
|
||||||
|
--------------------------------------------------------------------------
|
||||||
|
Existing tasks [Refresh]
|
||||||
|
Available 3 Active 2 Completed 1
|
||||||
|
Task A Task B Task C
|
||||||
|
```
|
||||||
|
|
||||||
|
`UI-02: Existing task-only canvas (desktop)`. Entry: the same task panel.
|
||||||
|
The upgrade dialog uses the current permission-review layout and keeps
|
||||||
|
Cancel and Enable actions visible. It does not promote the canvas.
|
||||||
|
|
||||||
|
```text
|
||||||
|
Task coordinator [Task data] [Enable workspace data] [Promote canvas]
|
||||||
|
|
|
||||||
|
Enable workspace data for this canvas?
|
||||||
|
Release: Task coordinator 1.0.0
|
||||||
|
Reads: tasks in this workspace
|
||||||
|
Writes/events/network: exact declared permissions
|
||||||
|
[Cancel] [Enable workspace data]
|
||||||
|
```
|
||||||
|
|
||||||
|
`UI-03: Existing task-only canvas (phone)`. Entry: focused task canvas route.
|
||||||
|
The existing Actions drawer exposes the upgrade; the review opens as a
|
||||||
|
full-height view with one scrolling permission region and safe-area actions.
|
||||||
|
|
||||||
|
```text
|
||||||
|
< Task Task coordinator [Actions]
|
||||||
|
Task data
|
||||||
|
| Actions drawer: Enable workspace data
|
||||||
|
v
|
||||||
|
Enable workspace data
|
||||||
|
Release and declared permissions [scrolls]
|
||||||
|
-------------------------------------------
|
||||||
|
[Cancel] [Enable workspace data] [fixed, safe area]
|
||||||
|
```
|
||||||
|
|
||||||
|
## Tests
|
||||||
|
|
||||||
|
| Criteria | Evidence |
|
||||||
|
| --- | --- |
|
||||||
|
| `.1`, `.2`, `.8` | Plugin instance and web-app protocol tests for exact grants, all workspace pages, foreign denial, writes, workflows, messages, events, and dependencies. |
|
||||||
|
| `.3`, `.4` | Canvas service promotion and projection tests. |
|
||||||
|
| `.5`, `.6` | Conditional legacy-upgrade transaction and stale/owner/revocation tests. |
|
||||||
|
| `.7` | Canvas host component tests and desktop/mobile E2E. |
|
||||||
|
|
||||||
|
## E2E tests
|
||||||
|
|
||||||
|
- `apps/web/e2e/tests/canvas/plugin-canvas.spec.ts` (`chromium`): owner-created
|
||||||
|
task canvas sees two or more workspace tasks and refreshes before promotion;
|
||||||
|
promotion leaves the list intact. Covers `.1`, `.3`, `.4`.
|
||||||
|
- `apps/web/e2e/tests/canvas/mobile-plugin-canvas.spec.ts`
|
||||||
|
(`mobile-chrome`): same data before promotion plus task-only legacy upgrade
|
||||||
|
from the phone action drawer. Covers `.5`, `.7`.
|
||||||
|
|
||||||
|
## Work orders
|
||||||
|
|
||||||
|
- [x] [Task 01: Workspace data scope and legacy upgrade](task-01-workspace-data-scope.md)
|
||||||
|
- [x] [Task 02: Host scope review and browser proof](task-02-host-scope-review.md)
|
||||||
|
|
||||||
|
Task 02 depends on Task 01. Both run in the primary session unless the user
|
||||||
|
later explicitly authorizes another arrangement.
|
||||||
|
|
||||||
|
## Verification results
|
||||||
|
|
||||||
|
Passed: backend canvas/plugin tests, focused frontend Vitest coverage, web
|
||||||
|
typecheck, i18n validation, desktop and mobile canvas Playwright suites, spec
|
||||||
|
index validation, specification lint, and public documentation validation.
|
||||||
|
|
||||||
|
## Risks
|
||||||
|
|
||||||
|
- Grant checks currently equate instance scope with data scope. Missing a
|
||||||
|
validation or event path could leak data or leave preview inconsistent.
|
||||||
|
- Existing task releases must retain their one-task behavior until reviewed;
|
||||||
|
a broad migration would silently change authority.
|
||||||
|
- Same-origin canvas code can call ordinary user APIs already; the supported
|
||||||
|
relative protocol must remain internally coherent in capability-only hosts.
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
---
|
||||||
|
id: "01-workspace-data-scope"
|
||||||
|
title: "Workspace data scope and legacy upgrade"
|
||||||
|
status: complete
|
||||||
|
wave: 1
|
||||||
|
depends_on: []
|
||||||
|
plan: "plan.md"
|
||||||
|
requirements:
|
||||||
|
- REQ-CANVASES-WORKSPACE-PREVIEW-001
|
||||||
|
- REQ-CANVASES-LOCAL-CREATION-001
|
||||||
|
- REQ-CANVASES-AGENT-WEB-APPS-003
|
||||||
|
acceptance_criteria:
|
||||||
|
- AC-CANVASES-WORKSPACE-PREVIEW-001.1
|
||||||
|
- AC-CANVASES-WORKSPACE-PREVIEW-001.2
|
||||||
|
- AC-CANVASES-WORKSPACE-PREVIEW-001.3
|
||||||
|
- AC-CANVASES-WORKSPACE-PREVIEW-001.5
|
||||||
|
- AC-CANVASES-WORKSPACE-PREVIEW-001.6
|
||||||
|
- AC-CANVASES-WORKSPACE-PREVIEW-001.8
|
||||||
|
- AC-CANVASES-LOCAL-CREATION-001.2
|
||||||
|
- AC-CANVASES-AGENT-WEB-APPS-003.2
|
||||||
|
system_design:
|
||||||
|
- ../../specs/canvases/system-design/task-canvas-workspace-preview.md
|
||||||
|
- ../../specs/canvases/system-design/local-creation-authority.md
|
||||||
|
- ../../specs/canvases/system-design/agent-authored-web-apps.md
|
||||||
|
- ../../specs/plugins/system-design/canvas-workspace-data-scope.md
|
||||||
|
---
|
||||||
|
|
||||||
|
# Task 01: Workspace data scope and legacy upgrade
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
Persist a separate data scope for canvas instances and honor it throughout the
|
||||||
|
relative web-app protocol. New owner-authorized task canvases get exact
|
||||||
|
declared workspace grants on first publication; existing task-only canvases
|
||||||
|
can obtain them only through a conditional owner-reviewed upgrade.
|
||||||
|
|
||||||
|
## In scope
|
||||||
|
|
||||||
|
- SQLite/PostgreSQL instance mapping and null fallback; version-2 creation
|
||||||
|
authority and first-publication transaction.
|
||||||
|
- Runtime binding, grant checks, task/workflow/write/message/event/dependency
|
||||||
|
enforcement, and additive context projection.
|
||||||
|
- Bounded expvar counters for first publication, reviewed upgrades, and
|
||||||
|
promotion data-scope results, without per-canvas metric labels.
|
||||||
|
- Workspace-data preview and confirmation endpoints for retained task canvases.
|
||||||
|
- Promotion behavior with an already workspace-data task canvas and tests for
|
||||||
|
foreign workspaces, stale reviews, owner drift, and revoked grants.
|
||||||
|
|
||||||
|
## Out of scope
|
||||||
|
|
||||||
|
- Host action, dialog, translations, browser tests, and public docs.
|
||||||
|
|
||||||
|
## Acceptance
|
||||||
|
|
||||||
|
1. A new task-placed local canvas can list and operate on all authorized tasks
|
||||||
|
in its own workspace through declared capabilities before promotion; no
|
||||||
|
foreign workspace or undeclared capability is admitted.
|
||||||
|
2. Existing task-only instances keep their current behavior until an owner
|
||||||
|
confirms an exact, current release review; confirmation changes grants and
|
||||||
|
data scope atomically without changing placement or release.
|
||||||
|
3. Promotion preserves workspace data access when it already exists, while
|
||||||
|
stale or invalid runtime bindings and reviews fail closed.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
Run from the repository root:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
(cd apps/backend && go test ./internal/canvas ./internal/plugins/instances ./internal/plugins ./internal/plugins/webapp ./internal/backendapp)
|
||||||
|
python3 scripts/list-docs.py validate
|
||||||
|
```
|
||||||
|
|
||||||
|
## Files likely touched
|
||||||
|
|
||||||
|
- `apps/backend/internal/plugins/instances/store.go`
|
||||||
|
- `apps/backend/internal/plugins/webapp/tokens.go`
|
||||||
|
- `apps/backend/internal/plugins/webapp_protocol.go`
|
||||||
|
- `apps/backend/internal/plugins/webapp_protocol_data.go`
|
||||||
|
- `apps/backend/internal/plugins/webapp_events.go`
|
||||||
|
- `apps/backend/internal/plugins/webapp_protocol_json.go`
|
||||||
|
- `apps/backend/internal/canvas/authoring.go`
|
||||||
|
- `apps/backend/internal/canvas/service.go`
|
||||||
|
- `apps/backend/internal/canvas/types.go`
|
||||||
|
- `apps/backend/internal/backendapp/` canvas HTTP handlers and tests
|
||||||
|
- Adjacent `*_test.go` files
|
||||||
|
|
||||||
|
## Dependencies
|
||||||
|
|
||||||
|
None.
|
||||||
|
|
||||||
|
## Risks
|
||||||
|
|
||||||
|
Every route that uses placement scope today must be classified by whether it
|
||||||
|
filters data, determines discovery, or checks grant validity. Missing a route
|
||||||
|
could make the preview inconsistent or broaden an unintended path.
|
||||||
|
|
||||||
|
## Parallelism
|
||||||
|
|
||||||
|
`sequential`
|
||||||
|
|
||||||
|
## Inputs
|
||||||
|
|
||||||
|
- [Workspace preview requirements](../../specs/canvases/requirements/task-canvas-workspace-preview.md)
|
||||||
|
- [Workspace preview design](../../specs/canvases/system-design/task-canvas-workspace-preview.md)
|
||||||
|
- [Scope decision](../../decisions/2026-09-23-task-canvas-workspace-data.md)
|
||||||
|
|
||||||
|
## Results
|
||||||
|
|
||||||
|
Passed: `go test ./internal/canvas ./internal/plugins/instances
|
||||||
|
./internal/plugins ./internal/plugins/webapp ./internal/backendapp` and
|
||||||
|
`python3 scripts/list-docs.py validate`. The canvas tests verify fixed-label
|
||||||
|
counter increments for initial publication, reviewed upgrade, and promotion.
|
||||||
@@ -0,0 +1,141 @@
|
|||||||
|
---
|
||||||
|
id: "02-host-scope-review"
|
||||||
|
title: "Host scope review and browser proof"
|
||||||
|
status: complete
|
||||||
|
wave: 2
|
||||||
|
depends_on:
|
||||||
|
- "01-workspace-data-scope"
|
||||||
|
plan: "plan.md"
|
||||||
|
requirements:
|
||||||
|
- REQ-CANVASES-WORKSPACE-PREVIEW-001
|
||||||
|
- REQ-CANVASES-AGENT-WEB-APPS-003
|
||||||
|
acceptance_criteria:
|
||||||
|
- AC-CANVASES-WORKSPACE-PREVIEW-001.1
|
||||||
|
- AC-CANVASES-WORKSPACE-PREVIEW-001.4
|
||||||
|
- AC-CANVASES-WORKSPACE-PREVIEW-001.5
|
||||||
|
- AC-CANVASES-WORKSPACE-PREVIEW-001.7
|
||||||
|
- AC-CANVASES-AGENT-WEB-APPS-003.1
|
||||||
|
system_design:
|
||||||
|
- ../../specs/canvases/system-design/task-canvas-workspace-preview.md
|
||||||
|
- ../../specs/canvases/system-design/agent-authored-web-apps.md
|
||||||
|
---
|
||||||
|
|
||||||
|
# Task 02: Host scope review and browser proof
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
Show task placement and workspace data access separately in the host and
|
||||||
|
promotion review. Give older task-only canvases an owner-reviewed access
|
||||||
|
action on desktop and phone, update author guidance, and prove live workspace
|
||||||
|
task refresh before promotion in browser tests.
|
||||||
|
|
||||||
|
## In scope
|
||||||
|
|
||||||
|
- Canvas API client/projections, existing host actions and review component,
|
||||||
|
localized copy in six catalogs, and phone action drawer.
|
||||||
|
- Versioned bundled authoring reference, public canvas and plugin API docs,
|
||||||
|
and concise scoped `AGENTS.md` updates if implementation changes their
|
||||||
|
current statements.
|
||||||
|
- Component and desktop/mobile Playwright tests using multiple tasks in one
|
||||||
|
workspace and a foreign-workspace negative case.
|
||||||
|
|
||||||
|
## Out of scope
|
||||||
|
|
||||||
|
- New canvas-authored UI template, broad UI redesign, installation-wide task
|
||||||
|
listing, and silent legacy grant migration.
|
||||||
|
|
||||||
|
## Acceptance
|
||||||
|
|
||||||
|
1. Desktop and phone hosts label the data scope accurately and offer the
|
||||||
|
upgrade review only for eligible task-only canvases; the action does not
|
||||||
|
promote or republish.
|
||||||
|
2. Promotion review distinguishes placement from data access and keeps exact
|
||||||
|
declared permission details visible on both viewports.
|
||||||
|
3. Browser tests show a new task canvas refreshing at least two workspace
|
||||||
|
tasks before promotion and an existing one expanding access after review.
|
||||||
|
|
||||||
|
## ASCII UI preview
|
||||||
|
|
||||||
|
`UI-01: Task canvas host, new release (desktop)` and
|
||||||
|
`UI-02: Existing task-only canvas (desktop)` use the same header shown in
|
||||||
|
[the full plan](plan.md#ascii-ui-preview):
|
||||||
|
|
||||||
|
```text
|
||||||
|
New: Task coordinator [Workspace data] [Releases] [Promote canvas]
|
||||||
|
Existing: Task coordinator [Task data] [Enable workspace data] [Promote canvas]
|
||||||
|
```
|
||||||
|
|
||||||
|
`UI-03: Existing task-only canvas (phone)` uses the existing focused route
|
||||||
|
and Actions drawer. Its review is full-height with one scroll region and
|
||||||
|
fixed safe-area actions:
|
||||||
|
|
||||||
|
```text
|
||||||
|
< Task Task coordinator [Actions]
|
||||||
|
Task data
|
||||||
|
Actions > Enable workspace data
|
||||||
|
Review declared permissions [scrolls]
|
||||||
|
[Cancel] [Enable workspace data] [fixed, safe area]
|
||||||
|
```
|
||||||
|
|
||||||
|
The scope label and action availability are required by
|
||||||
|
`AC-CANVASES-WORKSPACE-PREVIEW-001.4-.5` and `.7`; text spacing is
|
||||||
|
illustrative. Reuse the shipped canvas host and phone drawer.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
Run from the repository root:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
(cd apps/web && pnpm exec vitest run components/settings/canvas-lifecycle-dialogs.test.tsx components/settings/canvas-host-components.test.tsx lib/api/domains/canvas-api.test.ts)
|
||||||
|
(cd apps/web && pnpm run typecheck)
|
||||||
|
(cd apps/web && pnpm run i18n:check)
|
||||||
|
(cd apps/web && pnpm e2e:run --host --project chromium tests/canvas/plugin-canvas.spec.ts)
|
||||||
|
(cd apps/web && pnpm e2e:run --host --project mobile-chrome tests/canvas/mobile-plugin-canvas.spec.ts)
|
||||||
|
python3 scripts/list-docs.py validate
|
||||||
|
python3 scripts/lint-spec-files.py --all
|
||||||
|
node scripts/validate-public-docs.mjs
|
||||||
|
```
|
||||||
|
|
||||||
|
## Files likely touched
|
||||||
|
|
||||||
|
- `apps/web/lib/api/domains/canvas-api.ts`
|
||||||
|
- `apps/web/components/settings/canvas-host-actions.tsx`
|
||||||
|
- `apps/web/components/settings/canvas-lifecycle-dialogs.tsx`
|
||||||
|
- `apps/web/components/settings/canvas-host-route*.tsx`
|
||||||
|
- `apps/web/src/locales/*/canvases.json`
|
||||||
|
- `apps/web/e2e/tests/canvas/plugin-canvas.spec.ts`
|
||||||
|
- `apps/web/e2e/tests/canvas/mobile-plugin-canvas.spec.ts`
|
||||||
|
- `apps/backend/internal/mcp/canvasskill/files/references/browser-api.md`
|
||||||
|
- `apps/backend/config/prompts/create-canvas.md`
|
||||||
|
- `docs/public/canvases.md`
|
||||||
|
- `docs/public/plugins-authoring.md`
|
||||||
|
- Adjacent component tests and `AGENTS.md` files when needed
|
||||||
|
|
||||||
|
## Dependencies
|
||||||
|
|
||||||
|
Task 01 supplies the data-scope projection and conditional access endpoint.
|
||||||
|
|
||||||
|
## Risks
|
||||||
|
|
||||||
|
The current canvas fixture expects one task. The browser proof must create a
|
||||||
|
second task in the same workspace and retain a foreign-workspace negative case
|
||||||
|
without changing unrelated fixture behavior.
|
||||||
|
|
||||||
|
## Parallelism
|
||||||
|
|
||||||
|
`sequential`
|
||||||
|
|
||||||
|
## Inputs
|
||||||
|
|
||||||
|
- [Workspace preview requirements](../../specs/canvases/requirements/task-canvas-workspace-preview.md)
|
||||||
|
- [Workspace preview design](../../specs/canvases/system-design/task-canvas-workspace-preview.md)
|
||||||
|
- [Full UI preview](plan.md#ascii-ui-preview)
|
||||||
|
|
||||||
|
## Results
|
||||||
|
|
||||||
|
Passed: focused Vitest suite (41 tests), web typecheck, i18n checks, desktop
|
||||||
|
canvas Playwright suite, mobile canvas Playwright suite (5 tests), spec index
|
||||||
|
validation, specification lint, and public documentation validation. The phone
|
||||||
|
browser test verifies both scope labels and the legacy review flow; it uses a
|
||||||
|
test-only legacy API response, while backend service/store tests verify the
|
||||||
|
transactional grant upgrade.
|
||||||
+20
-5
@@ -53,7 +53,18 @@ Kandev validates the package before it stores or runs the release. An invalid dr
|
|||||||
|
|
||||||
## Use a canvas
|
## Use a canvas
|
||||||
|
|
||||||
A task canvas belongs to one task. A workspace canvas appears in workspace navigation and uses workspace scope. Promotion changes the same canvas from task scope to workspace scope. It does not copy the canvas.
|
A task canvas stays in its creating task until promotion. After the first valid
|
||||||
|
release, a new owner-authorized task canvas can use its declared supported
|
||||||
|
permissions across the current workspace. This lets the app preview the full
|
||||||
|
workspace task list before it appears in workspace navigation. Imported and
|
||||||
|
older task canvases keep their existing data scope until an authorized review
|
||||||
|
widens it.
|
||||||
|
|
||||||
|
A workspace canvas appears in workspace navigation. Promotion changes the
|
||||||
|
canvas placement and keeps its identity, active release, state, and release
|
||||||
|
history. For a new canvas that already has workspace data access, promotion
|
||||||
|
changes placement only. The host review identifies whether it changes data
|
||||||
|
access for a legacy canvas.
|
||||||
|
|
||||||
The app uses relative requests such as `./_kandev/v1/data/tasks` and `./_kandev/v1/state`. Kandev remains the source of truth for task, workflow, and message data. Canvas state stores app-specific shared state. It does not replace Kandev domain data.
|
The app uses relative requests such as `./_kandev/v1/data/tasks` and `./_kandev/v1/state`. Kandev remains the source of truth for task, workflow, and message data. Canvas state stores app-specific shared state. It does not replace Kandev domain data.
|
||||||
|
|
||||||
@@ -115,7 +126,7 @@ and **Releases and permissions** outside the failed frame. Retry creates a new
|
|||||||
runtime binding and startup attempt. This check confirms document and context
|
runtime binding and startup attempt. This check confirms document and context
|
||||||
startup; it does not certify application business health.
|
startup; it does not certify application business health.
|
||||||
|
|
||||||
Kandev calculates effective access from the package declaration, instance grant, trusted task or workspace scope, and current caller authorization. A release receives only the intersection of those permissions. See [Security and trust](security.md#isolated-web-applications) for the security boundary.
|
Kandev calculates effective access from the package declaration, instance grant, trusted data scope, and current caller authorization. A release receives only the intersection of those permissions. See [Security and trust](security.md#isolated-web-applications) for the security boundary.
|
||||||
|
|
||||||
### Return to a task after publication
|
### Return to a task after publication
|
||||||
|
|
||||||
@@ -136,13 +147,17 @@ Only a user can promote a task canvas. An agent cannot promote, demote, grant pe
|
|||||||
1. Open the task canvas and choose its promotion action.
|
1. Open the task canvas and choose its promotion action.
|
||||||
2. Read the review dialog before you continue.
|
2. Read the review dialog before you continue.
|
||||||
3. Review every Kandev data read and write, event subscription, shared-state permission, and exact external HTTPS origin.
|
3. Review every Kandev data read and write, event subscription, shared-state permission, and exact external HTTPS origin.
|
||||||
4. Confirm the task-to-workspace scope change and workspace placement.
|
4. Confirm the placement change to workspace navigation.
|
||||||
|
|
||||||
The confirmation includes the active release ID, permission declaration
|
The confirmation includes the active release ID, permission declaration
|
||||||
digest, and grant generation that you reviewed. If any of these changes before
|
digest, and grant generation that you reviewed. If any of these changes before
|
||||||
confirmation, Kandev rejects the request as stale and requires a new review.
|
confirmation, Kandev rejects the request as stale and requires a new review.
|
||||||
|
|
||||||
The promotion keeps the canvas identity, active release, state, and release history. A workspace canvas then appears in workspace navigation. Canceling the review leaves the task canvas unchanged.
|
The promotion keeps the canvas identity, active release, state, and release
|
||||||
|
history. A workspace canvas then appears in workspace navigation. A new
|
||||||
|
owner-authorized task canvas already has workspace data access, so promotion
|
||||||
|
changes placement only. A legacy task canvas uses the permission review shown
|
||||||
|
by its host. Canceling the review leaves the task canvas unchanged.
|
||||||
|
|
||||||
If promotion adds a permission, Kandev keeps the current active release until a user approves the new grant. The new release stays pending permission until that review finishes.
|
If promotion adds a permission, Kandev keeps the current active release until a user approves the new grant. The new release stays pending permission until that review finishes.
|
||||||
|
|
||||||
@@ -193,7 +208,7 @@ Use these recovery actions:
|
|||||||
- Roll back to the retained prior release after a failed publish.
|
- Roll back to the retained prior release after a failed publish.
|
||||||
- Restore the database and matching artifact directory after a storage loss. See [Canvas artifacts and recovery](operations.md#canvas-artifacts-and-recovery).
|
- Restore the database and matching artifact directory after a storage loss. See [Canvas artifacts and recovery](operations.md#canvas-artifacts-and-recovery).
|
||||||
- Archive a canvas to hide it from normal discovery. Restore it from the canvas controls.
|
- Archive a canvas to hide it from normal discovery. Restore it from the canvas controls.
|
||||||
- Remove a task canvas to remove its task-scoped data. Removing a task does not remove a canvas already promoted to a workspace.
|
- Remove a task canvas to remove the canvas and its data. Removing a task does not remove a canvas already promoted to a workspace.
|
||||||
- Remove a workspace canvas to remove its grants, state, tokens, releases, and artifacts after cleanup completes.
|
- Remove a workspace canvas to remove its grants, state, tokens, releases, and artifacts after cleanup completes.
|
||||||
|
|
||||||
Kandev records artifact cleanup before it removes release ownership. A worker completes cleanup after the database transaction and retries it after a restart.
|
Kandev records artifact cleanup before it removes release ownership. A worker completes cleanup after the database transaction and retries it after a restart.
|
||||||
|
|||||||
@@ -121,11 +121,12 @@ does not need a Go backend or an injected Kandev JavaScript API.
|
|||||||
the app needs.
|
the app needs.
|
||||||
5. Package the manifest and static files as a gzip-compressed tar archive.
|
5. Package the manifest and static files as a gzip-compressed tar archive.
|
||||||
|
|
||||||
For a new owner-created task canvas, the first valid release can receive the
|
For a new owner-authorized task canvas, the first valid release can receive
|
||||||
declared supported task-scoped permissions through its initial permission
|
only its declared supported permissions through the initial permission policy.
|
||||||
policy. Imported packages and later permission increases need human approval.
|
Kandev data access is limited to the current workspace while the canvas stays
|
||||||
Keep `network_origins` as exact HTTPS origins. Do not use wildcards, paths,
|
placed in its creating task. Imported packages and later permission increases
|
||||||
credentials, query strings, or fragments.
|
need human approval. Keep `network_origins` as exact HTTPS origins. Do not use
|
||||||
|
wildcards, paths, credentials, query strings, or fragments.
|
||||||
|
|
||||||
For example, a page can read task data with the browser Fetch API:
|
For example, a page can read task data with the browser Fetch API:
|
||||||
|
|
||||||
@@ -137,9 +138,12 @@ if (!response.ok) {
|
|||||||
const tasks = await response.json();
|
const tasks = await response.json();
|
||||||
```
|
```
|
||||||
|
|
||||||
Use `./_kandev/v1/events` for the event stream. Keep all protocol paths
|
Use `./_kandev/v1/events` for the event stream. Read both `scope_kind` and
|
||||||
relative so the same package works in task and workspace scope. Do not copy a
|
`data_scope_kind` from `./_kandev/v1/context`: the first describes placement,
|
||||||
capability URL from the host into the app.
|
and the second describes the Kandev data boundary. Keep all protocol paths
|
||||||
|
relative so the same package works in task and workspace placement. For task
|
||||||
|
lists, follow `page_info.next_cursor` to load every page. Do not set
|
||||||
|
`workspace_id` to another workspace or copy a capability URL from the host.
|
||||||
|
|
||||||
For recorded workflow movement, the browser can GET
|
For recorded workflow movement, the browser can GET
|
||||||
`./_kandev/v1/data/tasks/{task_id}/step-transitions` with
|
`./_kandev/v1/data/tasks/{task_id}/step-transitions` with
|
||||||
|
|||||||
@@ -10,8 +10,8 @@ last_updated: 2026-09-10
|
|||||||
# Canvases
|
# Canvases
|
||||||
|
|
||||||
Canvases own agent-authored task applications and their promotion to workspace
|
Canvases own agent-authored task applications and their promotion to workspace
|
||||||
applications. The system owns canvas scope, source lineage, release selection,
|
applications. The system owns canvas placement and data scope, source lineage,
|
||||||
editing sessions, discovery, and portable canvas distribution.
|
release selection, editing sessions, discovery, and portable canvas distribution.
|
||||||
|
|
||||||
The Plugins system owns the web-application runtime, permissions,
|
The Plugins system owns the web-application runtime, permissions,
|
||||||
Kandev data access, state, events, and package validation. The task system
|
Kandev data access, state, events, and package validation. The task system
|
||||||
@@ -20,18 +20,21 @@ remains authoritative for task data and permissions.
|
|||||||
## Requirements
|
## Requirements
|
||||||
|
|
||||||
- [Agent-authored web-app canvases](requirements/agent-authored-web-apps.md)
|
- [Agent-authored web-app canvases](requirements/agent-authored-web-apps.md)
|
||||||
|
- [Task canvas workspace data preview](requirements/task-canvas-workspace-preview.md)
|
||||||
- [Canvas marketplace and sharing](requirements/marketplace-sharing.md)
|
- [Canvas marketplace and sharing](requirements/marketplace-sharing.md)
|
||||||
- [Deprecated collaborative canvases](requirements/collaborative-canvases.md)
|
- [Deprecated collaborative canvases](requirements/collaborative-canvases.md)
|
||||||
|
|
||||||
## System design
|
## System design
|
||||||
|
|
||||||
- [Agent-authored web-app canvases](system-design/agent-authored-web-apps.md)
|
- [Agent-authored web-app canvases](system-design/agent-authored-web-apps.md)
|
||||||
|
- [Task canvas workspace data preview](system-design/task-canvas-workspace-preview.md)
|
||||||
- [Canvas marketplace and sharing](system-design/marketplace-sharing.md)
|
- [Canvas marketplace and sharing](system-design/marketplace-sharing.md)
|
||||||
- [Superseded collaborative canvases](system-design/collaborative-canvases.md)
|
- [Superseded collaborative canvases](system-design/collaborative-canvases.md)
|
||||||
|
|
||||||
## Related context
|
## Related context
|
||||||
|
|
||||||
- [Runtime and permission fix plan](../../plans/canvas-runtime-permission-fixes/plan.md)
|
- [Runtime and permission fix plan](../../plans/canvas-runtime-permission-fixes/plan.md)
|
||||||
|
- [Task canvas workspace data preview plan](../../plans/task-canvas-workspace-preview/plan.md)
|
||||||
- [Agent MCP discovery guidance](../agents/system-design/mcp-tool-discovery-guidance.md)
|
- [Agent MCP discovery guidance](../agents/system-design/mcp-tool-discovery-guidance.md)
|
||||||
- [MCP discovery and canvas prompt plan](../../plans/mcp-discovery-canvas-prompts/plan.md)
|
- [MCP discovery and canvas prompt plan](../../plans/mcp-discovery-canvas-prompts/plan.md)
|
||||||
- [GitHub Copilot App Canvas reference](../../copilot-canvas-reference.md)
|
- [GitHub Copilot App Canvas reference](../../copilot-canvas-reference.md)
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ system: canvases
|
|||||||
owners:
|
owners:
|
||||||
- canvases
|
- canvases
|
||||||
created: 2026-08-26
|
created: 2026-08-26
|
||||||
last_updated: 2026-09-21
|
last_updated: 2026-09-23
|
||||||
---
|
---
|
||||||
|
|
||||||
# Agent-authored web-app canvases Requirements
|
# Agent-authored web-app canvases Requirements
|
||||||
@@ -15,7 +15,8 @@ last_updated: 2026-09-21
|
|||||||
|
|
||||||
A canvas is a custom web application that an agent creates for one task. A
|
A canvas is a custom web application that an agent creates for one task. A
|
||||||
user can promote a useful task canvas to its workspace. A workspace canvas
|
user can promote a useful task canvas to its workspace. A workspace canvas
|
||||||
appears in workspace navigation and can use workspace-scoped data.
|
appears in workspace navigation. An owner-authorized task canvas can already
|
||||||
|
use its workspace's data while the user reviews it in the originating task.
|
||||||
|
|
||||||
The Canvases system owns the canvas scope, source lineage, release selection,
|
The Canvases system owns the canvas scope, source lineage, release selection,
|
||||||
promotion, editing flow, and discovery. The Plugins system owns the web-application
|
promotion, editing flow, and discovery. The Plugins system owns the web-application
|
||||||
@@ -24,13 +25,13 @@ runtime and its data contract.
|
|||||||
## Terminology
|
## Terminology
|
||||||
|
|
||||||
- **Task canvas:** A canvas that belongs to one task and appears only in that
|
- **Task canvas:** A canvas that belongs to one task and appears only in that
|
||||||
task.
|
task. Its placement does not determine its approved data scope.
|
||||||
- **Workspace canvas:** A canvas that belongs to one workspace and appears in
|
- **Workspace canvas:** A canvas that belongs to one workspace and appears in
|
||||||
workspace navigation. Agent-authored canvases reach this scope by promotion;
|
workspace navigation. Agent-authored canvases reach this scope by promotion;
|
||||||
[distribution](marketplace-sharing.md) also defines reviewed package installs.
|
[distribution](marketplace-sharing.md) also defines reviewed package installs.
|
||||||
- **Draft:** Editable canvas source in an authorized agent workspace.
|
- **Draft:** Editable canvas source in an authorized agent workspace.
|
||||||
- **Release:** An immutable package that passed validation.
|
- **Release:** An immutable package that passed validation.
|
||||||
- **Promotion:** A user action that changes a task canvas to workspace scope.
|
- **Promotion:** A user action that changes a task canvas to workspace placement.
|
||||||
|
|
||||||
## Requirements
|
## Requirements
|
||||||
|
|
||||||
@@ -120,8 +121,8 @@ browser session end.
|
|||||||
|
|
||||||
### REQ-CANVASES-AGENT-WEB-APPS-003: User-controlled promotion
|
### REQ-CANVASES-AGENT-WEB-APPS-003: User-controlled promotion
|
||||||
|
|
||||||
**Intent:** A useful task canvas becomes a workspace application only after a
|
**Intent:** A useful task canvas becomes available in workspace navigation only
|
||||||
user reviews its scope and permissions.
|
after a user reviews its placement and permissions.
|
||||||
|
|
||||||
**User story:** As a user, I want to promote a useful task canvas, so that I
|
**User story:** As a user, I want to promote a useful task canvas, so that I
|
||||||
can open it from the workspace sidebar.
|
can open it from the workspace sidebar.
|
||||||
@@ -132,7 +133,8 @@ can open it from the workspace sidebar.
|
|||||||
system shall show the requested data, write, event, state, and network
|
system shall show the requested data, write, event, state, and network
|
||||||
permissions before confirmation.
|
permissions before confirmation.
|
||||||
- **AC-CANVASES-AGENT-WEB-APPS-003.2:** When the user confirms promotion, the
|
- **AC-CANVASES-AGENT-WEB-APPS-003.2:** When the user confirms promotion, the
|
||||||
same canvas identity and active release shall change to workspace scope.
|
same canvas identity and active release shall change to workspace placement.
|
||||||
|
A release that already has workspace data access shall keep that access.
|
||||||
- **AC-CANVASES-AGENT-WEB-APPS-003.3:** When promotion completes, the canvas
|
- **AC-CANVASES-AGENT-WEB-APPS-003.3:** When promotion completes, the canvas
|
||||||
shall appear in navigation for that workspace only.
|
shall appear in navigation for that workspace only.
|
||||||
- **AC-CANVASES-AGENT-WEB-APPS-003.4:** An agent shall not promote, demote, or
|
- **AC-CANVASES-AGENT-WEB-APPS-003.4:** An agent shall not promote, demote, or
|
||||||
@@ -342,6 +344,7 @@ including missing references, lookup failures, and passthrough exclusions.
|
|||||||
|
|
||||||
- [Canvas runtime and task-entry recovery](../../../plans/canvas-runtime-entry-recovery/plan.md)
|
- [Canvas runtime and task-entry recovery](../../../plans/canvas-runtime-entry-recovery/plan.md)
|
||||||
- [Direct canvas creation and saved prompt](../../../plans/canvas-direct-creation/plan.md)
|
- [Direct canvas creation and saved prompt](../../../plans/canvas-direct-creation/plan.md)
|
||||||
|
- [Task canvas workspace data preview](../../../plans/task-canvas-workspace-preview/plan.md)
|
||||||
|
|
||||||
## Out of scope
|
## Out of scope
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ system: canvases
|
|||||||
owners:
|
owners:
|
||||||
- canvases
|
- canvases
|
||||||
created: 2026-09-10
|
created: 2026-09-10
|
||||||
last_updated: 2026-09-10
|
last_updated: 2026-09-23
|
||||||
---
|
---
|
||||||
|
|
||||||
# Owner-authorized canvas creation
|
# Owner-authorized canvas creation
|
||||||
@@ -39,10 +39,11 @@ imported code and later changes cannot claim that creation authority.
|
|||||||
- **AC-CANVASES-LOCAL-CREATION-001.1:** The first valid release of an
|
- **AC-CANVASES-LOCAL-CREATION-001.1:** The first valid release of an
|
||||||
owner-authorized task canvas shall activate without a separate permission
|
owner-authorized task canvas shall activate without a separate permission
|
||||||
approval. Its grants shall contain only its declared, supported permissions.
|
approval. Its grants shall contain only its declared, supported permissions.
|
||||||
- **AC-CANVASES-LOCAL-CREATION-001.2:** Initial grants shall remain within the
|
- **AC-CANVASES-LOCAL-CREATION-001.2:** Initial grants shall contain only the
|
||||||
task scope and current owner access. They can cover supported data reads,
|
declared supported data reads, writes, events, instance state, and exact HTTPS
|
||||||
writes, events, instance state, and exact HTTPS external origins. They shall
|
external origins. For a new owner-authorized canvas, those grants shall cover
|
||||||
not enable remote scripts, managed backend execution, or workspace access.
|
the current workspace while the canvas remains in its task. They shall not
|
||||||
|
enable another workspace, remote scripts, or managed backend execution.
|
||||||
- **AC-CANVASES-LOCAL-CREATION-001.3:** Agent fields, package metadata, display
|
- **AC-CANVASES-LOCAL-CREATION-001.3:** Agent fields, package metadata, display
|
||||||
titles, and ownership of an imported package shall not establish local
|
titles, and ownership of an imported package shall not establish local
|
||||||
creation authority. Missing or mismatched trusted identity shall deny it.
|
creation authority. Missing or mismatched trusted identity shall deny it.
|
||||||
@@ -71,3 +72,4 @@ a marketplace, import UI, collaborator role, or ongoing blanket approval.
|
|||||||
## Implementation plans
|
## Implementation plans
|
||||||
|
|
||||||
- [Canvas runtime and permission fixes](../../../plans/canvas-runtime-permission-fixes/plan.md)
|
- [Canvas runtime and permission fixes](../../../plans/canvas-runtime-permission-fixes/plan.md)
|
||||||
|
- [Task canvas workspace data preview](../../../plans/task-canvas-workspace-preview/plan.md)
|
||||||
|
|||||||
@@ -0,0 +1,88 @@
|
|||||||
|
---
|
||||||
|
id: canvases-task-canvas-workspace-preview
|
||||||
|
title: Task canvas workspace data preview
|
||||||
|
status: draft
|
||||||
|
system: canvases
|
||||||
|
owners:
|
||||||
|
- canvases
|
||||||
|
created: 2026-09-23
|
||||||
|
last_updated: 2026-09-23
|
||||||
|
---
|
||||||
|
|
||||||
|
# Task canvas workspace data preview Requirements
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
A user creates a canvas from a task and reviews the live application there
|
||||||
|
before deciding whether to add it to workspace navigation. Its task placement
|
||||||
|
must not reduce the data available to an owner-authorized first release.
|
||||||
|
Canvases owns the creation and promotion lifecycle; the Plugins system owns the
|
||||||
|
runtime data contract and grant enforcement.
|
||||||
|
|
||||||
|
## Terminology
|
||||||
|
|
||||||
|
- **Placement scope:** Where Kandev presents a canvas and which task owns its
|
||||||
|
unpromoted lifecycle.
|
||||||
|
- **Data scope:** The largest Kandev data boundary admitted by a release's
|
||||||
|
declared and granted capabilities. Data scope never overrides the viewing
|
||||||
|
user's current authorization.
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
### REQ-CANVASES-WORKSPACE-PREVIEW-001: Representative task canvas preview
|
||||||
|
|
||||||
|
**Intent:** A locally requested canvas works with the current workspace's live
|
||||||
|
data before the user chooses to promote it.
|
||||||
|
|
||||||
|
**User story:** As a user creating a canvas from a task, I want to see all tasks
|
||||||
|
in my current workspace, so that I can judge the canvas before promotion.
|
||||||
|
|
||||||
|
#### Acceptance criteria
|
||||||
|
|
||||||
|
- **AC-CANVASES-WORKSPACE-PREVIEW-001.1:** After the first valid release of an
|
||||||
|
owner-authorized task canvas activates, its declared task read capability
|
||||||
|
shall list and read all currently authorized tasks in the canvas's workspace,
|
||||||
|
including tasks added after publication. Refresh and pagination shall reach
|
||||||
|
the full result set. A task in another workspace shall remain unavailable.
|
||||||
|
- **AC-CANVASES-WORKSPACE-PREVIEW-001.2:** Before promotion, every supported
|
||||||
|
capability declared by that first release shall operate across the same
|
||||||
|
workspace where the capability has a workspace data meaning. Undeclared or
|
||||||
|
unsupported capabilities shall remain unavailable, and every operation shall
|
||||||
|
obey current user authorization.
|
||||||
|
- **AC-CANVASES-WORKSPACE-PREVIEW-001.3:** The canvas shall remain discoverable
|
||||||
|
only in its originating task until a user promotes it. Task removal shall
|
||||||
|
retain its existing cleanup behavior. Promotion shall still require user
|
||||||
|
confirmation, but it shall not be required to preview workspace data.
|
||||||
|
- **AC-CANVASES-WORKSPACE-PREVIEW-001.4:** The canvas context and host review
|
||||||
|
shall distinguish task placement from workspace data access. Promotion review
|
||||||
|
shall identify whether it changes data access or only placement.
|
||||||
|
- **AC-CANVASES-WORKSPACE-PREVIEW-001.5:** An existing task canvas with
|
||||||
|
task-only data access shall keep that access after upgrade until a current
|
||||||
|
workspace owner explicitly enables workspace data access. The review shall
|
||||||
|
name the active release and its declared permissions. Confirmation shall
|
||||||
|
widen only those declared grants without promoting or republishing the
|
||||||
|
canvas; cancellation shall change nothing.
|
||||||
|
- **AC-CANVASES-WORKSPACE-PREVIEW-001.6:** A stale review, changed owner,
|
||||||
|
revoked grant, missing task or workspace, or failed permission check shall
|
||||||
|
deny the access change without widening data exposure. A new release that
|
||||||
|
adds a capability after first publication shall still await permission
|
||||||
|
review.
|
||||||
|
- **AC-CANVASES-WORKSPACE-PREVIEW-001.7:** Desktop and phone users shall be
|
||||||
|
able to inspect the data scope and complete the existing-canvas access review
|
||||||
|
from the task canvas host. The phone flow shall remain usable with one
|
||||||
|
vertical scroll region, safe-area clearance, and touch controls.
|
||||||
|
- **AC-CANVASES-WORKSPACE-PREVIEW-001.8:** A canvas built under this contract
|
||||||
|
shall receive only workspace data authorized for its own workspace. Its
|
||||||
|
runtime token, manifest, or agent input shall not select a different
|
||||||
|
workspace or expand its capabilities.
|
||||||
|
|
||||||
|
## Out of scope
|
||||||
|
|
||||||
|
- Data from other workspaces or installation-wide grants.
|
||||||
|
- Blanket grants for capabilities a release does not declare.
|
||||||
|
- New canvas plugin capability types or arbitrary backend execution.
|
||||||
|
- Automatic widening of existing published releases.
|
||||||
|
|
||||||
|
## Implementation plans
|
||||||
|
|
||||||
|
- [Task canvas workspace data preview](../../../plans/task-canvas-workspace-preview/plan.md)
|
||||||
@@ -6,7 +6,7 @@ system: canvases
|
|||||||
owners:
|
owners:
|
||||||
- canvases
|
- canvases
|
||||||
created: 2026-08-26
|
created: 2026-08-26
|
||||||
last_updated: 2026-09-21
|
last_updated: 2026-09-23
|
||||||
requirements:
|
requirements:
|
||||||
- REQ-CANVASES-AGENT-WEB-APPS-001
|
- REQ-CANVASES-AGENT-WEB-APPS-001
|
||||||
- REQ-CANVASES-AGENT-WEB-APPS-002
|
- REQ-CANVASES-AGENT-WEB-APPS-002
|
||||||
@@ -110,9 +110,11 @@ Use a small lifecycle record instead of declarative canvas blocks.
|
|||||||
- nullable `archived_at`
|
- nullable `archived_at`
|
||||||
- `created_at` and `updated_at`
|
- `created_at` and `updated_at`
|
||||||
|
|
||||||
The plugin instance owns `scope_kind`, active release, status, and grants. The
|
The plugin instance owns placement `scope_kind`, effective data scope, active
|
||||||
canvas service is the only service that changes the task or workspace scope of
|
release, status, and grants. The canvas service is the only service that
|
||||||
a canvas instance.
|
changes task or workspace placement or authorizes a canvas data-scope change.
|
||||||
|
The [workspace preview design](task-canvas-workspace-preview.md) separates
|
||||||
|
these scopes for locally requested task canvases.
|
||||||
|
|
||||||
The canvas and plugin instance have a one-to-one relationship. Canvas removal
|
The canvas and plugin instance have a one-to-one relationship. Canvas removal
|
||||||
removes the plugin instance, runtime tokens, grants, state, pending releases,
|
removes the plugin instance, runtime tokens, grants, state, pending releases,
|
||||||
@@ -366,13 +368,14 @@ The promotion dialog shows:
|
|||||||
- each event subject
|
- each event subject
|
||||||
- shared state access
|
- shared state access
|
||||||
- each exact external network origin
|
- each exact external network origin
|
||||||
- the change from task scope to workspace scope
|
- the change from task placement to workspace placement, with any data-scope
|
||||||
|
difference identified separately
|
||||||
- the new workspace navigation placement
|
- the new workspace navigation placement
|
||||||
|
|
||||||
The user can cancel without a state change. Confirmation creates the approved
|
The user can cancel without a state change. Confirmation creates any needed
|
||||||
workspace grants and changes the plugin instance scope in one transaction. The
|
workspace grants and changes the plugin instance placement scope in one
|
||||||
canvas keeps its ID, plugin instance, active release, state, and release
|
transaction. The canvas keeps its ID, plugin instance, active release, state,
|
||||||
history.
|
and release history.
|
||||||
|
|
||||||
Promotion publishes one lifecycle event after commit. The task and workspace
|
Promotion publishes one lifecycle event after commit. The task and workspace
|
||||||
navigation projections then refresh.
|
navigation projections then refresh.
|
||||||
@@ -641,8 +644,10 @@ connection. An edit Quick Chat session also verifies its trusted target canvas
|
|||||||
metadata.
|
metadata.
|
||||||
|
|
||||||
Task canvases cannot appear in another task. Workspace canvases can serve task
|
Task canvases cannot appear in another task. Workspace canvases can serve task
|
||||||
surfaces only inside their workspace. Promotion does not grant access to tasks,
|
surfaces only inside their workspace. A task canvas can use approved workspace
|
||||||
repositories, sessions, or external services by itself.
|
data before promotion under the [workspace preview contract](task-canvas-workspace-preview.md).
|
||||||
|
Promotion does not grant undeclared access to tasks, repositories, sessions,
|
||||||
|
or external services.
|
||||||
|
|
||||||
The plugin runtime repeats resource authorization for each request. Canvas
|
The plugin runtime repeats resource authorization for each request. Canvas
|
||||||
authorization does not replace task or workspace authorization.
|
authorization does not replace task or workspace authorization.
|
||||||
|
|||||||
@@ -152,7 +152,8 @@ The definition in `config/prompts/create-canvas.md` contains this guidance:
|
|||||||
> Call `publish_canvas_kandev` and address any validation errors.
|
> Call `publish_canvas_kandev` and address any validation errors.
|
||||||
> Report the canvas identity and whether its release is active, awaits permission review, or was unsuccessful.
|
> Report the canvas identity and whether its release is active, awaits permission review, or was unsuccessful.
|
||||||
> If publication is unsuccessful, report the failure and do not claim that the canvas is published.
|
> If publication is unsuccessful, report the failure and do not claim that the canvas is published.
|
||||||
> If workspace access requires promotion, explain the user action that is still required.
|
> A new owner-authorized task canvas can use its declared supported permissions across the current workspace before promotion.
|
||||||
|
> Promotion adds workspace navigation. Do not claim that promotion is required to preview workspace task data.
|
||||||
> A local build alone does not publish a canvas inside Kandev.
|
> A local build alone does not publish a canvas inside Kandev.
|
||||||
> If the tools remain unavailable, report the limitation instead of claiming that workspace files are a Kandev canvas.
|
> If the tools remain unavailable, report the limitation instead of claiming that workspace files are a Kandev canvas.
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ system: canvases
|
|||||||
owners:
|
owners:
|
||||||
- canvases
|
- canvases
|
||||||
created: 2026-09-10
|
created: 2026-09-10
|
||||||
last_updated: 2026-09-10
|
last_updated: 2026-09-23
|
||||||
requirements:
|
requirements:
|
||||||
- REQ-CANVASES-LOCAL-CREATION-001
|
- REQ-CANVASES-LOCAL-CREATION-001
|
||||||
---
|
---
|
||||||
@@ -20,7 +20,8 @@ owns grant storage, release activation, and runtime enforcement. This design
|
|||||||
implements `REQ-CANVASES-LOCAL-CREATION-001`; the authority, transaction, and
|
implements `REQ-CANVASES-LOCAL-CREATION-001`; the authority, transaction, and
|
||||||
compatibility sections map to criteria .1-.3, .4-.5, and .6-.7 respectively.
|
compatibility sections map to criteria .1-.3, .4-.5, and .6-.7 respectively.
|
||||||
|
|
||||||
See [the creation authority decision](../../../decisions/2026-09-10-canvas-creation-authority.md)
|
See [the creation authority decision](../../../decisions/2026-09-10-canvas-creation-authority.md),
|
||||||
|
[its workspace-data amendment](../../../decisions/2026-09-23-task-canvas-workspace-data.md),
|
||||||
and [plugin grants](../../plugins/system-design/isolated-web-app-contributions.md#instance-grants).
|
and [plugin grants](../../plugins/system-design/isolated-web-app-contributions.md#instance-grants).
|
||||||
|
|
||||||
## Trusted creation authority
|
## Trusted creation authority
|
||||||
@@ -40,7 +41,7 @@ Add a canvas-owned `canvas_creation_authority` table through
|
|||||||
| `canvas_id` | Primary key, references the canvas lifecycle identity |
|
| `canvas_id` | Primary key, references the canvas lifecycle identity |
|
||||||
| `owner_user_id` | Trusted workspace owner at creation |
|
| `owner_user_id` | Trusted workspace owner at creation |
|
||||||
| `creating_session_id` | Trusted source session |
|
| `creating_session_id` | Trusted source session |
|
||||||
| `policy_version` | Integer 1 for the initial-publication policy |
|
| `policy_version` | Integer 1 for historical task-data grants; version 2 for new workspace-data grants |
|
||||||
| `consumed_at` | Empty until the first release transaction consumes it |
|
| `consumed_at` | Empty until the first release transaction consumes it |
|
||||||
|
|
||||||
Create the row in the canvas/instance creation transaction. No agent-facing
|
Create the row in the canvas/instance creation transaction. No agent-facing
|
||||||
@@ -52,7 +53,9 @@ Existing databases acquire the empty table; do not backfill authority.
|
|||||||
At publication require every predicate: a nonzero captured `PublishAuthority`,
|
At publication require every predicate: a nonzero captured `PublishAuthority`,
|
||||||
local-canvas instance source, task scope, matching task and workspace, matching
|
local-canvas instance source, task scope, matching task and workspace, matching
|
||||||
creating session, unchanged current owner, pending instance, no active or
|
creating session, unchanged current owner, pending instance, no active or
|
||||||
persisted release, no previous grant changes, and unconsumed policy version 1.
|
persisted release, no previous grant changes, and an unconsumed supported
|
||||||
|
policy version. New authority uses version 2; historical version-1 authority
|
||||||
|
keeps its task data ceiling and is never upgraded by publication alone.
|
||||||
Check the full instance snapshot again inside the transaction. Re-read the
|
Check the full instance snapshot again inside the transaction. Re-read the
|
||||||
current task/workspace ownership through the same transaction and serialize
|
current task/workspace ownership through the same transaction and serialize
|
||||||
against ownership changes; the adapter's earlier lookup alone is insufficient.
|
against ownership changes; the adapter's earlier lookup alone is insufficient.
|
||||||
@@ -64,10 +67,12 @@ The trusted adapter supplies the freshly resolved owner; caller-controlled
|
|||||||
## Grant derivation and transaction
|
## Grant derivation and transaction
|
||||||
|
|
||||||
Derive grants from `ManifestPermissions` and the existing static web-app
|
Derive grants from `ManifestPermissions` and the existing static web-app
|
||||||
capability vocabulary. Restrict Kandev resource access to the recorded task,
|
capability vocabulary. Version-2 authority binds the approved data scope to
|
||||||
using normal runtime authorization. Normalize each declared external origin
|
the recorded workspace while the canvas remains task-placed. Historical
|
||||||
through `NormalizeNetworkOrigins`; initial external grants count as delegated
|
version-1 authority retains task data scope. See the
|
||||||
owner approval, with that owner as `ApprovedBy`. This is deliberate: a locally
|
[workspace preview design](task-canvas-workspace-preview.md). Normalize each
|
||||||
|
declared external origin through `NormalizeNetworkOrigins`; initial external
|
||||||
|
grants count as delegated owner approval, with that owner as `ApprovedBy`. A locally
|
||||||
requested app can contact its declared HTTPS service on its first run.
|
requested app can contact its declared HTTPS service on its first run.
|
||||||
Unsupported capabilities and backend/native contributions cannot gain access
|
Unsupported capabilities and backend/native contributions cannot gain access
|
||||||
through this path. Do not add a generic manifest-controlled auto-approve flag.
|
through this path. Do not add a generic manifest-controlled auto-approve flag.
|
||||||
@@ -96,8 +101,9 @@ schema replay and backup enumeration must include this table.
|
|||||||
## API and compatibility
|
## API and compatibility
|
||||||
|
|
||||||
Return an additive `initial_permission_policy` field in the create response:
|
Return an additive `initial_permission_policy` field in the create response:
|
||||||
version 1, eligibility, scope `task`, supported permission kinds, and the exact
|
the policy version, eligibility, placement `task`, data scope, supported
|
||||||
HTTPS-origin constraint. This is descriptive output, never a publish input.
|
permission kinds, and exact HTTPS-origin constraint. This is descriptive
|
||||||
|
output, never a publish input.
|
||||||
It must not claim a permission ceiling that the backend does not enforce.
|
It must not claim a permission ceiling that the backend does not enforce.
|
||||||
Keep the existing `activated` and `permission_required` publish result fields.
|
Keep the existing `activated` and `permission_required` publish result fields.
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,168 @@
|
|||||||
|
---
|
||||||
|
id: canvases-task-canvas-workspace-preview-design
|
||||||
|
title: Task canvas workspace data preview design
|
||||||
|
status: draft
|
||||||
|
system: canvases
|
||||||
|
owners:
|
||||||
|
- canvases
|
||||||
|
created: 2026-09-23
|
||||||
|
last_updated: 2026-09-23
|
||||||
|
requirements:
|
||||||
|
- REQ-CANVASES-WORKSPACE-PREVIEW-001
|
||||||
|
---
|
||||||
|
|
||||||
|
# Task canvas workspace data preview System Design
|
||||||
|
|
||||||
|
## Boundary and mapping
|
||||||
|
|
||||||
|
Canvases owns when a locally requested task canvas gains workspace data scope,
|
||||||
|
how an older canvas can opt in, and when promotion changes placement. Plugins
|
||||||
|
persists the instance's effective data scope and enforces it through the shared
|
||||||
|
web-app runtime. Task services remain authoritative for data and current user
|
||||||
|
access. This design implements `REQ-CANVASES-WORKSPACE-PREVIEW-001`.
|
||||||
|
|
||||||
|
| Criteria | Design sections |
|
||||||
|
| --- | --- |
|
||||||
|
| `AC-CANVASES-WORKSPACE-PREVIEW-001.1`, `.2`, `.8` | Data scope and runtime enforcement |
|
||||||
|
| `AC-CANVASES-WORKSPACE-PREVIEW-001.3`, `.4` | Lifecycle and presentation |
|
||||||
|
| `AC-CANVASES-WORKSPACE-PREVIEW-001.5`, `.6`, `.7` | Existing canvas upgrade and host flow |
|
||||||
|
|
||||||
|
See [the workspace preview decision](../../../decisions/2026-09-23-task-canvas-workspace-data.md)
|
||||||
|
and [the existing creation authority](local-creation-authority.md).
|
||||||
|
|
||||||
|
## Data scope and persistence
|
||||||
|
|
||||||
|
Keep `plugin_instances.scope_kind` as the placement/lifecycle scope. Add a
|
||||||
|
nullable `data_scope_kind` to `plugin_instances`; null means the existing
|
||||||
|
`scope_kind` so older rows and non-canvas plugins retain their behavior. A
|
||||||
|
non-null value is valid only when it equals the placement scope, or when a
|
||||||
|
`local_canvas` task instance uses `workspace` data scope with its existing
|
||||||
|
trusted `workspace_id` and `task_id`. Reject `instance` and foreign workspace
|
||||||
|
data scope for canvases. Do not accept the field from agent, manifest, or
|
||||||
|
browser input. Store it through SQLite and PostgreSQL schema paths, backup,
|
||||||
|
snapshot, and instance row mapping.
|
||||||
|
|
||||||
|
At trusted canvas creation, record workspace data scope only for a new task
|
||||||
|
canvas with owner-authorized creation provenance. The task remains the canvas
|
||||||
|
owner and the plugin instance's placement scope. The first valid release
|
||||||
|
derives grants from exactly its declared supported capabilities with a
|
||||||
|
`workspace` ceiling, then consumes creation authority and activates in the
|
||||||
|
existing transaction. Raise `CreationAuthorityPolicyVersion` for newly issued
|
||||||
|
authority; consumed version-1 rows remain historical evidence and are never
|
||||||
|
reissued. If provenance or current owner checks fail, publication follows the
|
||||||
|
normal permission-review path and cannot infer a workspace grant.
|
||||||
|
|
||||||
|
All grant-fit, release activation, and runtime-binding validation for a web
|
||||||
|
app use its effective data scope. A grant ceiling cannot be interpreted as
|
||||||
|
covering a wider data scope merely because it covers the placement scope. A
|
||||||
|
later declaration increase still creates a pending release. Revocation bumps
|
||||||
|
the existing grant generation and invalidates runtime tokens.
|
||||||
|
|
||||||
|
## Runtime enforcement
|
||||||
|
|
||||||
|
`webapp.CapabilityBinding` carries both placement `ScopeKind` and effective
|
||||||
|
`DataScopeKind`. Issue both from the persisted instance, and compare both with
|
||||||
|
the current instance on every token validation. Existing bindings with an
|
||||||
|
empty data scope resolve to placement scope. The relative
|
||||||
|
`./_kandev/v1/context` response retains `scope_kind` for placement and adds
|
||||||
|
`data_scope_kind` for authors to label the available data accurately.
|
||||||
|
|
||||||
|
Use the effective data scope for the web-app task list, single-task read,
|
||||||
|
task update, message dispatch, workflow list and steps, task dependency edge
|
||||||
|
projection, and subscribed task events. A workspace data scope binds all
|
||||||
|
queries and mutations to the instance's trusted `workspace_id`, including
|
||||||
|
explicit `workspace_id` filters. It does not derive workspace IDs from the
|
||||||
|
request or allow `ScopeInstance`. The task-list route uses its existing
|
||||||
|
pagination path instead of the bound-task shortcut. Each operation still
|
||||||
|
checks the declared capability, effective grant, and current user/domain
|
||||||
|
authorization. Other plugin instances continue to use their existing scope
|
||||||
|
rules.
|
||||||
|
|
||||||
|
Never use the ordinary authenticated Kandev HTTP API as an implicit escape
|
||||||
|
from the canvas protocol. The [same-origin trust decision](../../../decisions/2026-09-19-trusted-same-origin-canvases.md)
|
||||||
|
acknowledges that browser code can call those APIs, but the documented canvas
|
||||||
|
contract and capability-only runtime must remain coherent.
|
||||||
|
|
||||||
|
## Lifecycle and presentation
|
||||||
|
|
||||||
|
`canvas_lifecycle_metadata.task_id` and the instance's placement scope retain
|
||||||
|
task discovery and deletion semantics. Workspace data scope does not add the
|
||||||
|
canvas to workspace navigation. Promotion remains a reviewed human action:
|
||||||
|
it changes placement to workspace, clears task placement, preserves the active
|
||||||
|
release, and leaves workspace data scope intact. The review states that data
|
||||||
|
access is already workspace-wide when that is true. A task-only legacy canvas
|
||||||
|
still has its existing promotion behavior, including grant expansion.
|
||||||
|
|
||||||
|
Project `data_scope_kind` alongside `scope_kind` in the canvas and runtime
|
||||||
|
responses. The task host exposes a compact data-scope label in its existing
|
||||||
|
header/action surface. Releases and permissions and promotion review explain
|
||||||
|
placement separately from data access. The bundled authoring guidance and
|
||||||
|
public canvas documentation tell authors to read both context fields and to
|
||||||
|
declare only capabilities they use. Revise the saved `create-canvas` prompt's
|
||||||
|
promotion guidance so it describes workspace navigation rather than saying
|
||||||
|
that workspace data always requires promotion. No generated package rewrite
|
||||||
|
is needed.
|
||||||
|
|
||||||
|
## Existing canvas upgrade
|
||||||
|
|
||||||
|
Do not silently widen a retained release. An active task canvas with task-only
|
||||||
|
data scope offers **Enable workspace data** in the existing host action menu.
|
||||||
|
The backend returns a review snapshot of the active release, permission
|
||||||
|
digest, grant generation, current/target data scope, and exact declared
|
||||||
|
permissions. Only a currently authorized workspace owner may confirm it.
|
||||||
|
|
||||||
|
Confirmation rechecks the canvas's task and workspace identity, owner, active
|
||||||
|
release, permission digest, and grant generation inside one transaction. It
|
||||||
|
upserts workspace-ceiling grants only for that release's declared permissions,
|
||||||
|
sets `data_scope_kind=workspace`, increments grant generation, and publishes a
|
||||||
|
single lifecycle refresh after commit. It neither promotes the canvas nor
|
||||||
|
republishes the artifact. On stale review or any failed check it changes
|
||||||
|
nothing. A canceled review has no write. Imported or legacy packages receive
|
||||||
|
no automatic exemption.
|
||||||
|
|
||||||
|
Use the existing canvas HTTP controller's preview/confirm pattern with
|
||||||
|
`GET /api/v1/canvases/{id}/workspace-data-preview` and
|
||||||
|
`POST /api/v1/canvases/{id}/workspace-data`. The request carries the same
|
||||||
|
release ID, permission digest, and grant generation preconditions as promotion.
|
||||||
|
Return an updated canvas projection after success. Keep the action unavailable
|
||||||
|
for pending, archived, removed, already workspace-data, or foreign canvases.
|
||||||
|
|
||||||
|
## Desktop and phone host flow
|
||||||
|
|
||||||
|
Desktop keeps the current canvas panel and action toolbar. The data-scope
|
||||||
|
label sits with existing host metadata, and a legacy canvas exposes the upgrade
|
||||||
|
action from its current actions. Phone keeps the focused canvas route and
|
||||||
|
secondary-action drawer; the same upgrade opens the existing full-height
|
||||||
|
review surface. The header and actions remain fixed while one body region
|
||||||
|
scrolls, with safe-area clearance and touch targets. Both presentations share
|
||||||
|
one review state and backend snapshot; neither relies on canvas-authored UI.
|
||||||
|
|
||||||
|
## Failure and observability
|
||||||
|
|
||||||
|
Denied or stale access review returns the existing safe error style and leaves
|
||||||
|
the active release and old scope usable. A missing task or workspace makes
|
||||||
|
its runtime binding stale. Scope or grant changes invalidate old tokens; the
|
||||||
|
host reloads the runtime through its existing recovery path. Record bounded
|
||||||
|
scope-transition result counters and IDs already allowed for canvas lifecycle
|
||||||
|
logging. Do not log package source, permission bodies, tokens, or task data.
|
||||||
|
|
||||||
|
## Verification strategy
|
||||||
|
|
||||||
|
- Instance-store tests cover null fallback, invalid combinations, transaction
|
||||||
|
rollback, snapshot/backup mapping, and grant-generation invalidation.
|
||||||
|
- Canvas service tests cover version-2 owner authority, exact declarations,
|
||||||
|
legacy review, stale snapshots, owner drift, and promotion without data
|
||||||
|
expansion.
|
||||||
|
- Web-app protocol tests cover list pagination, task read/write, workflow,
|
||||||
|
message, event, dependency projection, foreign-workspace denial, and old
|
||||||
|
task-scoped behavior.
|
||||||
|
- Desktop and mobile Playwright exercise an owner-created task canvas with at
|
||||||
|
least two tasks, refresh before promotion, and an older canvas's explicit
|
||||||
|
access upgrade. The mobile route and review remain touch-usable.
|
||||||
|
|
||||||
|
## Related designs
|
||||||
|
|
||||||
|
- [Canvas lifecycle and promotion](agent-authored-web-apps.md)
|
||||||
|
- [Owner-authorized first publication](local-creation-authority.md)
|
||||||
|
- [Plugin web-app runtime](../../plugins/system-design/isolated-web-app-contributions.md)
|
||||||
|
- [Plugin canvas data-scope runtime](../../plugins/system-design/canvas-workspace-data-scope.md)
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
---
|
||||||
|
id: plugins-canvas-workspace-data-scope-design
|
||||||
|
title: Canvas placement and data scope in the web-app runtime
|
||||||
|
status: draft
|
||||||
|
system: plugins
|
||||||
|
owners:
|
||||||
|
- kandev
|
||||||
|
created: 2026-09-23
|
||||||
|
last_updated: 2026-09-23
|
||||||
|
requirements: []
|
||||||
|
---
|
||||||
|
|
||||||
|
# Canvas placement and data scope in the web-app runtime
|
||||||
|
|
||||||
|
## Boundary
|
||||||
|
|
||||||
|
This is the Plugins runtime implementation contract for the
|
||||||
|
[Canvas workspace preview requirement](../../canvases/requirements/task-canvas-workspace-preview.md).
|
||||||
|
Canvases decides when an owner-authorized local app may use workspace data.
|
||||||
|
Plugins persists and enforces that decision; it does not infer authority from
|
||||||
|
the manifest or browser request. The existing
|
||||||
|
[isolated web-app design](isolated-web-app-contributions.md) remains the general
|
||||||
|
runtime contract.
|
||||||
|
|
||||||
|
## Instance model and grants
|
||||||
|
|
||||||
|
Add nullable `plugin_instances.data_scope_kind`. A null value resolves to
|
||||||
|
`scope_kind` for retained rows and non-canvas instances. A non-null workspace
|
||||||
|
data scope is valid only for a `local_canvas` instance placed in a task or
|
||||||
|
workspace with a trusted `workspace_id`. Reject any attempt to use an
|
||||||
|
installation-wide data scope for a canvas. Creation, reviewed upgrade, and
|
||||||
|
promotion update the field and grants through transactions owned by the
|
||||||
|
canvas lifecycle service.
|
||||||
|
|
||||||
|
Grant fit, release activation, and runtime validation compare each declared
|
||||||
|
permission with the effective data scope. An exact task grant cannot cover
|
||||||
|
workspace data. A workspace grant authorizes only the bound workspace and is
|
||||||
|
still intersected with current user and resource access. Revocation and scope
|
||||||
|
changes increment the existing grant generation.
|
||||||
|
|
||||||
|
## Runtime binding and protocol
|
||||||
|
|
||||||
|
Add effective `DataScopeKind` to `webapp.CapabilityBinding`, sourced from the
|
||||||
|
persisted instance and checked against it on every request. Keep `ScopeKind`
|
||||||
|
for placement and task lifecycle. The relative context response exposes both
|
||||||
|
`scope_kind` and `data_scope_kind`; empty historical data scope resolves to
|
||||||
|
placement scope.
|
||||||
|
|
||||||
|
Task lists, single-task reads, task writes, messages, workflow reads, task
|
||||||
|
dependency projections, and events use data scope. Workspace-data task
|
||||||
|
canvases use the paginated workspace path, bounded by the trusted workspace
|
||||||
|
ID, rather than the task-only shortcut. Explicit foreign `workspace_id`
|
||||||
|
filters fail. Non-canvas instances retain current behavior.
|
||||||
|
|
||||||
|
## Compatibility and failure
|
||||||
|
|
||||||
|
The schema migration does not widen old rows. Historical runtime bindings
|
||||||
|
and grants remain task-scoped until a reviewed update changes them. A stale
|
||||||
|
binding, missing workspace/task, revoked grant, or mismatched data scope fails
|
||||||
|
closed through existing safe errors. Current plugin manifests and immutable
|
||||||
|
canvas packages need no rewrite.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
Test SQLite and PostgreSQL mapping, legacy null fallback, invalid scope
|
||||||
|
combinations, runtime token invalidation, exact grants, all workspace task
|
||||||
|
pages, task read/write and message paths, workflow and event scope, dependency
|
||||||
|
redaction, and foreign-workspace denial.
|
||||||
|
|
||||||
|
## Related decision
|
||||||
|
|
||||||
|
- [Task canvas workspace data](../../../decisions/2026-09-23-task-canvas-workspace-data.md)
|
||||||
Reference in New Issue
Block a user