package canvas import ( "context" "crypto/sha256" "encoding/json" "errors" "fmt" "sort" "strings" "time" "github.com/google/uuid" "github.com/jmoiron/sqlx" plugininstances "github.com/kandev/kandev/internal/plugins/instances" "github.com/kandev/kandev/internal/plugins/manifest" "github.com/kandev/kandev/internal/plugins/webapp" ) // PermissionSummary is the review-safe projection of a canvas manifest. It // contains permission names and exact origins, never package source or state. type PermissionSummary struct { Reads []string `json:"reads,omitempty"` Writes []string `json:"writes,omitempty"` Events []string `json:"events,omitempty"` SharedState bool `json:"shared_state"` ExternalOrigins []string `json:"external_origins,omitempty"` } // PublishRequest contains a package already read from the trusted execution // source stream. The request has no user-controlled scope identifiers. type PublishRequest struct { CanvasID string Package *webapp.Package Artifact webapp.Artifact // ExpectedAuthority is captured by the trusted authoring boundary before // source transfer. Durable persistence must still match it atomically. ExpectedAuthority plugininstances.PublishAuthority // ExpectedBaseReleaseID is set for a trusted edit session. The release // publish transaction rejects the package if the active release changed // after the editor materialized its source. ExpectedBaseReleaseID string SourceActorKind string SourceUserID string SourceUserSynthetic bool SourceTaskID string SourceSessionID string } // PublishResult describes the release outcome without exposing package files. type PublishResult struct { Canvas *Canvas Release plugininstances.Release Activated bool PermissionRequired bool // ReleasePersisted tells the source owner that the artifact is now owned // by durable release metadata, even when a post-persistence projection or // retention step returned an error. ReleasePersisted bool } // PromotionPreview is shown to a human before a task canvas becomes a // workspace canvas. type PromotionPreview struct { Canvas *Canvas SourceActorKind string `json:"source_actor_kind"` SourceUserID string `json:"source_user_id,omitempty"` SourceTaskID string `json:"source_task_id,omitempty"` SourceSessionID string `json:"source_session_id,omitempty"` Permissions PermissionSummary `json:"permissions"` ActiveReleaseID string `json:"active_release_id"` PermissionDigest string `json:"permission_digest"` GrantGeneration int64 `json:"grant_generation"` CurrentScope string `json:"current_scope"` TargetScope string `json:"target_scope"` CurrentDataScope string `json:"current_data_scope_kind"` TargetDataScope string `json:"target_data_scope_kind"` 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 // the durable plugin instance store. Keeping it optional preserves the small // lifecycle fake used by the basic canvas service tests. type authoringInstanceStore interface { PluginInstanceStore SetPluginID(context.Context, string, string) error CreateRelease(context.Context, plugininstances.Release) error ListReleases(context.Context, string) ([]plugininstances.Release, error) ActivateRelease(context.Context, string, string) error SetReleaseValidation(context.Context, string, string, string) error ListGrants(context.Context, string) ([]plugininstances.Grant, error) PromoteScopeAndGrants(context.Context, string, string, string, []plugininstances.Grant) error ApproveRelease(context.Context, string, string, string, []plugininstances.Grant) error } type transactionalAuthoringStore interface { authoringInstanceStore WithTransaction(context.Context, func(*sqlx.Tx) error) error CreateTx(context.Context, *sqlx.Tx, plugininstances.Instance) error CreateReleaseTx(context.Context, *sqlx.Tx, plugininstances.Release) error SetPluginIDTx(context.Context, *sqlx.Tx, string, string) error ActivateReleaseTx(context.Context, *sqlx.Tx, string, string) error ApproveReleaseTx(context.Context, *sqlx.Tx, string, string, string, []plugininstances.Grant) error } type reviewedPromotionStore interface { 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 { CreateReleaseIfAuthorityTx(context.Context, *sqlx.Tx, string, plugininstances.PublishAuthority, plugininstances.Release) error } type initialGrantStore interface { 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 { ConsumeCreationAuthorityTx(context.Context, *sqlx.Tx, CreationAuthority, string, string, string, bool) error } type legacyConditionalReleaseStore interface { CreateReleaseIfActiveReleaseTx(context.Context, *sqlx.Tx, string, string, plugininstances.Release) error } // releaseRetentionStore is optional so the authoring service remains // compatible with the narrow fakes used by lifecycle tests. The durable // plugin instance store implements it to remove superseded release ownership // after a publish. type releaseRetentionStore interface { PruneReleases(context.Context, string) error } type releaseRejectionStore interface { RejectReleaseAndPrune(context.Context, string, string) error } // PublishPackage stores one validated package as an immutable release. A // failed validation, quota check, or activation leaves the current active // pointer untouched. func (s *Service) PublishPackage(ctx context.Context, request PublishRequest) (*PublishResult, error) { if err := s.ready(); err != nil { return nil, err } store, ok := s.instances.(authoringInstanceStore) if !ok { return nil, ErrCanvasNotConfigured } instance, release, activated, creationAuthority, initialGrants, err := s.preparePublishedRelease(ctx, store, request) if err != nil { return nil, err } persisted, err := persistPublishedRelease(ctx, store, s.repo, request.CanvasID, instance.ID, release, activated, request.ExpectedAuthority, request.ExpectedBaseReleaseID, creationAuthority, initialGrants, request.SourceUserID, request.SourceUserSynthetic, request.SourceSessionID, request.SourceTaskID) if err != nil { if persisted { return &PublishResult{Release: release, Activated: activated, PermissionRequired: !activated, ReleasePersisted: true}, err } return nil, err } result := &PublishResult{Release: release, Activated: activated, PermissionRequired: !activated, ReleasePersisted: true} if err := prunePublishedReleases(ctx, store, instance.ID); err != nil { return result, err } resultCanvas, err := s.Get(ctx, request.CanvasID) if err != nil { return result, err } result.Canvas = resultCanvas eventType := EventReleasePermissionRequired if activated { eventType = EventReleaseActivated } s.mu.Lock() publisher := s.publisher s.mu.Unlock() publishEvent(ctx, publisher, lifecycleEvent(eventType, *resultCanvas)) return result, nil } // InstallCanvasPackage commits the canvas instance, its first release, and // the durable installation receipt in the same lifecycle transaction. The // artifact is written before entering the transaction and is owned by the // release row only after this method commits. func (s *Service) InstallCanvasPackage(ctx context.Context, request InstallCanvasPackageRequest) (*InstallResult, error) { if err := s.ready(); err != nil { return nil, err } store, createRequest, err := s.prepareCanvasInstall(request) if err != nil { return nil, err } s.mu.Lock() result, event, err := s.installCanvasPackageLocked(ctx, store, createRequest, request) publisher := s.publisher s.mu.Unlock() if err != nil { return result, err } publishEvent(ctx, publisher, event) return result, nil } func (s *Service) prepareCanvasInstall(request InstallCanvasPackageRequest) (transactionalAuthoringStore, CreateCanvasRequest, error) { if request.Package == nil || request.Package.Manifest == nil || request.Artifact.Digest == "" || s.repo == nil { return nil, CreateCanvasRequest{}, fmt.Errorf("%w: package and artifact are required", ErrInvalidCanvas) } if !request.Approved || strings.TrimSpace(request.Receipt.UserID) == "" { return nil, CreateCanvasRequest{}, ErrInstallNotApproved } store, ok := s.instances.(transactionalAuthoringStore) if !ok { return nil, CreateCanvasRequest{}, ErrCanvasNotConfigured } createRequest, err := normalizeCreateRequest(CreateCanvasRequest{WorkspaceID: request.WorkspaceID, Title: request.Title, PluginID: CanvasPluginID}) if err != nil { return nil, CreateCanvasRequest{}, err } return store, createRequest, nil } func (s *Service) installCanvasPackageLocked(ctx context.Context, store transactionalAuthoringStore, createRequest CreateCanvasRequest, request InstallCanvasPackageRequest) (*InstallResult, LifecycleEvent, error) { now := s.nowUTC() metadata := CanvasMetadata{ID: uuid.NewString(), PluginInstanceID: uuid.NewString(), WorkspaceID: createRequest.WorkspaceID, Title: createRequest.Title, CreatedAt: now, UpdatedAt: now} instance := plugininstances.Instance{ID: metadata.PluginInstanceID, PluginID: createRequest.PluginID, SourceKind: plugininstances.SourceLocalCanvas, ScopeKind: ScopeWorkspace, WorkspaceID: createRequest.WorkspaceID, Status: plugininstances.StatusPending, CreatedAt: now, UpdatedAt: now} publishRequest := PublishRequest{CanvasID: metadata.ID, Package: request.Package, Artifact: request.Artifact, SourceActorKind: request.SourceActorKind, SourceUserID: request.SourceUserID} preparedInstance, release, activated, err := s.preparePublishedReleaseForInstance(ctx, store, instance, publishRequest) if err != nil { return nil, LifecycleEvent{}, err } if preparedInstance.ID != instance.ID { return nil, LifecycleEvent{}, ErrInvalidCanvas } receipt := request.Receipt receipt.CanvasID = metadata.ID receipt.WorkspaceID = metadata.WorkspaceID if receipt.CreatedAt.IsZero() { receipt.CreatedAt = now } if err := s.commitCanvasInstallation(ctx, store, instance, metadata, release, activated, receipt, request.Package.Manifest); err != nil { return nil, LifecycleEvent{}, err } installed, err := s.Get(ctx, metadata.ID) if err != nil { return &InstallResult{Receipt: receipt}, LifecycleEvent{}, err } return &InstallResult{Canvas: installed, Receipt: receipt}, lifecycleEvent(EventReleaseActivated, *installed), nil } func (s *Service) commitCanvasInstallation(ctx context.Context, store transactionalAuthoringStore, instance plugininstances.Instance, metadata CanvasMetadata, release plugininstances.Release, activated bool, receipt InstallReceipt, canvasManifest *manifest.Manifest) error { return store.WithTransaction(ctx, func(tx *sqlx.Tx) error { if err := store.CreateTx(ctx, tx, instance); err != nil { return err } if err := s.repo.CreateTx(ctx, tx, metadata); err != nil { return err } if err := store.CreateReleaseTx(ctx, tx, release); err != nil { return err } if err := activateOrApproveCanvasRelease(ctx, store, tx, instance.ID, release, activated, receipt.UserID, canvasManifest); err != nil { return err } return s.repo.CreateInstallReceiptTx(ctx, tx, receipt) }) } func activateOrApproveCanvasRelease(ctx context.Context, store transactionalAuthoringStore, tx *sqlx.Tx, instanceID string, release plugininstances.Release, activated bool, userID string, canvasManifest *manifest.Manifest) error { if !activated { return store.ApproveReleaseTx(ctx, tx, instanceID, release.ID, userID, grantsForManifest(ManifestPermissions(canvasManifest), userID, ScopeWorkspace)) } if err := store.SetPluginIDTx(ctx, tx, instanceID, release.PluginID); err != nil { return err } return store.ActivateReleaseTx(ctx, tx, instanceID, release.ID) } func (s *Service) preparePublishedRelease(ctx context.Context, store authoringInstanceStore, request PublishRequest) (plugininstances.Instance, plugininstances.Release, bool, CreationAuthority, []plugininstances.Grant, error) { if err := validatePublishRequest(request); err != nil { return plugininstances.Instance{}, plugininstances.Release{}, false, CreationAuthority{}, nil, err } _, instance, err := s.load(ctx, request.CanvasID) if err != nil { return plugininstances.Instance{}, plugininstances.Release{}, false, CreationAuthority{}, nil, err } if err := validatePublishInstance(instance, request.ExpectedAuthority); err != nil { return plugininstances.Instance{}, plugininstances.Release{}, false, CreationAuthority{}, nil, err } permissions := ManifestPermissions(request.Package.Manifest) grants, releases, err := loadPublicationState(ctx, store, instance.ID) if err != nil { return plugininstances.Instance{}, plugininstances.Release{}, false, CreationAuthority{}, nil, err } creationAuthority, initialGrants, err := s.resolveCreationAuthority(ctx, request, instance, permissions, grants, releases) if err != nil { return plugininstances.Instance{}, plugininstances.Release{}, false, CreationAuthority{}, nil, err } activated := permissionsFit(permissions, instance.EffectiveDataScopeKind(), grants) if creationAuthority.CanvasID != "" { activated = true } release, err := s.buildPublishedRelease(request, instance.ID, permissions, activated) if err != nil { return plugininstances.Instance{}, plugininstances.Release{}, false, CreationAuthority{}, nil, err } return instance, release, activated, creationAuthority, initialGrants, nil } func (s *Service) preparePublishedReleaseForInstance(ctx context.Context, store authoringInstanceStore, instance plugininstances.Instance, request PublishRequest) (plugininstances.Instance, plugininstances.Release, bool, error) { if request.Package == nil || request.Package.Manifest == nil || request.Artifact.Digest == "" { return plugininstances.Instance{}, plugininstances.Release{}, false, fmt.Errorf("%w: package and artifact are required", ErrInvalidCanvas) } if instance.Status == StatusRemoved || instance.Status == StatusArchived { return plugininstances.Instance{}, plugininstances.Release{}, false, fmt.Errorf("%w: canvas is %s", ErrInvalidCanvasState, instance.Status) } if !request.ExpectedAuthority.IsZero() && request.ExpectedAuthority != instance.PublishAuthority() { return plugininstances.Instance{}, plugininstances.Release{}, false, ErrStaleCanvasPublish } if request.Artifact.Digest != request.Package.Digest { return plugininstances.Instance{}, plugininstances.Release{}, false, fmt.Errorf("%w: artifact digest does not match package", ErrInvalidCanvas) } if err := request.Package.Manifest.Validate(); err != nil { return plugininstances.Instance{}, plugininstances.Release{}, false, fmt.Errorf("%w: invalid manifest: %v", ErrInvalidCanvas, err) } permissions := ManifestPermissions(request.Package.Manifest) grants, err := store.ListGrants(ctx, instance.ID) if err != nil { return plugininstances.Instance{}, plugininstances.Release{}, false, err } activated := permissionsFit(permissions, instance.EffectiveDataScopeKind(), grants) release, err := s.buildPublishedRelease(request, instance.ID, permissions, activated) if err != nil { return plugininstances.Instance{}, plugininstances.Release{}, false, err } return instance, release, activated, nil } func validatePublishRequest(request PublishRequest) error { if request.Package == nil || request.Package.Manifest == nil || request.Artifact.Digest == "" { return fmt.Errorf("%w: package and artifact are required", ErrInvalidCanvas) } if request.Artifact.Digest != request.Package.Digest { return fmt.Errorf("%w: artifact digest does not match package", ErrInvalidCanvas) } if err := request.Package.Manifest.Validate(); err != nil { return fmt.Errorf("%w: invalid manifest: %v", ErrInvalidCanvas, err) } return validateLocalCanvasManifest(request.Package.Manifest) } func validatePublishInstance(instance plugininstances.Instance, expectedAuthority plugininstances.PublishAuthority) error { if instance.Status == StatusRemoved || instance.Status == StatusArchived { return fmt.Errorf("%w: canvas is %s", ErrInvalidCanvasState, instance.Status) } if !expectedAuthority.IsZero() && expectedAuthority != instance.PublishAuthority() { return ErrStaleCanvasPublish } return nil } func loadPublicationState(ctx context.Context, store authoringInstanceStore, instanceID string) ([]plugininstances.Grant, []plugininstances.Release, error) { grants, err := store.ListGrants(ctx, instanceID) if err != nil { return nil, nil, err } releases, err := store.ListReleases(ctx, instanceID) if err != nil { return nil, nil, err } return grants, releases, nil } func (s *Service) resolveCreationAuthority(ctx context.Context, request PublishRequest, instance plugininstances.Instance, permissions PermissionSummary, grants []plugininstances.Grant, releases []plugininstances.Release) (CreationAuthority, []plugininstances.Grant, error) { if request.ExpectedAuthority.IsZero() { return CreationAuthority{}, nil, nil } candidate, err := s.repo.GetCreationAuthority(ctx, request.CanvasID) if err != nil { if errors.Is(err, ErrCreationAuthorityNotFound) { return CreationAuthority{}, nil, nil } return CreationAuthority{}, nil, err } if !creationAuthorityEligible(candidate, instance, request, grants, releases) { return CreationAuthority{}, nil, 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) { manifestJSON, err := json.Marshal(request.Package.Manifest) if err != nil { return plugininstances.Release{}, fmt.Errorf("marshal canvas manifest: %w", err) } permissionsJSON, err := json.Marshal(permissions) if err != nil { return plugininstances.Release{}, fmt.Errorf("marshal canvas permissions: %w", err) } validationStatus, validationError := plugininstances.ValidationPendingPermission, "permission_review_required" if activated { validationStatus, validationError = plugininstances.ValidationValid, "" } return plugininstances.Release{ ID: uuid.NewString(), PluginID: request.Package.Manifest.ID, InstanceID: instanceID, PackageDigest: request.Package.Digest, SourceKind: plugininstances.SourceLocalCanvas, SourceActorKind: strings.TrimSpace(request.SourceActorKind), SourceUserID: strings.TrimSpace(request.SourceUserID), SourceTaskID: strings.TrimSpace(request.SourceTaskID), SourceSessionID: strings.TrimSpace(request.SourceSessionID), ManifestJSON: manifestJSON, DeclaredPermissionsJSON: permissionsJSON, ArtifactPath: request.Artifact.RelativePath, ArtifactBytes: request.Artifact.Bytes, ProtocolVersion: 1, ValidationStatus: validationStatus, ValidationError: validationError, CreatedAt: s.nowUTC(), }, nil } func persistPublishedRelease(ctx context.Context, store authoringInstanceStore, authorityStore creationAuthorityStore, canvasID, instanceID string, release plugininstances.Release, activated bool, expectedAuthority plugininstances.PublishAuthority, expectedBaseReleaseID string, creationAuthority CreationAuthority, initialGrants []plugininstances.Grant, ownerUserID string, allowUnownedWorkspace bool, sessionID, taskID string) (bool, error) { if !expectedAuthority.IsZero() { if expectedBaseReleaseID != "" && expectedBaseReleaseID != expectedAuthority.ActiveReleaseID { return false, ErrStaleCanvasEdit } transactional, ok := store.(transactionalAuthoringStore) conditional, supportsConditional := store.(conditionalReleaseStore) if !ok || !supportsConditional { return false, ErrStaleCanvasPublish } 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 } if expectedBaseReleaseID != "" { transactional, ok := store.(transactionalAuthoringStore) conditional, supportsConditional := store.(legacyConditionalReleaseStore) if !ok || !supportsConditional { return false, ErrStaleCanvasEdit } err := persistConditionalRelease(ctx, transactional, conditional, instanceID, release, activated, expectedBaseReleaseID) return err == nil, err } if !activated { if err := store.CreateRelease(ctx, release); err != nil { return false, err } return true, nil } transactional, ok := store.(transactionalAuthoringStore) if ok { err := persistActivatedRelease(ctx, transactional, instanceID, release) return err == nil, err } return persistActivatedReleaseFallback(ctx, store, instanceID, release) } func persistAuthorityRelease(ctx context.Context, transactional transactionalAuthoringStore, conditional conditionalReleaseStore, authorityStore creationAuthorityStore, canvasID, instanceID string, release plugininstances.Release, activated bool, expectedAuthority plugininstances.PublishAuthority, creationAuthority CreationAuthority, initialGrants []plugininstances.Grant, ownerUserID string, allowUnownedWorkspace bool, sessionID, taskID string) error { return transactional.WithTransaction(ctx, func(tx *sqlx.Tx) error { if err := conditional.CreateReleaseIfAuthorityTx(ctx, tx, instanceID, expectedAuthority, release); err != nil { return err } if !activated { return nil } if creationAuthority.CanvasID != "" { grantStore, ok := transactional.(initialGrantStore) if !ok || authorityStore == nil || creationAuthority.CanvasID != canvasID { return ErrStaleCanvasPublish } if err := authorityStore.ConsumeCreationAuthorityTx(ctx, tx, creationAuthority, ownerUserID, sessionID, taskID, allowUnownedWorkspace); err != nil { return err } 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 } } if err := transactional.SetPluginIDTx(ctx, tx, instanceID, release.PluginID); err != nil { return err } return transactional.ActivateReleaseTx(ctx, tx, instanceID, release.ID) }) } func persistConditionalRelease(ctx context.Context, transactional transactionalAuthoringStore, conditional legacyConditionalReleaseStore, instanceID string, release plugininstances.Release, activated bool, expectedBaseReleaseID string) error { return transactional.WithTransaction(ctx, func(tx *sqlx.Tx) error { if err := conditional.CreateReleaseIfActiveReleaseTx(ctx, tx, instanceID, expectedBaseReleaseID, release); err != nil { return err } if !activated { return nil } if err := transactional.SetPluginIDTx(ctx, tx, instanceID, release.PluginID); err != nil { return err } return transactional.ActivateReleaseTx(ctx, tx, instanceID, release.ID) }) } func persistActivatedRelease(ctx context.Context, store transactionalAuthoringStore, instanceID string, release plugininstances.Release) error { return store.WithTransaction(ctx, func(tx *sqlx.Tx) error { if err := store.CreateReleaseTx(ctx, tx, release); err != nil { return err } if err := store.SetPluginIDTx(ctx, tx, instanceID, release.PluginID); err != nil { return err } return store.ActivateReleaseTx(ctx, tx, instanceID, release.ID) }) } func persistActivatedReleaseFallback(ctx context.Context, store authoringInstanceStore, instanceID string, release plugininstances.Release) (bool, error) { if err := store.CreateRelease(ctx, release); err != nil { return false, err } if err := store.SetPluginID(ctx, instanceID, release.PluginID); err != nil { return true, err } if err := store.ActivateRelease(ctx, instanceID, release.ID); err != nil { return true, err } return true, nil } // Releases returns release history for a canvas after checking its durable // metadata binding. func (s *Service) Releases(ctx context.Context, canvasID string) ([]plugininstances.Release, error) { if err := s.ready(); err != nil { return nil, err } store, ok := s.instances.(authoringInstanceStore) if !ok { return nil, ErrCanvasNotConfigured } _, instance, err := s.load(ctx, canvasID) if err != nil { return nil, err } return store.ListReleases(ctx, instance.ID) } // PromotionPreview builds the human review projection for a task-scoped // canvas. It does not mutate scope, grants, or release state. func (s *Service) PromotionPreview(ctx context.Context, canvasID string) (*PromotionPreview, 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.ActiveReleaseID == "" || canvas.ActiveReleaseStatus != ValidationValid { return nil, fmt.Errorf("%w: canvas must have a valid task-scoped release", ErrInvalidCanvasState) } release, err := store.GetRelease(ctx, canvas.ActiveReleaseID) if err != nil { return nil, err } m, err := manifestFromRelease(release) if err != nil { return nil, err } return &PromotionPreview{ Canvas: canvas, SourceActorKind: release.SourceActorKind, SourceUserID: release.SourceUserID, SourceTaskID: release.SourceTaskID, SourceSessionID: release.SourceSessionID, Permissions: ManifestPermissions(m), ActiveReleaseID: release.ID, PermissionDigest: PermissionDigest(release), GrantGeneration: canvas.GrantGeneration, CurrentScope: ScopeTask, TargetScope: ScopeWorkspace, CurrentDataScope: canvas.DataScopeKind, TargetDataScope: ScopeWorkspace, Placement: manifest.WebAppPlacementWorkspace, }, 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 // instance scope with the canvas metadata provenance. The durable store // transaction keeps both lifecycle authorities consistent after a crash. func (s *Service) PromoteCanvas(ctx context.Context, canvasID, userID string) (*Canvas, error) { return s.PromoteCanvasReviewed(ctx, canvasID, userID, "", "", 0) } // PromoteCanvasReviewed atomically applies a promotion only when the release // and permission projection still match the human review response. func (s *Service) PromoteCanvasReviewed(ctx context.Context, canvasID, userID, expectedReleaseID, expectedPermissionDigest string, expectedGrantGeneration int64) (*Canvas, error) { if err := s.ready(); err != nil { return nil, err } store, ok := s.instances.(authoringInstanceStore) if !ok { return nil, ErrCanvasNotConfigured } if strings.TrimSpace(userID) == "" { return nil, fmt.Errorf("%w: approving user is required", ErrInvalidCanvas) } preview, err := s.PromotionPreview(ctx, canvasID) if err != nil { return nil, err } grants := grantsForManifest(preview.Permissions, userID, ScopeWorkspace) promotedAt := s.nowUTC() if err := s.promoteInstanceForReview(ctx, store, preview, canvasID, userID, grants, promotedAt, expectedReleaseID, expectedPermissionDigest, expectedGrantGeneration); err != nil { return nil, err } updated, err := s.Get(ctx, canvasID) if err != nil { return nil, err } result := canvasScopePreserved if preview.CurrentDataScope != ScopeWorkspace && updated.DataScopeKind == ScopeWorkspace { result = canvasScopeExpanded } recordCanvasScopeTransition(canvasScopePromotion, result) s.mu.Lock() publisher := s.publisher s.mu.Unlock() publishEvent(ctx, publisher, lifecycleEvent(EventPromoted, *updated)) return updated, nil } func (s *Service) promoteInstanceForReview(ctx context.Context, store authoringInstanceStore, preview *PromotionPreview, canvasID, userID string, grants []plugininstances.Grant, promotedAt time.Time, expectedReleaseID, expectedPermissionDigest string, expectedGrantGeneration int64) error { reviewed := expectedReleaseID != "" || expectedPermissionDigest != "" || expectedGrantGeneration > 0 transactional, isTransactional := store.(transactionalPluginInstanceStore) if isTransactional { err := transactional.WithTransaction(ctx, func(tx *sqlx.Tx) error { if err := promoteInstanceTx(ctx, tx, transactional, preview, userID, grants, reviewed, expectedReleaseID, expectedPermissionDigest, expectedGrantGeneration); err != nil { return err } return s.repo.PromoteTx(ctx, tx, canvasID, userID, promotedAt) }) return err } if reviewed { return ErrStalePromotionReview } if err := store.PromoteScopeAndGrants(ctx, preview.Canvas.PluginInstanceID, preview.Canvas.WorkspaceID, userID, grants); err != nil { return err } if err := s.repo.Promote(ctx, canvasID, userID, promotedAt); err != nil { return errors.Join(ErrCanvasMetadataBroken, err) } return nil } func promoteInstanceTx(ctx context.Context, tx *sqlx.Tx, store transactionalPluginInstanceStore, preview *PromotionPreview, userID string, grants []plugininstances.Grant, reviewed bool, expectedReleaseID, expectedPermissionDigest string, expectedGrantGeneration int64) error { if reviewed { reviewedStore, ok := store.(reviewedPromotionStore) if !ok { return ErrStalePromotionReview } return reviewedStore.PromoteScopeAndGrantsReviewedTx(ctx, tx, preview.Canvas.PluginInstanceID, preview.Canvas.WorkspaceID, userID, grants, expectedReleaseID, expectedPermissionDigest, expectedGrantGeneration) } return store.PromoteScopeAndGrantsTx(ctx, tx, preview.Canvas.PluginInstanceID, preview.Canvas.WorkspaceID, userID, grants) } // ApproveRelease grants the requested permissions and activates one pending // release without changing the canvas scope. func (s *Service) ApproveRelease(ctx context.Context, canvasID, releaseID, userID string) (*Canvas, 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 err := releaseMutationStateError(canvas.Status); err != nil { return nil, err } release, err := store.GetRelease(ctx, releaseID) if err != nil { return nil, err } if release.InstanceID != canvas.PluginInstanceID { return nil, plugininstances.ErrInvalidRelease } m, err := manifestFromRelease(release) if err != nil { return nil, err } if err := store.ApproveRelease(ctx, canvas.PluginInstanceID, releaseID, userID, grantsForManifest(ManifestPermissions(m), userID, canvas.DataScopeKind)); err != nil { return nil, err } if err := prunePublishedReleases(ctx, store, canvas.PluginInstanceID); err != nil { return nil, err } 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(EventReleaseActivated, *updated)) return updated, nil } // RejectRelease keeps the active release untouched and records a safe // rejection diagnostic on the pending release. func (s *Service) RejectRelease(ctx context.Context, canvasID, releaseID string) (*Canvas, 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 err := releaseMutationStateError(canvas.Status); err != nil { return nil, err } release, err := store.GetRelease(ctx, releaseID) if err != nil { return nil, err } if release.InstanceID != canvas.PluginInstanceID || release.ValidationStatus != ValidationPendingPermission { return nil, plugininstances.ErrInvalidRelease } if rejection, ok := store.(releaseRejectionStore); ok { if err := rejection.RejectReleaseAndPrune(ctx, canvas.PluginInstanceID, releaseID); err != nil { return nil, err } } else { if err := store.SetReleaseValidation(ctx, releaseID, plugininstances.ValidationInvalid, "rejected_by_user"); err != nil { return nil, err } if err := prunePublishedReleases(ctx, store, canvas.PluginInstanceID); err != nil { return nil, err } } return s.Get(ctx, canvasID) } // RollbackRelease selects a retained valid release and rechecks its required // permissions against current grants before moving the active pointer. func (s *Service) RollbackRelease(ctx context.Context, canvasID, releaseID string) (*Canvas, 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 err := releaseMutationStateError(canvas.Status); err != nil { return nil, err } instance, err := store.Get(ctx, canvas.PluginInstanceID) if err != nil { return nil, err } releases, err := store.ListReleases(ctx, instance.ID) if err != nil { return nil, err } release, err := selectRollbackRelease(ctx, store, instance, releases, releaseID) if err != nil { return nil, err } m, err := manifestFromRelease(release) if err != nil { return nil, err } grants, err := store.ListGrants(ctx, instance.ID) if err != nil { return nil, err } if !permissionsFit(ManifestPermissions(m), instance.EffectiveDataScopeKind(), grants) { return nil, fmt.Errorf("%w: rollback requires permission review", plugininstances.ErrInvalidRelease) } if err := store.ActivateRelease(ctx, instance.ID, release.ID); err != nil { return nil, err } if err := prunePublishedReleases(ctx, store, instance.ID); err != nil { return nil, err } 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(EventReleaseActivated, *updated)) return updated, nil } func selectRollbackRelease(ctx context.Context, store authoringInstanceStore, instance plugininstances.Instance, releases []plugininstances.Release, releaseID string) (plugininstances.Release, error) { if releaseID == "" { for _, candidate := range releases { if candidate.ID != instance.ActiveReleaseID && candidate.ValidationStatus == ValidationValid { releaseID = candidate.ID break } } } if releaseID == "" { return plugininstances.Release{}, fmt.Errorf("%w: no retained valid release", plugininstances.ErrInvalidRelease) } release, err := store.GetRelease(ctx, releaseID) if err != nil { return plugininstances.Release{}, err } if release.InstanceID != instance.ID || release.ID == instance.ActiveReleaseID || release.ValidationStatus != ValidationValid { return plugininstances.Release{}, plugininstances.ErrInvalidRelease } return release, nil } func prunePublishedReleases(ctx context.Context, store authoringInstanceStore, instanceID string) error { retention, ok := store.(releaseRetentionStore) if !ok { return nil } return retention.PruneReleases(ctx, instanceID) } func releaseMutationStateError(status string) error { if status == StatusArchived || status == StatusDisabled { return fmt.Errorf("%w: canvas is %s", ErrInvalidCanvasState, status) } return nil } // ManifestPermissions converts the manifest into the review/runtime shape. func ManifestPermissions(m *manifest.Manifest) PermissionSummary { if m == nil { return PermissionSummary{} } origins := nonEmptyStrings([]string{m.BaseURL}) seenOrigins := make(map[string]struct{}, len(origins)) for _, origin := range origins { seenOrigins[origin] = struct{}{} } for _, app := range m.UI.WebApps { normalized, err := webapp.NormalizeNetworkOrigins(app.NetworkOrigins) if err != nil { continue } for _, origin := range normalized { if _, exists := seenOrigins[origin]; exists { continue } seenOrigins[origin] = struct{}{} origins = append(origins, origin) } } return PermissionSummary{ Reads: append([]string(nil), m.Capabilities.APIRead...), Writes: append([]string(nil), m.Capabilities.APIWrite...), Events: append([]string(nil), m.Capabilities.Events...), SharedState: m.Capabilities.State, ExternalOrigins: origins, } } func manifestFromRelease(release plugininstances.Release) (*manifest.Manifest, error) { var m manifest.Manifest if err := json.Unmarshal(release.ManifestJSON, &m); err != nil { return nil, fmt.Errorf("parse release manifest: %w", err) } return &m, nil } // ReleasePermissionSummary returns the review-safe declaration persisted with // a release. Older rows used a flat permission-key array, which remains // readable so a release review never falls back to an empty declaration. func ReleasePermissionSummary(release plugininstances.Release) PermissionSummary { var summary PermissionSummary if err := json.Unmarshal(release.DeclaredPermissionsJSON, &summary); err == nil { return summary } var legacy []string if err := json.Unmarshal(release.DeclaredPermissionsJSON, &legacy); err != nil { return summary } for _, permission := range legacy { parts := strings.SplitN(permission, ":", 2) if len(parts) != 2 { continue } switch parts[0] { case permissionKindAPIRead: summary.Reads = append(summary.Reads, parts[1]) case permissionKindAPIWrite: summary.Writes = append(summary.Writes, parts[1]) case permissionKindEvents: summary.Events = append(summary.Events, parts[1]) case permissionKindNetwork: summary.ExternalOrigins = append(summary.ExternalOrigins, parts[1]) case permissionKindState: summary.SharedState = true } } return summary } // PermissionDigest returns the digest of the exact persisted declaration. It // is safe to show to a reviewer and lets confirmation reject a changed // declaration without exposing its manifest or source package. func PermissionDigest(release plugininstances.Release) string { digest := sha256.Sum256([]byte(strings.TrimSpace(string(release.DeclaredPermissionsJSON)))) return fmt.Sprintf("%x", digest[:]) } // MissingPermissionKeys reports declaration keys not covered by the current // grants at the release scope. The result is sorted for stable API output. func MissingPermissionKeys(summary PermissionSummary, scope string, grants []plugininstances.Grant) []string { missing := make([]string, 0) for _, permission := range permissionKeys(summary) { if !grantCovers(permission, scope, grants) { missing = append(missing, permission) } } sort.Strings(missing) return missing } func permissionKeys(summary PermissionSummary) []string { keys := make([]string, 0, len(summary.Reads)+len(summary.Writes)+len(summary.Events)+len(summary.ExternalOrigins)+1) for _, value := range summary.Reads { keys = append(keys, permissionKindAPIRead+":"+value) } for _, value := range summary.Writes { keys = append(keys, permissionKindAPIWrite+":"+value) } for _, value := range summary.Events { keys = append(keys, permissionKindEvents+":"+value) } for _, value := range summary.ExternalOrigins { keys = append(keys, permissionKindNetwork+":"+value) } if summary.SharedState { keys = append(keys, permissionKindState) } return keys } func permissionsFit(summary PermissionSummary, scope string, grants []plugininstances.Grant) bool { if len(permissionKeys(summary)) == 0 { return true } for _, required := range permissionKeys(summary) { if !grantCovers(required, scope, grants) { return false } } return true } func creationAuthorityEligible(authority CreationAuthority, instance plugininstances.Instance, request PublishRequest, grants []plugininstances.Grant, releases []plugininstances.Release) bool { return creationAuthorityPolicyIsCurrent(authority) && creationAuthorityInstanceMatches(authority, instance, grants, releases) && creationAuthoritySourceMatches(authority, instance, request) } func validateLocalCanvasManifest(m *manifest.Manifest) error { if !localCanvasUIIsValid(m) { return invalidLocalCanvasManifest() } if !localCanvasIntegrationsAreValid(m) { return invalidLocalCanvasManifest() } if !localCanvasCapabilitiesAreValid(m) { return invalidLocalCanvasManifest() } return nil } func localCanvasUIIsValid(m *manifest.Manifest) bool { return m.IsStaticWebAppOnly() && len(m.UI.Pages) == 0 && m.UI.Bundle == "" && len(m.UI.Styles) == 0 && len(m.UI.Keybindings) == 0 } func localCanvasIntegrationsAreValid(m *manifest.Manifest) bool { return len(m.Webhooks) == 0 && len(m.Actions) == 0 && len(m.RepositoryProviders) == 0 && len(m.ReferenceSources) == 0 && len(m.AuthProviders) == 0 && len(m.AgentTools) == 0 } func localCanvasCapabilitiesAreValid(m *manifest.Manifest) bool { return !m.Capabilities.Secrets && !m.Capabilities.AgentInvoke && !m.Capabilities.Auth && !m.Capabilities.UserState && supportedCanvasCapabilityValues(m.Capabilities.APIRead, permissionKindAPIRead) && supportedCanvasCapabilityValues(m.Capabilities.APIWrite, permissionKindAPIWrite) && supportedCanvasCapabilityValues(m.Capabilities.Events, permissionKindEvents) } func supportedCanvasCapabilityValues(values []string, kind string) bool { for _, value := range values { if !supportedCanvasCapability(kind, value) { return false } } return true } func supportedCanvasCapability(kind, value string) bool { switch kind { case permissionKindAPIRead: return value == canvasCapabilityTasks || value == canvasCapabilityWorkflows case permissionKindAPIWrite: return value == canvasCapabilityTasks || value == canvasCapabilityMessages case permissionKindEvents: return value == canvasCapabilityTaskUpdate default: return false } } func invalidLocalCanvasManifest() error { return fmt.Errorf("%w: canvas packages support only static web applications and task-scoped data permissions", ErrInvalidCanvas) } func creationAuthorityPolicyIsCurrent(authority CreationAuthority) bool { 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 { 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 { return request.SourceActorKind == "agent" && request.SourceUserID == authority.OwnerUserID && request.SourceTaskID == instance.TaskID && request.SourceSessionID == authority.CreatingSessionID } func grantCovers(permission, scope string, grants []plugininstances.Grant) bool { parts := strings.SplitN(permission, ":", 2) kind, resource := parts[0], "" if len(parts) == 2 { resource = parts[1] } for _, grant := range grants { if kind == permissionKindNetwork { if grant.PermissionKind == kind && grant.NetworkOrigin == resource && grantScopeCovers(grant.ScopeCeiling, scope) { return true } continue } if grant.PermissionKind == kind && grant.Resource == resource && grantScopeCovers(grant.ScopeCeiling, scope) { return true } } return false } func grantScopeCovers(ceiling, scope string) bool { return ceiling == plugininstances.ScopeInstance || ceiling == scope } func grantsForManifest(summary PermissionSummary, userID, scope string) []plugininstances.Grant { grantScope := scope if grantScope == "" { grantScope = ScopeWorkspace } now := time.Now().UTC() grants := make([]plugininstances.Grant, 0, len(permissionKeys(summary))) for _, permission := range permissionKeys(summary) { parts := strings.SplitN(permission, ":", 2) resource := "" networkOrigin := "" if len(parts) == 2 { resource = parts[1] if parts[0] == permissionKindNetwork { networkOrigin = resource resource = "" } } grants = append(grants, plugininstances.Grant{ PermissionKind: parts[0], Resource: resource, NetworkOrigin: networkOrigin, ScopeCeiling: grantScope, ApprovedBy: userID, ApprovedAt: now, }) } return grants } func nonEmptyStrings(values []string) []string { result := make([]string, 0, len(values)) for _, value := range values { if strings.TrimSpace(value) != "" && !containsString(result, value) { result = append(result, value) } } return result } func containsString(values []string, target string) bool { for _, value := range values { if value == target { return true } } return false }